291 lines
14 KiB
Python
291 lines
14 KiB
Python
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."]
|