Weather-Crop-Damage/tests/test_station_report.py
2026-10-27 14:13:32 +01:00

508 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from datetime import date, datetime
from damage_engine.report import report, station_report
from damage_engine.schemas import EVENT, NO_DATA, NO_EVENT, Analysis, Severity
DAY = date(2026, 4, 22)
def _a(block, station, status=NO_EVENT, details=None, field=None, run_day=DAY, **kw):
return Analysis(block=block, run_day=run_day, field_id=field or f"ST{station}",
field_name=f"field {station}", station_id=station, station_name=f"S{station}",
status=status, details=details or {}, **kw)
BRINA_EVENT = dict(status=EVENT, severity=Severity.SEVERE, severity_score=0.79, duration_h=5,
start=datetime(2026, 4, 22, 1), end=datetime(2026, 4, 22, 5),
details={"wind_checked": True, "records": 13, "night_min_temp_c": -1.2,
"min_temp_c": -1.2, "min_bud_temp_c": -3.2, "min_wet_bulb_c": -1.58,
"severity_method": "kill_fraction", "bbch": 65, "stage": "full_bloom",
"kill_fraction": 0.57, "in_cover_window": True,
"max_wind_ms": 3.4, "windy_hours": 2})
FLOOD_DRY = {"records": 24, "rain_day_mm": 0.0, "max_1h_mm": 0.0, "max_72h_mm": None,
"rain_72h_to_run_mm": 8.6, "rain_10d_mm": 8.8, "reference_10d_mm": None}
def _store(root):
report.submit("brina", DAY, [_a("brina", 566, **BRINA_EVENT),
_a("brina", 3, details={"night_min_temp_c": 9.0})], root=root)
report.submit("flood", DAY, [_a("flood", 566, details=FLOOD_DRY),
_a("flood", 3, details=FLOOD_DRY)], root=root)
def _check(block, name):
return next(c for c in block["checks"] if c["check"] == name)
def test_report_holds_only_its_station_and_every_block(tmp_path):
_store(tmp_path)
c = station_report.build(566, DAY, tmp_path)
assert [b["block"] for b in c["blocks"]] == list(station_report.EVENTS) # every event, diagram order
assert {b["block"] for b in c["blocks"] if b["status"] != "not_run"} == {"brina", "flood"}
assert {f["field_id"] for b in c["blocks"] for f in b["fields"]} == {"ST566"}
assert c["overall"]["events"] == 1 and c["overall"]["worst_severity"] == "severe"
def test_checks_show_value_threshold_and_margin(tmp_path):
_store(tmp_path)
c = station_report.build(566, DAY, tmp_path)
brina, flood = _block(c, "brina"), _block(c, "flood")
air = _check(brina, "Lowest air temperature")
assert (air["measured"], air["threshold"], air["status"], air["margin"]) == (-1.2, 2.0, "crossed", 3.2)
wind = _check(brina, "Highest wind in brina hours") # not a condition: info only
assert (wind["measured"], wind["status"]) == (3.4, "info")
rain = _check(flood, "Rain in 1 hour (wettest hour)")
assert (rain["status"], rain["margin"], rain["threshold_text"]) == ("not_crossed", 30.0, "≥ 30 mm")
assert _check(flood, "Rain in the last 10 days")["status"] == "info" # no 5-year average
def _block(content, name):
return next(b for b in content["blocks"] if b["block"] == name)
def test_no_event_night_marks_unstored_values(tmp_path):
_store(tmp_path)
brina = _block(station_report.build(3, DAY, tmp_path), "brina")
assert _check(brina, "Lowest wet-bulb temperature")["status"] == "not_recorded"
assert _check(brina, "Lowest air temperature")["status"] == "not_crossed"
assert _check(brina, "Highest wind in brina hours")["status"] == "not_applied" # no wind_checked
def test_no_data_has_no_checks(tmp_path):
report.submit("brina", DAY, [_a("brina", 600, status=NO_DATA, data_notes=["No sensor."])],
root=tmp_path)
c = station_report.build(600, DAY, tmp_path)
assert _block(c, "brina")["checks"] == [] and _block(c, "brina")["data_notes"] == ["No sensor."]
assert c["overall"]["headline"] == "No usable data for this station."
def test_write_makes_json_and_pdf(tmp_path):
_store(tmp_path / "reports")
json_path, pdf_path = station_report.write(566, DAY, tmp_path / "reports", tmp_path / "out")
assert json_path.name == "2026-04-22.json" and json_path.parent.name == "566"
assert json.loads(json_path.read_text(encoding="utf-8"))["station"]["id"] == 566
assert pdf_path.read_bytes()[:5] == b"%PDF-"
def test_missing_sensors_compared_with_other_stations(tmp_path):
c = station_report.build(153, DAY, tmp_path) # Caraglio: no wind, no solar
brina = [m["sensor"] for m in _block(c, "brina")["missing_sensors"]]
assert brina == ["VelocitaVento2m", "RadiazioneSolare"]
wind = _block(c, "strong_wind")["missing_sensors"][0]
assert wind == {"sensor": "VelocitaVento2m", "name": wind["name"]} # no counts of other stations
assert _block(c, "flood")["missing_sensors"] == [] # every station has a rain gauge
drought = [m["sensor"] for m in _block(c, "drought")["missing_sensors"]] # rain + soil moisture
assert drought == ["Suolo1", "Suolo2"]
def test_five_year_note_hidden_and_new_block_gets_generic_table(tmp_path):
report.submit("flood", DAY, [_a("flood", 3, details=FLOOD_DRY,
data_notes=["No 5-year reference average for the 10-day rain: x.",
"Rain missing for 2 of 24 hours."])], root=tmp_path)
report.submit("new_block", DAY, [_a("new_block", 3, details={"max_size_mm": 21.5,
"wind_checked": True})],
root=tmp_path)
c = station_report.build(3, DAY, tmp_path)
assert _block(c, "flood")["data_notes"] == ["Rain missing for 2 of 24 hours."]
[size] = _block(c, "new_block")["checks"]
assert (size["check"], size["measured"], size["status"]) == ("Max size mm", 21.5, "info")
WIND_EVENT = dict(status=EVENT, severity=Severity.MODERATE, severity_score=0.6, duration_h=3,
start=datetime(2026, 4, 21, 18), end=datetime(2026, 4, 21, 20),
details={"records": 24, "trigger": "hourly_max", "max_wind_ms": 18.5,
"max_wind_kmh": 66.6, "max_wind_at": "2026-04-21T19:00",
"max_mean_wind_ms": 9.3, "fallback_hours": 0, "strong_wind_hours": 3,
"in_cover_window": None})
def test_strong_wind_checks_period_and_severity_text(tmp_path):
report.submit("strong_wind", DAY, [
_a("strong_wind", 566, **WIND_EVENT),
_a("strong_wind", 3, details={"records": 24, "trigger": "hourly_max", "max_wind_ms": 8.0,
"max_wind_kmh": 28.8, "max_wind_at": "2026-04-22T02:00",
"max_mean_wind_ms": None, "fallback_hours": 0})], root=tmp_path)
wind = _block(station_report.build(566, DAY, tmp_path), "strong_wind")
assert wind["window"] == {"start": "2026-04-21T07:00", "end": "2026-04-22T07:00"}
top = _check(wind, "Highest wind in the 24 h")
assert (top["status"], top["margin"], top["threshold_text"]) == ("crossed", 4.6, "≥ 13.9 m/s")
assert top["note"].startswith("At 21 Apr 19:00, 66.6 km/h. ≥ 50 km/h, Beaufort 7")
time_ = _check(wind, "Time with wind ≥ 13.9 m/s")
assert (time_["measured"], time_["status"], time_["threshold_text"]) == (3, "crossed", "≥ 10 min")
assert _check(wind, "Highest hourly mean wind")["status"] == "info"
field = wind["fields"][0]
assert field["severity_basis"].startswith("Graded on the highest wind")
assert field["severity_scale"] == ("Wind: light ≥ 13.9 m/s, moderate ≥ 17.2 m/s, severe ≥ 20.8 m/s, "
"extreme ≥ 24.5 m/s (Beaufort 7, 8, 9, 10)")
calm = _block(station_report.build(3, DAY, tmp_path), "strong_wind")
assert _check(calm, "Highest wind in the 24 h")["status"] == "not_crossed"
assert _check(calm, "Time with wind ≥ 13.9 m/s")["status"] == "not_crossed"
assert _check(calm, "Highest hourly mean wind")["status"] == "not_recorded"
FREEZING_EVENT = dict(status=EVENT, severity=Severity.MODERATE, severity_score=0.56, duration_h=4,
start=datetime(2026, 4, 21, 10), end=datetime(2026, 4, 21, 13),
details={"records": 13, "daylight_hours": 13, "sunlit_hours_in_night_window": 2,
"solar_checked": True, "day_min_temp_c": -2.5, "mean_wind_ms": 3.0,
"advective_wind": True, "min_wet_bulb_c": -3.47,
"severity_method": "fallback_min_bud_temp", "in_cover_window": None})
def test_freezing_checks_period_and_severity_text(tmp_path):
report.submit("freezing", DAY, [_a("freezing", 566, **FREEZING_EVENT),
_a("freezing", 153, details={"records": 11, "daylight_hours": 11,
"sunlit_hours_in_night_window": 0,
"solar_checked": False,
"day_min_temp_c": 8.0})], root=tmp_path)
frz = _block(station_report.build(566, DAY, tmp_path), "freezing")
assert frz["window"] == {"start": "2026-04-21T06:00", "end": "2026-04-22T06:00"}
air = _check(frz, "Lowest daylight air temperature")
assert (air["status"], air["margin"], air["threshold_text"]) == ("crossed", 2.5, "< 0 °C")
assert _check(frz, "Daylight hours below 0 °C")["status"] == "crossed"
assert _check(frz, "Mean wind while below 0 °C")["status"] == "crossed" # ≥ 2 m/s
assert _check(frz, "Sunlit hours of the night (solar radiation)")["measured"] == 2
assert frz["fields"][0]["severity_basis"] == ("Crop stage not set: graded on the lowest "
"daylight air temperature.")
quiet = _block(station_report.build(153, DAY, tmp_path), "freezing") # no pyranometer, warm day
assert _check(quiet, "Lowest daylight air temperature")["status"] == "not_crossed"
assert _check(quiet, "Sunlit hours of the night (solar radiation)")["status"] == "not_applied"
assert _check(quiet, "Mean wind while below 0 °C")["status"] == "not_recorded"
assert _check(quiet, "Lowest wet-bulb while below 0 °C")["status"] == "not_recorded"
HEAVY = {"mm": 90.0, "first_rain": "2026-04-18T12:00", "last_rain": "2026-04-19T18:00",
"reached_at": "2026-04-19T18:00", "partial": False}
def test_rain_row_3_is_the_last_72h_over_the_threshold(tmp_path):
report.submit("flood", DAY, [
_a("flood", 566, details={**FLOOD_DRY, "last_heavy_72h": HEAVY, "wettest_72h": HEAVY}),
_a("flood", 3, details={**FLOOD_DRY, "last_heavy_72h": None,
"wettest_72h": {**HEAVY, "mm": 41.5, "partial": True}}),
_a("flood", 5, details=FLOOD_DRY), # run stored before this row existed
], root=tmp_path)
row = _block(station_report.build(566, DAY, tmp_path), "flood")["checks"][2]
assert row["check"] == "72-Hour Heavy Rain Watch (18 Apr 07:00 → 22 Apr 07:00)" # the 4 days checked
assert (row["measured"], row["status"], row["margin"]) == (90.0, "crossed", 10.0)
assert row["note"] == "Rain 18 Apr 12:00 → 19 Apr 18:00; 80 mm reached on 19 Apr 18:00."
c = station_report.build(566, DAY, tmp_path) # no new event: past one in the overview
assert _block(c, "flood")["notice"]["short"] == "Past: 90 mm in 72 h (18 Apr → 19 Apr)"
assert c["overall"]["notices"][0]["text"].startswith("No new event, but 90 mm of rain fell")
assert c["overall"]["headline"].startswith("No new damaging event")
assert _block(station_report.build(3, DAY, tmp_path), "flood")["notice"] is None
none = _block(station_report.build(3, DAY, tmp_path), "flood")["checks"][2]
assert (none["measured"], none["status"], none["margin"]) == (41.5, "not_crossed", 38.5)
assert none["check"] == "72-Hour Heavy Rain Watch (18 Apr 07:00 → 22 Apr 07:00)"
assert none["note"] == ("Rain 18 Apr 12:00 → 19 Apr 18:00. " # the wettest window
"Rain also fell just before these days (not counted).")
old = _block(station_report.build(5, DAY, tmp_path), "flood")["checks"][2]
assert old["status"] == "not_recorded" and old["check"] == "72-Hour Heavy Rain Watch (18 Apr 07:00 → 22 Apr 07:00)"
def test_rain_row_3_name_is_the_watch_and_its_4_days(tmp_path):
report.submit("flood", DAY, [_a("flood", 3, details={**FLOOD_DRY, "last_heavy_72h": None,
"wettest_72h": {**HEAVY, "mm": 63.2}})],
root=tmp_path)
row = _block(station_report.build(3, DAY, tmp_path), "flood")["checks"][2]
assert (row["check"], row["note"]) == ("72-Hour Heavy Rain Watch (18 Apr 07:00 → 22 Apr 07:00)", "Rain 18 Apr 12:00 → 19 Apr 18:00.")
later = date(2026, 4, 26)
report.submit("flood", later, [_a("flood", 3, run_day=later, details={
**FLOOD_DRY, "last_heavy_72h": None, "wettest_72h": None})], root=tmp_path)
row = _block(station_report.build(3, later, tmp_path), "flood")["checks"][2]
assert row["check"] == "72-Hour Heavy Rain Watch (22 Apr 07:00 → 26 Apr 07:00)" # follows the run day
assert row["note"] == "No rain in the last 4 days."
one = {"mm": 0.2, "first_rain": "2026-04-24T06:00", "last_rain": "2026-04-24T06:00", "partial": False}
report.submit("flood", later, [_a("flood", 3, run_day=later, details={
**FLOOD_DRY, "last_heavy_72h": None, "wettest_72h": one})], root=tmp_path)
row = _block(station_report.build(3, later, tmp_path), "flood")["checks"][2]
assert row["note"] == "Rain at 24 Apr 06:00." # one rainy hour
def test_rain_row_2_is_the_last_72_hours(tmp_path):
intense = dict(status=EVENT, severity=Severity.LIGHT, severity_score=0.1, duration_h=1,
details={**FLOOD_DRY, "rain_day_mm": 1.0, "max_72h_mm": 85.0,
"rain_72h_to_run_mm": 70.0, "criteria_met": ["intense_72h"],
"worst_criterion": "intense_72h", "rain_trigger_ratio": 1.06})
report.submit("flood", DAY, [
_a("flood", 566, details=FLOOD_DRY),
_a("flood", 3, details={**FLOOD_DRY, "rain_72h_to_run_mm": 95.0}), # dry day after a storm
_a("flood", 5, **intense), # earlier window crossed
], root=tmp_path)
rows = {s: _block(station_report.build(s, DAY, tmp_path), "flood")["checks"] for s in (566, 3, 5)}
assert [r["check"] for r in rows[566]][:2] == ["Rain in 1 hour (wettest hour)", "Rain in the last 72 hours"]
quiet, after_storm, earlier = (rows[s][1] for s in (566, 3, 5))
assert (quiet["measured"], quiet["status"], quiet["margin"], quiet["note"]) == (8.6, "not_crossed", 71.4, "")
assert quiet["threshold_text"] == "≥ 80 mm"
assert (after_storm["status"], after_storm["margin"]) == ("crossed", 15.0)
assert after_storm["note"].startswith("No rain in the last 24 h: not a new event")
assert (earlier["measured"], earlier["status"]) == (70.0, "not_crossed")
assert earlier["note"].startswith("The event comes from an earlier 72 h window")
def test_overview_period_of_rain_includes_the_last_4_days(tmp_path):
_store(tmp_path)
c = station_report.build(566, DAY, tmp_path)
flood = _block(c, "flood")
assert flood["window"] == {"start": "2026-04-21T07:00", "end": "2026-04-22T07:00"}
assert flood["watch_window"] == {"label": "Last 4 days", "start": "2026-04-18T07:00",
"end": "2026-04-22T07:00"}
assert _block(c, "brina")["watch_window"] is None
def test_new_run_replaces_only_that_day_and_keeps_10_days(tmp_path):
_store(tmp_path / "reports")
folder = tmp_path / "out" / "566"
days = [date(2026, 4, d) for d in range(11, 23)] # 12 days: 11 → 22 April
for d in days:
station_report.write(566, d, tmp_path / "reports", tmp_path / "out")
kept = sorted(p.stem for p in folder.glob("*.json"))
assert kept == [d.isoformat() for d in days[-10:]] # the last 10 days stay
assert sorted(p.stem for p in folder.glob("*.pdf")) == kept
report.submit("flood", DAY, [_a("flood", 566, details={**FLOOD_DRY, "rain_day_mm": 7.5})],
root=tmp_path / "reports") # new run of the same day
station_report.write(566, DAY, tmp_path / "reports", tmp_path / "out")
assert sorted(p.stem for p in folder.glob("*.json")) == kept # no other day removed
new = json.loads((folder / "2026-04-22.json").read_text(encoding="utf-8"))
assert _block(new, "flood")["details"]["rain_day_mm"] == 7.5 # that day's report replaced
HOT_WIND_EVENT = dict(status=EVENT, severity=Severity.MODERATE, severity_score=0.55, duration_h=2,
start=datetime(2026, 4, 21, 14), end=datetime(2026, 4, 21, 15),
details={"records": 24, "trigger": "hourly_max", "max_temp_c": 42.3,
"max_temp_at": "2026-04-21T15:00", "max_mean_temp_c": 40.1,
"fallback_hours": 0, "hot_hours": 3, "still_hot_hours": 1,
"wind_checked": True, "wind_at_max_temp_ms": 2.4,
"humidity_at_max_temp_pct": 18, "direction_checked": False,
"hot_wind_hours": 2, "max_hot_wind_temp_c": 42.3,
"wind_assumed_hours": 0, "min_humidity_pct": 18,
"harvest_checked": False, "in_cover_window": None})
def test_hot_wind_checks_period_and_severity_text(tmp_path):
report.submit("hot_wind", DAY, [
_a("hot_wind", 566, **HOT_WIND_EVENT),
_a("hot_wind", 3, details={"records": 24, "trigger": "hourly_max", "max_temp_c": 31.0,
"max_temp_at": "2026-04-21T16:00", "max_mean_temp_c": 29.5,
"fallback_hours": 0, "hot_hours": 0, "still_hot_hours": 0,
"wind_checked": False, "wind_at_max_temp_ms": None,
"humidity_at_max_temp_pct": None, "direction_checked": False})],
root=tmp_path)
hot = _block(station_report.build(566, DAY, tmp_path), "hot_wind")
assert hot["window"] == {"start": "2026-04-21T07:00", "end": "2026-04-22T07:00"}
top = _check(hot, "Highest air temperature in the 24 h")
assert (top["status"], top["margin"], top["threshold_text"]) == ("crossed", 2.3, "≥ 40 °C")
assert top["note"].startswith("At 21 Apr 15:00. ≥ 40 °C at 2 m (contract)")
hours = _check(hot, "Hours at ≥ 40 °C with moving air")
assert (hours["measured"], hours["status"], hours["threshold_text"]) == (2, "crossed", "≥ 1 h")
assert "1 hour(s) at ≥ 40 °C had still air." in hours["note"]
wind = _check(hot, "Wind in the hottest hour")
assert (wind["status"], wind["threshold_text"]) == ("crossed", "≥ 1 m/s")
direction = _check(hot, "Wind from south-east to south-west")
assert (direction["status"], direction["threshold_text"]) == ("not_applied", "135°–225°")
assert _check(hot, "Humidity in the hottest hour")["status"] == "info"
field = hot["fields"][0]
assert field["severity_basis"].startswith("Graded on the highest air temperature")
assert field["severity_scale"] == ("Air temperature: light ≥ 40 °C, moderate ≥ 42 °C, "
"severe ≥ 44 °C, extreme ≥ 46 °C")
mild = _block(station_report.build(3, DAY, tmp_path), "hot_wind")
assert _check(mild, "Highest air temperature in the 24 h")["status"] == "not_crossed"
assert _check(mild, "Hours at ≥ 40 °C with moving air")["status"] == "not_crossed"
assert _check(mild, "Wind in the hottest hour")["status"] == "not_applied"
assert _check(mild, "Humidity in the hottest hour")["status"] == "not_recorded"
HIGH_TEMPERATURE_EVENT = dict(
status=EVENT, severity=Severity.MODERATE, severity_score=0.6, duration_h=2,
start=datetime(2026, 4, 21, 14), end=datetime(2026, 4, 21, 15),
details={"records": 24, "trigger": "hourly_max", "max_temp_c": 41.2,
"max_temp_at": "2026-04-21T15:00", "max_mean_temp_c": 39.5, "fallback_hours": 0,
"humidity_at_max_temp_pct": 22, "wet_bulb_at_max_temp_c": 24.1,
"solar_checked": True, "sunlit_hours": 10, "max_solar_wm2": 950,
"max_fst_c": 51.1, "max_fst_at": "2026-04-21T14:00", "solar_at_max_fst_wm2": 950,
"wind_at_max_fst_ms": 1.2, "sunburn_risk_hours": 4, "max_risk_fst_c": 51.1,
"hot_hours": 2, "severity_method": "fst", "severity_value_c": 51.1,
"in_cover_window": None})
def test_high_temperature_checks_period_and_severity_text(tmp_path):
report.submit("high_temperature", DAY, [
_a("high_temperature", 566, **HIGH_TEMPERATURE_EVENT),
_a("high_temperature", 3, status=EVENT, severity=Severity.LIGHT, duration_h=1,
start=datetime(2026, 4, 21, 15), end=datetime(2026, 4, 21, 15),
details={"records": 24, "trigger": "hourly_max", "max_temp_c": 40.4,
"max_temp_at": "2026-04-21T15:00", "fallback_hours": 0,
"humidity_at_max_temp_pct": None, "wet_bulb_at_max_temp_c": None,
"solar_checked": False, "max_fst_c": None, "sunburn_risk_hours": None,
"hot_hours": 1, "severity_method": "air", "severity_value_c": 40.4})],
root=tmp_path)
hot = _block(station_report.build(566, DAY, tmp_path), "high_temperature")
assert hot["window"] == {"start": "2026-04-21T07:00", "end": "2026-04-22T07:00"}
top = _check(hot, "Highest air temperature in the 24 h")
assert (top["status"], top["margin"], top["threshold_text"]) == ("crossed", 1.2, "≥ 40 °C")
assert top["note"].startswith("At 21 Apr 15:00. ≥ 40 °C at 2 m (contract)")
hours = _check(hot, "Hours at ≥ 40 °C")
assert (hours["measured"], hours["status"]) == (2, "crossed")
fst = _check(hot, "Highest fruit surface temperature")
assert (fst["status"], fst["margin"], fst["threshold_text"]) == ("crossed", 5.1, "≥ 46 °C")
assert fst["note"].startswith("At 21 Apr 14:00: sun 950 W/m², wind 1.2 m/s.")
risk = _check(hot, "Sunburn risk hours (air ≥ 32 °C, fruit ≥ 46 °C)")
assert (risk["measured"], risk["status"]) == (4, "crossed")
assert _check(hot, "Wet-bulb in the hottest hour")["status"] == "info"
field = hot["fields"][0]
assert field["severity_basis"].startswith("Graded on the highest estimated fruit surface temperature (51.1 °C)")
assert field["severity_scale"] == ("Fruit surface: light ≥ 46 °C, moderate ≥ 49 °C, "
"severe ≥ 52 °C, extreme ≥ 55 °C")
no_sun = _block(station_report.build(3, DAY, tmp_path), "high_temperature")
assert _check(no_sun, "Highest fruit surface temperature")["status"] == "not_applied"
assert _check(no_sun, "Sunburn risk hours (air ≥ 32 °C, fruit ≥ 46 °C)")["status"] == "not_applied"
assert _check(no_sun, "Humidity in the hottest hour")["status"] == "not_recorded"
assert no_sun["fields"][0]["severity_basis"].startswith("No solar radiation data: graded on the highest air")
assert no_sun["fields"][0]["severity_scale"].startswith("Air temperature: light ≥ 40 °C")
SNOW_SUSPECTED = dict(status=EVENT, duration_h=3, start=datetime(2026, 4, 21, 23),
end=datetime(2026, 4, 22, 1),
details={"records": 24, "cold_basis": "wet_bulb", "air_fallback_hours": 0,
"min_cold_c": -1.6, "min_cold_at": "2026-04-22T00:00",
"cold_hours": 3, "rain_checked": True, "humidity_checked": True,
"solar_checked": False, "snow_hours": 3, "snow_water_mm": 4.0,
"melt_hours": 0, "melt_water_mm": 0.0, "saturated_hours": 0,
"midday_solar_max_wm2": None, "est_new_snow_cm": 4.0,
"est_new_snow_max_cm": 4.0, "signals": ["A"], "suspicion": "likely",
"in_cover_window": True,
"camera_request": {"phenomenon": "snow", "station_id": 3,
"photo_at": "2026-04-22T09:00", "level": "likely",
"signals": ["A"],
"reason": "Snow likely: precipitation in a cold hour."}})
def test_snow_is_shown_as_suspected_with_its_signs_and_photo(tmp_path):
report.submit("snow", DAY, [_a("snow", 3, **SNOW_SUSPECTED)], root=tmp_path)
c = station_report.build(3, DAY, tmp_path)
b = _block(c, "snow")
assert b["window"] == {"start": "2026-04-21T07:00", "end": "2026-04-22T07:00"}
[f] = b["fields"]
assert f["suspected"] and f["camera_request"]["photo_at"] == "2026-04-22T09:00"
assert f["severity_basis"].startswith("Not graded")
assert c["overall"]["events"] == 0 # not a detected event: a photo decides
assert c["overall"]["headline"] == "Snow suspected · photo requested to confirm"
assert "22 Apr 09:00" in c["overall"]["suspected"][0]["text"]
assert _check(b, "A · Precipitation in cold hours")["status"] == "crossed"
assert _check(b, "B · Rain within 12 h after a cold hour")["status"] == "not_crossed"
assert _check(b, "D · Highest midday sun after the first suspect hour")["status"] == "not_applied"
assert _check(b, "Lowest wet-bulb in the 24 h")["margin"] == 2.6
json_path, pdf_path = station_report.write(3, DAY, tmp_path, out_dir=tmp_path / "out")
assert pdf_path.read_bytes()[:5] == b"%PDF-"
def test_hail_is_shown_as_suspected_with_its_signs_and_photo(tmp_path):
import pandas as pd
from damage_engine.detectors import hail
from damage_engine.sources.fields import Field
hours = pd.date_range("2026-04-21 07:00", "2026-04-22 07:00", freq="h")
t = [20.0] * len(hours)
t[10:12] = [14.0, 14.0] # 17:00: 6.5 °C drop
rain = [0.0] * len(hours)
rain[10:12] = [22.0, 2.0] # burst rain at 17:00
df = pd.DataFrame({"CodiceStazione": 3, "Timestamp": hours, hail.COL_T_MEAN: t,
hail.COL_T_MIN: [x - 0.5 for x in t], hail.COL_RAIN: rain,
hail.COL_WIND: [3.0] * len(hours), hail.COL_WIND_MEAN: [1.0] * len(hours)})
[a] = hail.detect(df, [Field(id="ST3", name="field 3", station_id=3)], DAY)
assert a.status == EVENT and a.details["suspicion"] == "likely"
report.submit("hail", DAY, [a], root=tmp_path)
c = station_report.build(3, DAY, tmp_path)
b = _block(c, "hail")
assert b["window"] == {"start": "2026-04-21T07:00", "end": "2026-04-22T07:00"}
[f] = b["fields"]
assert f["suspected"] and f["camera_request"]["photo_at"] == "2026-04-22T09:00"
assert f["severity_basis"].startswith("Not graded")
assert c["overall"]["events"] == 0
assert c["overall"]["headline"] == "Hail suspected · photo requested to confirm"
burst = _check(b, "A · Rain in the wettest hour")
assert (burst["status"], burst["margin"]) == ("crossed", 2.0)
assert _check(b, "A · Highest rain rate")["status"] == "not_applied"
assert _check(b, "B · Largest temperature drop")["measured"] == 6.5
assert _check(b, "C · Highest wind (gust)")["status"] == "not_crossed"
assert _check(b, "Signs in one storm")["measured"] == 2
json_path, pdf_path = station_report.write(3, DAY, tmp_path, out_dir=tmp_path / "out")
assert pdf_path.read_bytes()[:5] == b"%PDF-"
DROUGHT_MONTHS = [
{"month": "2026-01", "label": "Jan 2026", "days": 31, "missing_days": 0, "rain_mm": 10.0,
"normal_mm": 52.7, "rain_pct_of_normal": 19.0, "et0_mm": 20.0, "water_balance_mm": -10.0},
{"month": "2026-02", "label": "Feb 2026", "days": 28, "missing_days": 2, "rain_mm": 20.0,
"normal_mm": 61.5, "rain_pct_of_normal": 32.5, "et0_mm": 30.0, "water_balance_mm": -10.0},
{"month": "2026-03", "label": "Mar 2026", "days": 31, "missing_days": 0, "rain_mm": 30.0,
"normal_mm": 82.2, "rain_pct_of_normal": 36.5, "et0_mm": 50.0, "water_balance_mm": -20.0},
]
DROUGHT_EVENT = dict(status=EVENT, severity=Severity.MODERATE, severity_score=0.6,
duration_h=90 * 24, start=datetime(2026, 1, 1), end=datetime(2026, 4, 1),
details={"months": DROUGHT_MONTHS, "window_label": "Jan–Mar 2026",
"window_months": 3, "days": 90, "missing_days": 2, "rain_mm": 60.0,
"normal_mm": 196.4, "rain_pct_of_normal": 30.5, "deficit_mm": 136.4,
"last_month_label": "Mar 2026", "et0_mm": 100.0,
"water_balance_mm": -40.0, "water_balance_months": 3,
"soil_moisture": None, "window_start": "2026-01-01T00:00",
"window_end": "2026-04-01T00:00"})
def test_drought_shows_rain_against_the_average_month_by_month(tmp_path):
report.submit("drought", date(2026, 4, 1), [_a("drought", 566, **DROUGHT_EVENT)], root=tmp_path)
_store(tmp_path) # daily blocks of 22 Apr
c = station_report.build(566, DAY, tmp_path)
b = _block(c, "drought")
assert b["window"]["label"] == "Jan–Mar 2026" and b["monthly"] and b["stale"]
pct = _check(b, "Rain as % of the average")
assert (pct["measured"], pct["status"], pct["margin"]) == (30.5, "crossed", 29.5)
assert pct["threshold_text"] == "≤ 60 % of the average (= 117.8 mm)"
total = _check(b, "Rain in the last 3 months (Jan–Mar 2026)")
assert total["threshold_text"] == "average 196.4 mm" and "136.4 mm below" in total["note"]
feb = _check(b, "Rain in Feb 2026")
assert (feb["measured"], feb["threshold_text"], feb["status"]) == (20.0, "average 61.5 mm", "info")
assert _check(b, "Water balance: rain − ET0 (Jan–Mar 2026)")["measured"] == -40.0
assert _check(b, "Soil moisture, mean of Mar 2026")["status"] == "not_applied"
[f] = b["fields"]
assert f["period_text"] == "Jan–Mar 2026 (90 days)" and f["duration_text"] == "90 d"
assert f["severity_scale"].startswith("Rain / average: light ≤ 60 %")
json_path, pdf_path = station_report.write(566, DAY, tmp_path, out_dir=tmp_path / "out")
assert pdf_path.read_bytes()[:5] == b"%PDF-"
def test_hail_daytime_suspicion_is_merged_into_the_hail_section(tmp_path):
report.submit("hail", DAY, [_a("hail", 3, summary="No hail suspected: no rain in the 24 hours. "
"Hail-risk day: the station is checked again "
"today at 13:00 and 19:00.",
details={"hail_risk": True, "storm_signs": 0})], root=tmp_path)
quiet = _a("hail_daytime", 3, summary="No hail suspected: no rain in the 6 hours.",
details={"check": "daytime", "window_start": "2026-04-22T07:00",
"window_end": "2026-04-22T13:00"})
request = {"phenomenon": "hail", "station_id": 3, "photo_at": "2026-04-22T19:00",
"level": "likely", "signals": ["A", "B"], "reason": "Hail likely: burst rain; x."}
storm = _a("hail_daytime", 3, status=EVENT, duration_h=2, start=datetime(2026, 4, 22, 16),
end=datetime(2026, 4, 22, 17), summary="Hail likely: burst rain of 24 mm.",
details={"check": "daytime", "window_start": "2026-04-22T13:00",
"window_end": "2026-04-22T19:00", "camera_request": request,
"in_cover_window": None},
data_notes=["No wind sensor (VelocitaVento2m) at this station: x."])
report.submit("hail_daytime", DAY, [quiet], root=tmp_path, slot="13h")
report.submit("hail_daytime", DAY, [storm], root=tmp_path, slot="19h")
c = station_report.build(3, DAY, tmp_path)
assert not any(b["block"] == "hail_daytime" for b in c["blocks"])
b = _block(c, "hail")
assert b["status"] == EVENT
assert b["watch_window"] == {"label": "Daytime checks", "start": "2026-04-22T07:00",
"end": "2026-04-22T19:00"}
[f] = b["fields"]
assert f["suspected"] and f["camera_request"]["photo_at"] == "2026-04-22T19:00"
assert (f["start"], f["end"], f["duration_h"]) == ("2026-04-22T16:00", "2026-04-22T17:00", 2)
assert "Daytime check 07:00 → 13:00: No hail suspected" in f["summary"]
assert "Daytime check 13:00 → 19:00: Hail likely" in f["summary"]
assert [d["slot"] for d in f["daytime_checks"]] == ["13h", "19h"]
assert b["data_notes"] == ["Daytime check 13:00 → 19:00: No wind sensor (VelocitaVento2m) at "
"this station: x."]
assert c["overall"]["headline"] == "Hail suspected · photo requested to confirm"
json_path, pdf_path = station_report.write(3, DAY, tmp_path, out_dir=tmp_path / "out")
assert pdf_path.read_bytes()[:5] == b"%PDF-"