File size: 8,646 Bytes
93d288c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
93d288c
 
 
 
 
 
a1f52bf
93d288c
a1f52bf
93d288c
 
a1f52bf
93d288c
a1f52bf
93d288c
a1f52bf
93d288c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93d288c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93d288c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
"""Offline tests for wavereader.climate (Worker B slice). No network."""

import json
import sys
from pathlib import Path

# Bootstrap: repo root on sys.path so `wavereader.climate` imports without
# requiring Worker A's `wavereader/__init__.py` (absent on v2-climate branch).
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

import pytest

from wavereader.climate import (
    COMPASS16,
    audit_break,
    break_climate,
    build_profile,
    climate_rose_fig,
    compute_monthly_stats,
    compute_rose,
    direction_rose,
    direction_to_bin,
    dominant_directions,
    filter_existing_slugs,
    ideal_window_label,
    load_climate,
    monthly_medians,
    monthly_window_pct,
    month_to_season,
    rose_top_share,
    slugify,
    summarize,
    top_months_by_window,
    top_rose_bins,
)

FIXTURE = Path(__file__).parent / "fixtures" / "climate_bells.json"

BELLS_BREAK = {
    "id": "Bells Beach | Victoria | Surf Coast",
    "name": "Bells Beach",
    "idealSwell": {"direction": ["SW", "S", "SSW"], "sizeRangeFt": {"min": 4, "max": 12}},
    "bestSeason": ["autumn", "winter"],
}


@pytest.fixture()
def bells_climate():
    return json.loads(FIXTURE.read_text(encoding="utf-8"))


def test_direction_to_bin_edges():
    assert direction_to_bin(0) == "N"
    assert direction_to_bin(360) == "N"
    assert direction_to_bin(22.5) == "NNE"
    assert direction_to_bin(225) == "SW"
    assert direction_to_bin(359) == "N"


def test_compute_rose_known_distribution():
    rose = compute_rose([90.0] * 50 + [180.0] * 25 + [None, None])
    assert rose["E"] == pytest.approx(66.67, abs=0.01)
    assert rose["S"] == pytest.approx(33.33, abs=0.01)
    assert sum(rose.values()) == pytest.approx(100.0, abs=0.1)
    assert set(rose) == set(COMPASS16)


def test_compute_rose_empty_and_nulls():
    assert all(v == 0.0 for v in compute_rose([]).values())
    assert all(v == 0.0 for v in compute_rose([None, None]).values())
    assert sum(compute_rose([None, 45.0]).values()) == pytest.approx(100.0)


def test_direction_rose_accessor(bells_climate):
    rose = direction_rose(bells_climate)
    assert set(rose) == set(COMPASS16)
    assert sum(rose.values()) == pytest.approx(100.0, abs=0.5)
    assert top_rose_bins(bells_climate, 3) == ["SW", "S", "SSW"]


def test_monthly_medians_accessor(bells_climate):
    med = monthly_medians(bells_climate)
    assert set(med) == set(range(1, 13))
    assert med[6]["median_swell_height_m"] == pytest.approx(2.5)
    assert med[1]["median_swell_period_s"] == pytest.approx(10.5)


def test_monthly_stats_grouping_and_window():
    dates = ["2021-01-05", "2021-01-20", "2021-06-10", "bad-date"]
    # window 1.0m..2.0m
    stats = compute_monthly_stats(dates, [1.5, 5.0, 1.2], [10.0, 11.0, 12.0], 1.0, 2.0)
    assert stats[1]["median_swell_height_m"] == pytest.approx(3.25)
    assert stats[1]["days_in_ideal_window"] == 1
    assert stats[6]["days_in_ideal_window"] == 1
    assert stats[6]["n_days"] == 1
    assert stats[2]["n_days"] == 0
    assert stats[2]["median_swell_height_m"] is None


def test_top_months_by_window(bells_climate):
    assert top_months_by_window(bells_climate, 3) == [6, 7, 5]


def test_monthly_window_pct(bells_climate):
    pct = monthly_window_pct(bells_climate)
    assert set(pct) == set(range(1, 13))
    assert pct[10] == pytest.approx(22.6, abs=0.01)  # 35/155, rounded to 0.1
    assert pct[6] == pytest.approx(53.3, abs=0.01)   # 80/150
    empty = {"monthly": {"3": {"days_in_ideal_window": 0, "n_days": 0}}}
    assert monthly_window_pct(empty)[3] is None


def test_dominant_directions_and_top_share(bells_climate):
    assert dominant_directions(bells_climate, 2) == [("SW", 28.0), ("S", 22.0)]
    assert rose_top_share(bells_climate, 2) == pytest.approx(50.0)
    assert rose_top_share({"direction_rose_pct": {}}) == 0.0


def test_ideal_window_label(bells_climate):
    assert ideal_window_label(bells_climate) == "4–12 ft"
    assert ideal_window_label({"ideal_window_ft": {"min": None, "max": None}}) == ""
    assert ideal_window_label({}) == ""


