142 lines
6.9 KiB
Python
142 lines
6.9 KiB
Python
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)
|