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

155 lines
7.4 KiB
Python

from datetime import date, datetime
import pandas as pd
from damage_engine.detectors import brina, freezing
from damage_engine.schemas import EVENT, NO_DATA, NO_EVENT, Severity
from damage_engine.sources import derived
from damage_engine.sources.fields import Field
RUN_DAY = date(2025, 3, 12)
# the 24 h checked for RUN_DAY: index 0-10 = day hours 07:00-17:00 of 11 March,
# index 11-23 = Brina's night window 18:00 (11 March) -> 06:00 (12 March)
HOURS = pd.date_range("2025-03-11 07:00", "2025-03-12 06:00", freq="h")
DAY, NIGHT = 11, 13
WARM_NIGHT = [3.0] * NIGHT
COLD_DAY = [1, 0.5, 0, -0.5, -1.5, -2.5, -1, 0, 1, 2, 3] + WARM_NIGHT # 4 h below 0 °C, 10:00-13:00
def _day(station, temps, rh=80.0, wind=None, solar=None, ts=None):
ts = HOURS[:len(temps)] if ts is None else ts
df = pd.DataFrame({"CodiceStazione": station, "Timestamp": ts, freezing.COL_TEMP: temps,
freezing.COL_RH: rh, freezing.COL_RH_MAX: rh})
if wind is not None:
df[freezing.COL_WIND] = wind
if solar is not None:
df[freezing.COL_SOLAR] = solar
return df
def _field(station, bbch=None, fid=None):
return Field(id=fid or f"ST{station}", name=f"field {station}", station_id=station, bbch=bbch)
def test_windows_match_brina():
assert freezing.day_window(RUN_DAY) == (datetime(2025, 3, 11, 6), datetime(2025, 3, 12, 6))
b_start, b_end = brina.night_window(RUN_DAY)
assert freezing.night_start(RUN_DAY) == b_start and freezing.day_window(RUN_DAY)[1] == b_end
def test_cold_day_is_freezing_fallback_severity():
[a] = freezing.detect(_day(3, COLD_DAY), [_field(3)], RUN_DAY, {3: "Lagnasco"})
assert a.status == EVENT and a.block == "freezing" and a.station_name == "Lagnasco"
assert a.duration_h == 4 # 0 °C hours do not count ("below 0 °C")
assert a.start == datetime(2025, 3, 11, 10) and a.end == datetime(2025, 3, 11, 13)
assert a.details["day_min_temp_c"] == -2.5
assert a.severity == Severity.MODERATE # fallback: -2 > -2.5 >= -4
assert a.details["severity_method"] == "fallback_min_bud_temp"
assert a.details["in_cover_window"] is None # stage unknown
def test_exactly_zero_is_not_freezing():
[a] = freezing.detect(_day(3, [0.0] * 24), [_field(3)], RUN_DAY)
assert a.status == NO_EVENT and a.severity == Severity.NONE
def test_sub_zero_night_is_left_to_brina():
[a] = freezing.detect(_day(3, [5.0] * DAY + [-3.0] * NIGHT), [_field(3)], RUN_DAY)
assert a.status == NO_EVENT
assert a.details["day_min_temp_c"] == 5.0
assert a.details["sunlit_hours_in_night_window"] == 0
def test_sunlit_hours_of_the_night_window_are_freezing():
solar = [400.0] * DAY + [200, 50, 5] + [0.0] * 9 + [30] # 18:00, 19:00 and 06:00 sunlit
temps = [5.0] * DAY + [-1.0] * NIGHT
[a] = freezing.detect(_day(566, temps, solar=solar), [_field(566)], RUN_DAY)
assert a.status == EVENT and a.duration_h == 3
assert a.details["sunlit_hours_in_night_window"] == 3 and a.details["daylight_hours"] == 14
assert a.start == datetime(2025, 3, 11, 18) and a.end == datetime(2025, 3, 12, 6)
def test_every_hour_goes_to_brina_or_freezing_never_both():
solar = [400.0] * DAY + [200, 50, 5] + [0.0] * 9 + [30]
for rh, wind in ((95.0, 0.5), (40.0, 5.0)): # brina all night: calm + humid, windy + dry
df = _day(566, [-3.0] * 24, rh=rh, wind=[wind] * 24, solar=solar)
frz = freezing.day_hours(df, RUN_DAY)
frz_hours = set(frz.loc[frz["freezing"], "Timestamp"])
night = df[df["Timestamp"] >= brina.night_window(RUN_DAY)[0]]
br = brina.brina_hours(night)
brina_hours_ = set(br.loc[br["brina"], "Timestamp"])
assert not frz_hours & brina_hours_
assert frz_hours | brina_hours_ == set(HOURS)
def test_severity_uses_field_stage_when_known():
bloom, dormant = freezing.detect(_day(3, COLD_DAY), [_field(3, 65, "A"), _field(3, 40, "B")], RUN_DAY)
assert bloom.details["stage"] == "full_bloom"
assert 0.10 < bloom.details["kill_fraction"] < 0.50 # -2.5 °C at full bloom
assert bloom.severity == Severity.MODERATE
assert bloom.details["in_cover_window"] is True
assert dormant.status == EVENT and dormant.severity == Severity.NONE
def test_out_of_cover_window_flagged():
df = _day(3, COLD_DAY, ts=pd.date_range("2025-10-18 07:00", periods=24, freq="h"))
[a] = freezing.detect(df, [_field(3, 65)], date(2025, 10, 19))
assert a.status == EVENT and a.details["in_cover_window"] is False
def test_wind_and_wet_bulb_are_reported_not_used_to_detect():
windy = _day(3, COLD_DAY, wind=[3.0] * 24)
calm = _day(6, COLD_DAY, wind=[0.5] * 24)
no_wind = _day(9, COLD_DAY)
out = freezing.detect(pd.concat([windy, calm, no_wind]), [_field(3), _field(6), _field(9)], RUN_DAY)
assert [a.status for a in out] == [EVENT] * 3 and len({a.duration_h for a in out}) == 1
assert [a.details["advective_wind"] for a in out] == [True, False, None]
assert out[0].details["mean_wind_ms"] == 3.0 and out[2].details["mean_wind_ms"] is None
rows = freezing.day_hours(windy, RUN_DAY)
assert rows["wet_bulb_c"].equals(rows[derived.WET_BULB_MIN]) # the shared value
assert out[0].details["min_wet_bulb_c"] == round(float(rows.loc[rows["freezing"], "wet_bulb_c"].min()), 2)
def test_rows_outside_the_24_hours_are_ignored():
df = _day(3, [5.0] * 24)
extra = _day(3, [-10.0, -10.0], ts=pd.to_datetime(["2025-03-11 06:00", "2025-03-12 07:00"]))
[a] = freezing.detect(pd.concat([df, extra]), [_field(3)], RUN_DAY)
assert a.status == NO_EVENT and a.details["records"] == DAY
def test_no_records_or_no_daylight_temperature_is_no_data():
night_only = _day(3, [5.0] * NIGHT, ts=HOURS[DAY:])
missing, no_day = freezing.detect(night_only, [_field(77), _field(3)], RUN_DAY)
assert missing.status == NO_DATA
assert missing.data_notes == ["No records from the station for yesterday's daylight hours."]
assert no_day.status == NO_DATA
assert "Air temperature missing for 11 of 11 daylight hours" in " ".join(no_day.data_notes)
def test_missing_sensors_are_noted():
[a] = freezing.detect(_day(9, COLD_DAY), [_field(9)], RUN_DAY,
station_sensors={9: {"TemperaturaAria", "UmiditaRelativa"}})
assert a.status == EVENT
assert any(n.startswith("No wind sensor at this station") for n in a.data_notes)
assert any(n.startswith("No solar radiation sensor at this station: daylight = the fixed day "
"hours (records 07:00-17:00)") for n in a.data_notes)
def test_partly_missing_hours_are_noted():
solar = [400.0] * DAY + [0.0] * 10 + [None] * 3
df = _day(566, COLD_DAY, wind=[1.0] * 24, solar=solar).drop(index=[0, 1]) # no 07:00, 08:00 rows
df.loc[2, freezing.COL_TEMP] = None
df[freezing.COL_TEMP_FALLBACK] = 0.0
[a] = freezing.detect(df, [_field(566)], RUN_DAY)
text = " ".join(a.data_notes)
assert "Solar radiation missing for 3 of 13 hours of the night window" in text
assert "Air temperature missing for 2 of 11 daylight hours" in text # the two missing rows
assert "Hourly minimum temperature missing for 1 of 9 daylight hours: hourly mean used" in text
def test_complete_station_has_no_notes():
solar = [400.0] * DAY + [0.0] * NIGHT
[a] = freezing.detect(_day(566, COLD_DAY, wind=[1.0] * 24, solar=solar), [_field(566)], RUN_DAY)
assert a.status == EVENT and a.data_notes == []
assert a.details["solar_checked"] is True