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

142 lines
6.9 KiB
Python
Raw Permalink 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.

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)