import json from datetime import date, datetime import numpy as np import pandas as pd from damage_engine.detectors import snow from damage_engine.schemas import EVENT, NO_DATA, NO_EVENT, Analysis, Severity, camera_request from damage_engine.sources import derived from damage_engine.sources.fields import Field RUN_DAY = date(2026, 2, 10) # fetched for RUN_DAY: 12 look-back records (20:00 of 8 Feb ... 07:00 of 9 Feb), then the 24 h # checked (08:00 of 9 Feb ... 07:00 of 10 Feb) HOURS = pd.date_range("2026-02-08 20:00", "2026-02-10 07:00", freq="h") LB = 12 # index of the first record of the 24 h (08:00) WARM = [8.0] * 36 ALL = {"TemperaturaAria", "UmiditaRelativa", "Pioggia", "RadiazioneSolare"} NO_SOLAR = {"TemperaturaAria", "UmiditaRelativa", "Pioggia"} def _day(station, t, rh=None, rain=None, solar=None): n = len(t) nan = [np.nan] * n rh = [70.0] * n if rh is None else rh return pd.DataFrame({ "CodiceStazione": station, "Timestamp": HOURS[:n], derived.T_MIN: t, derived.T_MEAN: [x + 0.5 for x in t], derived.T_MAX: [x + 1 for x in t], derived.RH_MIN: rh, derived.RH_MEAN: rh, derived.RH_MAX: rh, snow.COL_RAIN: [0.0] * n if rain is None else rain, snow.COL_SOLAR: nan if solar is None else solar, }) def _field(station, bbch=None): return Field(id=f"ST{station}", name=f"field {station}", station_id=station, bbch=bbch) def _set(base, at, values): out = list(base) out[at:at + len(values)] = values return out def _one(df, sensors=NO_SOLAR, bbch=None, day=RUN_DAY): [a] = snow.detect(df, [_field(3, bbch)], day, station_sensors={3: sensors}) return a def test_window_photo_time_and_lookback(): assert snow.RUN_HOUR == 7 and snow.LOOKBACK_H == 12 assert snow.day_window(RUN_DAY) == (datetime(2026, 2, 9, 7), datetime(2026, 2, 10, 7)) assert snow.photo_time(RUN_DAY) == datetime(2026, 2, 10, 9) def test_warm_day_no_snow_possible(): a = _one(_day(3, WARM, rain=_set([0.0] * 36, LB + 5, [4.0]))) assert a.status == NO_EVENT and a.severity == Severity.NONE assert a.details["cold_hours"] == 0 and a.details["signals"] == [] assert "camera_request" not in a.details assert a.summary.startswith("No snow possible: lowest wet-bulb") def test_signal_a_cold_precipitation_is_likely_and_asks_for_a_photo(): t = _set(WARM, LB + 15, [-1.0, -1.5, -1.0]) # 23:00, 00:00, 01:00 rain = _set([0.0] * 36, LB + 15, [1.2, 2.0, 0.8]) a = _one(_day(3, t, rh=[90.0] * 36, rain=rain)) assert a.status == EVENT and a.severity == Severity.NONE and a.severity_score == 0.0 d = a.details assert d["signals"] == ["A"] and d["suspicion"] == "likely" assert d["snow_water_mm"] == 4.0 and d["est_new_snow_cm"] == 4.0 # × 10, mm -> cm assert (a.duration_h, a.start, a.end) == (3, datetime(2026, 2, 9, 23), datetime(2026, 2, 10, 1)) req = camera_request(d) assert req == {"phenomenon": "snow", "station_id": 3, "photo_at": "2026-02-10T09:00", "level": "likely", "signals": ["A"], "reason": req["reason"]} assert "Photo requested for 10 Feb 09:00" in a.summary and "4 cm" in a.summary def test_signal_b_rain_soon_after_cold_hours_only_within_12h(): t = _set(WARM, 0, [-2.0] * LB) # cold dry night before the window rain = _set([0.0] * 36, LB + 2, [3.0]) # 10:00, 3 h after the last cold hour a = _one(_day(3, t, rain=rain)) assert a.status == EVENT and a.details["signals"] == ["B"] and a.details["suspicion"] == "likely" assert a.details["melt_water_mm"] == 3.0 and a.details["est_new_snow_cm"] == 0.0 assert a.details["est_new_snow_max_cm"] == 3.0 late = _one(_day(3, t, rain=_set([0.0] * 36, LB + 20, [3.0]))) # 04:00: 21 h later assert late.status == NO_EVENT def test_signal_c_only_is_possible(): t = _set(WARM, LB + 18, [-0.5] * 4) rh = _set([70.0] * 36, LB + 18, [98.0] * 4) a = _one(_day(3, t, rh=rh)) assert a.status == EVENT and a.details["signals"] == ["C"] and a.details["suspicion"] == "possible" assert a.details["saturated_hours"] == 4 and a.duration_h == 4 def test_signal_d_raises_c_to_likely_but_never_triggers_alone(): t = _set(WARM, LB, [-0.5] * 3) # 08:00 ... 10:00 cold and saturated rh = _set([70.0] * 36, LB, [98.0] * 3) dark = _set([0.0] * 36, LB + 3, [10.0] * 4) # midday records 11:00 ... 14:00 a = _one(_day(3, t, rh=rh, solar=dark), sensors=ALL) assert a.details["signals"] == ["C", "D"] and a.details["suspicion"] == "likely" assert a.details["sensor_covered"] is True and a.details["midday_solar_max_wm2"] == 10 grey = _one(_day(3, t, rh=rh, solar=_set(dark, LB + 3, [150.0] * 4)), sensors=ALL) assert grey.details["signals"] == ["C"] and grey.details["sensor_covered"] is False dry_cold = _one(_day(3, [-1.0] * 36, solar=dark), sensors=ALL) # cold, dark, no other sign assert dry_cold.status == NO_EVENT and dry_cold.details["cold_hours"] == 24 def test_no_humidity_uses_air_temperature(): t = _set(WARM, LB + 15, [0.5]) rain = _set([0.0] * 36, LB + 15, [2.0]) sensors = {"TemperaturaAria", "Pioggia"} a = _one(_day(3, t, rh=[np.nan] * 36, rain=rain), sensors=sensors) assert a.status == EVENT and a.details["signals"] == ["A"] assert a.details["cold_basis"] == "air" and a.details["air_fallback_hours"] == 24 assert any("No humidity sensor" in n for n in a.data_notes) dry = _one(_day(3, [-1.0] * 36, rh=[np.nan] * 36), sensors=sensors) # C cannot fire assert dry.status == NO_EVENT def test_no_rain_gauge_only_saturated_air_can_flag(): rh = _set([70.0] * 36, LB + 18, [98.0] * 2) sensors = {"TemperaturaAria", "UmiditaRelativa"} a = _one(_day(3, [-1.0] * 36, rh=rh, rain=[np.nan] * 36), sensors=sensors) assert a.status == EVENT and a.details["signals"] == ["C"] and a.details["rain_checked"] is False assert any("No rain gauge" in n for n in a.data_notes) def test_missing_temperature_and_missing_station(): a = _one(_day(3, [np.nan] * 36)) assert a.status == NO_DATA and "No air temperature data" in a.data_notes[0] part = _one(_day(3, _set(WARM, LB, [np.nan] * 4))) assert part.status == NO_EVENT and part.details["records"] == 20 assert any("missing for 4 of 24" in n for n in part.data_notes) [none] = snow.detect(_day(3, WARM), [_field(5)], RUN_DAY) assert none.status == NO_DATA and "No records" in none.data_notes[0] def test_solar_note_and_cover_window(): a = _one(_day(3, WARM)) assert any("No solar radiation sensor" in n for n in a.data_notes) assert snow.in_cover_window(date(2026, 11, 15), 60) is False assert snow.in_cover_window(date(2026, 4, 1), None) is None assert snow.in_cover_window(date(2026, 4, 1), 40) is False # dormant assert snow.in_cover_window(date(2026, 4, 1), 60) is True def test_event_stores_and_reloads_as_json(): t = _set(WARM, LB + 15, [-1.0]) a = _one(_day(3, t, rain=_set([0.0] * 36, LB + 15, [1.0])), bbch=55) assert a.details["in_cover_window"] is True and a.details["bbch"] == 55 back = Analysis.from_dict(json.loads(json.dumps(a.to_dict()))) assert camera_request(back.details)["photo_at"] == "2026-02-10T09:00"