from datetime import date, datetime import numpy as np import pandas as pd from damage_engine.detectors import hail from damage_engine.schemas import EVENT, NO_DATA, NO_EVENT, Severity, camera_request from damage_engine.sources.fields import Field RUN_DAY = date(2026, 7, 10) # fetched for RUN_DAY: 1 look-back record (07:00 of 9 Jul), then the 24 h checked # (08:00 of 9 Jul ... 07:00 of 10 Jul) HOURS = pd.date_range("2026-07-09 07:00", "2026-07-10 07:00", freq="h") N = len(HOURS) # 25 LB = 1 # index of the first record of the 24 h (08:00) ALL = {"TemperaturaAria", "UmiditaRelativa", "Pioggia", "VelocitaVento2m"} NO_WIND = {"TemperaturaAria", "UmiditaRelativa", "Pioggia"} def _day(station=3, t=None, rain=None, wind=None, wind_mean=None, rate=None, t_min=None, rh=None): """Hourly rows: mean temperature t (min = t - 0.5, max = t + 0.5 unless t_min given), rain, wind, humidity rh (default 50 %).""" t = [22.0] * N if t is None else t nan = [np.nan] * N return pd.DataFrame({ "CodiceStazione": station, "Timestamp": HOURS, hail.COL_T_MEAN: t, hail.COL_T_MIN: [x - 0.5 for x in t] if t_min is None else t_min, hail.COL_T_MAX: [x + 0.5 for x in t], hail.COL_RH_MEAN: [50.0] * N if rh is None else rh, hail.COL_RAIN: [0.0] * N if rain is None else rain, hail.COL_RATE: nan if rate is None else rate, hail.COL_WIND: [3.0] * N if wind is None else wind, hail.COL_WIND_MEAN: [1.5] * N if wind_mean is None else wind_mean, }) def _set(base, at, values): out = list(base) out[at:at + len(values)] = values return out def _field(station=3, bbch=None): return Field(id=f"ST{station}", name=f"field {station}", station_id=station, bbch=bbch) def _one(df, sensors=ALL, bbch=None, day=RUN_DAY): [a] = hail.detect(df, [_field(3, bbch)], day, station_sensors={3: sensors}) return a ZERO = [0.0] * N H16 = LB + 8 # record ending 16:00 of 9 Jul def test_window_photo_time_lookback_and_rate(): assert hail.RUN_HOUR == 7 and hail.LOOKBACK_H == 1 and hail.STORM_H == 1 assert hail.day_window(RUN_DAY) == (datetime(2026, 7, 9, 7), datetime(2026, 7, 10, 7)) assert hail.photo_time(RUN_DAY) == datetime(2026, 7, 10, 9) assert hail.RATE_MMH == 30.0 # 5 mm in 10 min def test_dry_calm_day_no_hail(): a = _one(_day()) assert a.status == NO_EVENT and a.severity == Severity.NONE assert a.details["signals"] == [] and a.details["suspicion"] is None assert a.details["records"] == 24 and "camera_request" not in a.details assert a.summary == "No hail suspected: no rain in the 24 hours." assert a.data_notes == [] def test_burst_rain_alone_is_possible_and_asks_for_a_photo(): a = _one(_day(rain=_set(ZERO, H16, [20.0]))) # exactly 20 mm counts (>=) 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"] == "possible" and d["storm_signs"] == 1 assert (d["max_rain_1h_mm"], d["max_rain_1h_at"]) == (20.0, "2026-07-09T16:00") assert (a.duration_h, a.start, a.end) == (1, datetime(2026, 7, 9, 16), datetime(2026, 7, 9, 16)) req = camera_request(d) assert req == {"phenomenon": "hail", "station_id": 3, "photo_at": "2026-07-10T09:00", "level": "possible", "signals": ["A"], "reason": "Hail possible: burst rain."} assert a.summary.startswith("Hail possible: burst rain of 20 mm in one hour (09 Jul 16:00)") assert "Photo requested for 10 Jul 09:00" in a.summary def test_rain_below_burst_no_sign(): a = _one(_day(rain=_set(ZERO, H16, [19.9]))) assert a.status == NO_EVENT assert a.summary.startswith("No hail suspected: wettest hour 19.9 mm (sign at ≥ 20 mm)") def test_all_three_signs_in_one_storm_are_likely(): t = _set([28.0] * N, H16 + 1, [21.0, 20.0]) # 17:00 min 20.5 after 16:00 mean 28 rain = _set(ZERO, H16 + 1, [25.0, 3.0]) wind = _set([3.0] * N, H16, [15.0]) # gust at 16:00, rain starts at 17:00 a = _one(_day(t=t, rain=rain, wind=wind)) d = a.details assert a.status == EVENT and d["suspicion"] == "likely" assert d["signals"] == ["A", "B", "C"] and d["storm_signs"] == 3 assert (d["max_t_drop_c"], d["max_t_drop_at"]) == (7.5, "2026-07-09T17:00") assert (d["max_gust_ms"], d["max_gust_at"]) == (15.0, "2026-07-09T16:00") assert (a.start, a.end, a.duration_h) == (datetime(2026, 7, 9, 16), datetime(2026, 7, 9, 17), 2) assert camera_request(d)["reason"] == ("Hail likely: burst rain; sudden temperature drop near " "rain; strong gust near rain.") def test_drop_and_gust_without_rain_nearby_do_not_count(): t = _set([28.0] * N, H16, [22.0]) # 6 °C drop, dry wind = _set([3.0] * N, H16 + 6, [16.0]) # gust, dry rain = _set(ZERO, H16 + 3, [2.0]) # rain 3 h after the drop a = _one(_day(t=t, rain=rain, wind=wind)) d = a.details assert a.status == NO_EVENT assert (d["drop_hours"], d["drop_dry_hours"]) == (0, 1) assert (d["gust_hours"], d["gust_dry_hours"]) == (0, 1) def test_drop_with_light_rain_next_hour_is_possible(): t = _set([28.0] * N, H16, [22.0]) a = _one(_day(t=t, rain=_set(ZERO, H16 + 1, [0.4]))) d = a.details assert a.status == EVENT and d["signals"] == ["B"] and d["suspicion"] == "possible" assert "temperature drop of 6.5 °C near rain" in a.summary def test_two_signs_hours_apart_are_two_storms_possible(): rain = _set(ZERO, H16, [22.0]) rain = _set(rain, H16 + 4, [1.0]) wind = _set([3.0] * N, H16 + 4, [14.0]) # 4 h later: another storm a = _one(_day(rain=rain, wind=wind)) d = a.details assert d["signals"] == ["A", "C"] and d["storm_signs"] == 1 and d["suspicion"] == "possible" def test_rain_rate_at_293_gives_sign_a(): rain = _set(ZERO, H16, [8.0]) rate = _set([0.0] * N, H16, [36.0]) [a] = hail.detect(_day(293, rain=rain, rate=rate), [_field(293)], RUN_DAY) d = a.details assert a.status == EVENT and d["signals"] == ["A"] and d["rate_checked"] assert d["max_rain_rate_mmh"] == 36.0 assert "rain rate up to 36 mm/h" in a.summary def test_drop_in_first_hour_uses_the_lookback_record(): t = _set([28.0] * N, LB, [22.0]) # 08:00 of 9 Jul, after 07:00 mean 28 a = _one(_day(t=t, rain=_set(ZERO, LB, [1.0]))) assert a.details["signals"] == ["B"] and a.start == datetime(2026, 7, 9, 8) def test_no_wind_sensor_note_and_gust_not_checked(): nan = [np.nan] * N a = _one(_day(rain=_set(ZERO, H16, [21.0]), wind=nan, wind_mean=nan), sensors=NO_WIND) assert a.details["wind_checked"] is False and a.details["max_gust_ms"] is None assert any("No wind sensor (VelocitaVento2m)" in n for n in a.data_notes) def test_wind_max_missing_uses_hourly_mean(): wind = _set([3.0] * N, H16, [np.nan]) wind_mean = _set([1.5] * N, H16, [14.5]) a = _one(_day(rain=_set(ZERO, H16, [1.0]), wind=wind, wind_mean=wind_mean)) assert a.details["signals"] == ["C"] and a.details["wind_fallback_hours"] == 1 assert any("Hourly maximum wind missing for 1 hour(s)" in n for n in a.data_notes) def test_no_rain_data_counts_drop_as_if_it_rained(): t = _set([28.0] * N, H16, [22.0]) a = _one(_day(t=t, rain=[np.nan] * N)) assert a.status == EVENT and a.details["signals"] == ["B"] assert a.details["rain_checked"] is False and a.details["rain_day_mm"] is None assert any(n.startswith("No rain data in the 24 hours") for n in a.data_notes) def test_missing_temperature_hours_noted(): t = _set([25.0] * N, H16, [np.nan]) a = _one(_day(t=t, t_min=_set([24.5] * N, H16, [np.nan]))) # the drop is not known for 16:00 (no value) and 17:00 (no value in the hour before) assert any("Temperature drop not checked for 2 of 24 hours" in n for n in a.data_notes) def test_no_data_and_station_without_records(): nan = [np.nan] * N a = _one(_day(t=nan, t_min=nan, rain=nan, wind=nan, wind_mean=nan)) assert a.status == NO_DATA assert a.summary.startswith("No rain, temperature or wind data from the station for the last 24 hours.") assert a.details["hail_risk"] is None # unknown in the season: checked later [b] = hail.detect(pd.DataFrame(), [_field(6)], RUN_DAY) assert b.status == NO_DATA and b.data_notes == ["No records from the station for the last 24 hours."] def test_cover_window_fruit_set_and_nets_note(): storm = _day(rain=_set(ZERO, H16, [25.0])) assert _one(storm, bbch=72).details["in_cover_window"] is True assert _one(storm, bbch=65).details["in_cover_window"] is False # before fruit set a = _one(storm) assert a.details["in_cover_window"] is None and a.details["net_checked"] is False assert any("under nets the hail cover ended on 25 May" in n for n in a.data_notes) assert hail.in_cover_window(date(2026, 11, 2), 80) is False assert hail.in_cover_window(date(2026, 5, 20), 72) is True def test_nets_note_not_before_25_may(): hours = pd.date_range("2026-05-19 07:00", "2026-05-20 07:00", freq="h") df = _day(rain=_set(ZERO, H16, [25.0])).assign(Timestamp=hours) a = _one(df, day=date(2026, 5, 20)) assert a.status == EVENT assert not any("nets" in n for n in a.data_notes) # ─────────────────────────── hail-risk day ─────────────────────────── def test_risk_day_warm_and_moist_in_season(): t = _set([22.0] * N, LB + 6, [27.0, 28.0]) # afternoon up to 28.5 °C (max) a = _one(_day(t=t, rh=[70.0] * N)) # dew point ~22 °C at 28 °C, 70 % d = a.details assert a.status == NO_EVENT and d["hail_risk"] is True assert d["max_temp_c"] == 28.5 and d["max_dew_point_c"] >= 14 assert d["hail_risk_reason"].startswith("warm, moist air") assert a.summary.endswith("Hail-risk day: the station is checked again today at 13:00 and 19:00.") def test_no_risk_when_dry_cool_or_out_of_season(): hot = _set([22.0] * N, LB + 6, [28.0]) dry = _one(_day(t=hot, rh=[25.0] * N)).details # dew point ~6 °C assert dry["hail_risk"] is False and dry["hail_risk_reason"].startswith("warm (28.5 °C) but dry") cool = _one(_day(rh=[90.0] * N)).details # 22.5 °C max assert cool["hail_risk"] is False and "< 25 °C" in cool["hail_risk_reason"] hours = pd.date_range("2026-10-25 07:00", "2026-10-26 07:00", freq="h") october = _day(t=hot, rh=[80.0] * N, rain=_set(ZERO, H16, [25.0])).assign(Timestamp=hours) d = _one(october, day=date(2026, 10, 26)).details assert d["hail_risk"] is False and d["hail_risk_reason"] == "outside the hail season" assert not hail.in_season(date(2026, 4, 30)) and hail.in_season(date(2026, 5, 1)) def test_risk_day_after_a_hail_sign_and_without_humidity(): a = _one(_day(rain=_set(ZERO, H16, [25.0]))) assert a.details["hail_risk"] is True assert a.details["hail_risk_reason"] == "a hail sign in the last 24 hours" hot = _set([22.0] * N, LB + 6, [28.0]) b = _one(_day(t=hot, rh=[np.nan] * N)) assert b.details["hail_risk"] is True and b.details["max_dew_point_c"] is None assert any("hail-risk day was decided on the air temperature alone" in n for n in b.data_notes) # ─────────────────────────── daytime checks ─────────────────────────── DAY_HOURS = pd.date_range("2026-07-10 06:00", "2026-07-10 13:00", freq="h") # 1 look-back + 6 h def _daytime(rain=None, t=None): n = len(DAY_HOURS) t = [24.0] * n if t is None else t return pd.DataFrame({"CodiceStazione": 3, "Timestamp": DAY_HOURS, hail.COL_T_MEAN: t, hail.COL_T_MIN: [x - 0.5 for x in t], hail.COL_T_MAX: [x + 0.5 for x in t], hail.COL_RH_MEAN: [60.0] * n, hail.COL_RAIN: [0.0] * n if rain is None else rain, hail.COL_RATE: [np.nan] * n, hail.COL_WIND: [3.0] * n, hail.COL_WIND_MEAN: [1.5] * n}) def test_daytime_windows_and_hours(): assert hail.DAYTIME_HOURS == (13, 19) and hail.DAYTIME_H == 6 assert hail.daytime_window(RUN_DAY, 13) == (datetime(2026, 7, 10, 7), datetime(2026, 7, 10, 13)) assert hail.daytime_window(RUN_DAY, 19) == (datetime(2026, 7, 10, 13), datetime(2026, 7, 10, 19)) import pytest with pytest.raises(ValueError): hail.run_daytime(RUN_DAY, 1) # no night check def test_daytime_check_asks_for_a_photo_at_once(): rain = [0.0] * len(DAY_HOURS) rain[4] = 23.0 # record ending 10:00 window = hail.daytime_window(RUN_DAY, 13) [a] = hail.detect(_daytime(rain=rain), [_field()], RUN_DAY, station_sensors={3: ALL}, window=window) d = a.details assert a.block == "hail_daytime" and a.status == EVENT and d["check"] == "daytime" assert (d["window_start"], d["window_end"], d["hours"]) == ("2026-07-10T07:00", "2026-07-10T13:00", 6) assert camera_request(d)["photo_at"] == "2026-07-10T13:00" # at once, not next morning assert "hail_risk" not in d assert "Photo requested at once 10 Jul 13:00" in a.summary def test_daytime_check_quiet_and_no_records(): window = hail.daytime_window(RUN_DAY, 13) [a] = hail.detect(_daytime(), [_field()], RUN_DAY, station_sensors={3: ALL}, window=window) assert a.status == NO_EVENT and a.summary == "No hail suspected: no rain in the 6 hours." assert a.details["records"] == 6 [b] = hail.detect(pd.DataFrame(), [_field(6)], RUN_DAY, window=window) assert b.status == NO_DATA and b.data_notes == ["No records from the station for the 6 hours."]