from datetime import date, datetime import numpy as np import pandas as pd import pytest from damage_engine.detectors import drought from damage_engine.schemas import EVENT, NO_DATA, NO_EVENT, Severity from damage_engine.sources.fields import Field from damage_engine.thresholds import RAIN_NORMALS_MM RUN_DAY = date(2026, 11, 1) # checks Aug, Sep, Oct 2026 (92 days) DAYS = pd.date_range("2026-08-01", "2026-10-31", freq="D") NORMAL_566 = sum(RAIN_NORMALS_MM[566][7:10]) # 74.0 + 87.2 + 98.3 = 259.5 mm ALL = {"Pioggia", "TemperaturaAria", "UmiditaRelativa"} SOIL = ALL | {"Suolo1", "Suolo2"} def _daily(station, rain_total, et0=2.0, drop=None, days=DAYS): """Daily rows with rain_total spread evenly; `drop` = indexes of days without any row.""" rain = [rain_total / len(days)] * len(days) df = pd.DataFrame({"CodiceStazione": station, "Data": days, "Pioggia": rain, "EvapoPot": [et0] * len(days)}) return df.drop(index=list(drop)) if drop else df def _one(df, station=566, sensors=ALL, soil=None, soil_error=None, day=RUN_DAY): f = Field(id=f"ST{station}", name=f"field {station}", station_id=station) [a] = drought.detect(df, [f], day, station_sensors={station: sensors}, soil=soil, soil_error=soil_error) return a def test_schedule_window_and_labels(): assert drought.due(date(2026, 11, 1)) and not drought.due(date(2026, 11, 2)) assert drought.window_months(RUN_DAY) == [date(2026, 8, 1), date(2026, 9, 1), date(2026, 10, 1)] assert drought.window_months(date(2026, 10, 22)) == [date(2026, 7, 1), date(2026, 8, 1), date(2026, 9, 1)] assert drought.window(RUN_DAY) == (datetime(2026, 8, 1), datetime(2026, 11, 1)) assert drought.months_label(drought.window_months(RUN_DAY)) == "Aug–Oct 2026" assert drought.months_label(drought.window_months(date(2026, 1, 1))) == "Oct–Dec 2025" assert drought.months_label(drought.window_months(date(2026, 2, 1))) == "Nov 2025–Jan 2026" assert drought.normals_for(566, drought.window_months(RUN_DAY)) == [74.0, 87.2, 98.3] assert drought.normals_for(999, drought.window_months(RUN_DAY)) is None def test_usual_rain_is_no_drought(): a = _one(_daily(566, NORMAL_566)) assert a.status == NO_EVENT and a.severity == Severity.NONE d = a.details assert (d["rain_mm"], d["normal_mm"], d["rain_pct_of_normal"]) == (259.5, 259.5, 100.0) assert [m["label"] for m in d["months"]] == ["Aug 2026", "Sep 2026", "Oct 2026"] assert [m["normal_mm"] for m in d["months"]] == [74.0, 87.2, 98.3] assert d["window_label"] == "Aug–Oct 2026" and d["window_start"] == "2026-08-01T00:00" assert "no drought" in a.summary and "100 % of the average" in a.summary assert a.start is None and a.duration_h == 0 def test_dry_months_are_a_drought_graded_on_percent_of_average(): a = _one(_daily(566, 50.0)) # 19.3 % of 259.5 mm assert a.status == EVENT and a.severity == Severity.SEVERE # <= 30 %, > 15 % assert 0.75 <= a.severity_score < 1.0 d = a.details assert d["rain_pct_of_normal"] == 19.3 and d["deficit_mm"] == 209.5 assert (a.start, a.end) == (datetime(2026, 8, 1), datetime(2026, 11, 1)) assert a.duration_h == 92 * 24 assert a.summary.startswith("Dry period: 50 mm of rain in Aug–Oct 2026, only 19.3 %") assert "Oct 2026: 17.1 % of its average" in a.summary # 16.85 mm of 98.3 mm @pytest.mark.parametrize("rain, status, severity", [ (156.0, NO_EVENT, Severity.NONE), # 60.1 % (155.0, EVENT, Severity.LIGHT), # 59.7 % (100.0, EVENT, Severity.MODERATE), # 38.5 % (30.0, EVENT, Severity.EXTREME), # 11.6 % ]) def test_trigger_and_bands(rain, status, severity): a = _one(_daily(566, rain)) assert (a.status, a.severity) == (status, severity) def test_one_dry_month_alone_is_not_enough(): rain = [0.0] * 61 + [9.0] * 31 # Aug, Sep dry; Oct 279 mm df = pd.DataFrame({"CodiceStazione": 566, "Data": DAYS, "Pioggia": rain, "EvapoPot": 2.0}) a = _one(df) assert a.status == NO_EVENT and a.details["rain_pct_of_normal"] == 107.5 assert [m["rain_pct_of_normal"] for m in a.details["months"]] == [0.0, 0.0, 283.8] def test_water_balance_from_et0(): d = _one(_daily(566, 92.0, et0=3.0)).details assert (d["et0_mm"], d["water_balance_mm"], d["water_balance_months"]) == (276.0, -184.0, 3) assert d["months"][0]["water_balance_mm"] == -62.0 def test_missing_days_are_a_lower_bound_until_too_many(): a = _one(_daily(566, 92.0, drop=range(10))) # 10 of 92 days missing (10.9 %) assert a.status == EVENT and a.details["missing_days"] == 10 and a.details["rain_mm"] == 82.0 assert any("Rain missing for 10 of 92 days (Aug 2026 10)" in n for n in a.data_notes) b = _one(_daily(566, 92.0, drop=range(20))) # 21.7 % > 20 % assert b.status == NO_DATA and b.details["reason"] == "too many days without rain data" assert "could be only missing data" in b.data_notes[0] and b.summary == b.data_notes[0] def test_no_records_or_no_average_is_no_data(): a = _one(pd.DataFrame()) assert a.status == NO_DATA and "No daily rain data for Aug–Oct 2026" in a.data_notes[0] b = _one(_daily(999, 50.0), station=999) assert b.status == NO_DATA and "No average rain for station 999" in b.data_notes[0] def test_missing_et0_column_and_no_soil_sensor_are_notes(): df = _daily(566, 259.5).drop(columns=["EvapoPot"]) a = _one(df) assert a.status == NO_EVENT and a.details["water_balance_mm"] is None assert any("No evapotranspiration data" in n for n in a.data_notes) assert any("No soil moisture sensor" in n for n in a.data_notes) assert any("CERRA" in n for n in a.data_notes) # where the average comes from def _soil(station, s1, s2=None): hours = pd.date_range("2026-10-01 01:00", "2026-11-01 00:00", freq="h") first = hours <= pd.Timestamp("2026-10-08 00:00") v1 = np.where(first, s1[0], s1[1]) df = pd.DataFrame({"CodiceStazione": station, "Timestamp": hours, "Suolo1": v1}) if s2 is not None: df["Suolo2"] = s2 return df def test_soil_moisture_of_the_last_month_is_information_only(): a = _one(_daily(600, 50.0), station=600, sensors=SOIL, soil=_soil(600, (30.0, 18.0), 25.0)) soil = a.details["soil_moisture"] assert soil["Suolo1"]["first_week_pct"] == 30.0 and soil["Suolo1"]["last_week_pct"] == 18.0 assert soil["Suolo2"]["mean_pct"] == 25.0 assert a.status == EVENT # decided by the rain only b = _one(_daily(600, 50.0), station=600, sensors=SOIL, soil_error="ConnectionError") assert b.details["soil_moisture"] is None and b.status == EVENT assert any("could not be read" in n for n in b.data_notes) c = _one(_daily(600, 50.0), station=600, sensors=SOIL) assert any("No soil moisture data" in n for n in c.data_notes)