def test_summarize_digest(bells_climate):
    s = summarize(bells_climate)
    assert s["ideal_window_ft"] == "4–12 ft"
    assert s["dominant_directions"] == [["SW", 28.0], ["S", 22.0]]
    assert s["top_directions_share_pct"] == pytest.approx(50.0)
    # by share of in-window days: 6 (53.3%) > 7 (48.4%) > 5 (45.2%)
    assert s["best_months_by_share"] == [6, 7, 5]
    assert set(s["monthly_window_pct"]) == set(range(1, 13))
    assert s["monthly_window_pct"][6] == pytest.approx(53.3, abs=0.1)
    json.dumps(s)  # must stay JSON-serializable for the agent tool surface


def test_month_to_season():
    assert month_to_season(1) == "summer"
    assert month_to_season(4) == "autumn"
    assert month_to_season(7) == "winter"
    assert month_to_season(10) == "spring"
    with pytest.raises(ValueError):
        month_to_season(13)


def test_audit_agreement_bells(bells_climate):
    # Dataset SW/S/SSW == rose top-3; autumn/winter covers top months 6,7,5.
    assert audit_break(BELLS_BREAK, bells_climate) == []


def test_audit_direction_disagreement(bells_climate):
    bad = {**BELLS_BREAK, "idealSwell": {"direction": ["N", "NE"]}}
    findings = audit_break(bad, bells_climate)
    by_field = {f["field"]: f for f in findings}
    assert by_field["idealSwell.direction"]["severity"] == "high"
    assert by_field["idealSwell.direction"]["dataset_value"] == ["N", "NE"]
    assert by_field["idealSwell.direction"]["climate_value"] == ["SW", "S", "SSW"]
    # season still agrees -> no season finding
    assert "bestSeason" not in by_field


def test_audit_season_disagreement(bells_climate):
    bad = {**BELLS_BREAK, "bestSeason": ["summer"]}
    findings = audit_break(bad, bells_climate)
    by_field = {f["field"]: f for f in findings}
    assert by_field["bestSeason"]["severity"] == "medium"
    assert by_field["bestSeason"]["dataset_value"] == ["summer"]
    assert by_field["bestSeason"]["climate_value"] == [6, 7, 5]
    assert "idealSwell.direction" not in by_field


def test_audit_no_data_returns_empty():
    empty = {"n_valid_direction": 0, "direction_rose_pct": {}, "monthly": {}}
    assert audit_break(BELLS_BREAK, empty) == []


def test_slugify():
    assert slugify("Bells Beach | Victoria | Surf Coast") == "bells-beach-victoria-surf-coast"
    assert slugify("Avoca Beach | New South Wales | Central Coast") == (
        "avoca-beach-new-south-wales-central-coast"
    )


def test_load_climate_fixture(bells_climate):
    loaded = load_climate("climate_bells", Path(__file__).parent / "fixtures")
    assert loaded == bells_climate
    with pytest.raises(FileNotFoundError):
        load_climate("nope", Path(__file__).parent / "fixtures")


def test_break_climate_prefers_embedded(bells_climate, tmp_path):
    break_ = dict(BELLS_BREAK, state="Victoria", region="Surf Coast", climate=bells_climate)
    # embedded copy wins even when the fallback dir holds nothing
    assert break_climate(break_, tmp_path) == bells_climate


def test_break_climate_file_fallback(bells_climate, tmp_path):
    import shutil

    shutil.copy(FIXTURE, tmp_path / "bells-beach-victoria-surf-coast.json")
    break_ = {k: v for k, v in BELLS_BREAK.items() if k != "id"}
    break_["state"] = "Victoria"
    break_["region"] = "Surf Coast"
    assert break_climate(break_, tmp_path) == bells_climate
    with pytest.raises(FileNotFoundError):
        break_climate({"name": "Nowhere", "state": "X", "region": "Y"}, tmp_path)


def test_filter_existing_slugs_resumable(tmp_path):
    (tmp_path / "a.json").write_text("{}", encoding="utf-8")
    assert filter_existing_slugs(["a", "b"], tmp_path) == ["b"]
    assert filter_existing_slugs(["a", "b"], tmp_path, overwrite=True) == ["a", "b"]


def test_build_profile_roundtrip():
    dates = ["2021-01-01", "2021-01-02", "2021-07-01"]
    prof = build_profile(
        slug="x", break_id="X", name="X", latitude=-38.5, longitude=144.3,
        start="2021-01-01", end="2021-12-31", dates=dates,
        heights_m=[1.5, None, 2.0], directions_deg=[225.0, 225.0, None],
        periods_s=[12.0, 11.0, 13.0], window_min_ft=4, window_max_ft=12,
    )
    assert prof["n_days"] == 3
    assert prof["n_valid_direction"] == 2
    assert prof["direction_rose_pct"]["SW"] == pytest.approx(100.0)
    assert prof["monthly"]["1"]["n_days"] == 2


def test_climate_rose_fig(bells_climate):
    fig = climate_rose_fig(bells_climate, "Bells Beach")
    assert len(fig.data) == 1
    assert len(fig.data[0].r) == 16
    assert list(fig.data[0].theta) == COMPASS16
    assert max(fig.data[0].r) == pytest.approx(28.0)