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

156 lines
7.2 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
import numpy as np
import pandas as pd
from damage_engine.detectors import snow
from damage_engine.schemas import EVENT, NO_DATA, NO_EVENT, Analysis, Severity, camera_request
from damage_engine.sources import derived
from damage_engine.sources.fields import Field
RUN_DAY = date(2026, 2, 10)
# fetched for RUN_DAY: 12 look-back records (20:00 of 8 Feb ... 07:00 of 9 Feb), then the 24 h
# checked (08:00 of 9 Feb ... 07:00 of 10 Feb)
HOURS = pd.date_range("2026-02-08 20:00", "2026-02-10 07:00", freq="h")
LB = 12 # index of the first record of the 24 h (08:00)
WARM = [8.0] * 36
ALL = {"TemperaturaAria", "UmiditaRelativa", "Pioggia", "RadiazioneSolare"}
NO_SOLAR = {"TemperaturaAria", "UmiditaRelativa", "Pioggia"}
def _day(station, t, rh=None, rain=None, solar=None):
n = len(t)
nan = [np.nan] * n
rh = [70.0] * n if rh is None else rh
return pd.DataFrame({
"CodiceStazione": station, "Timestamp": HOURS[:n],
derived.T_MIN: t, derived.T_MEAN: [x + 0.5 for x in t], derived.T_MAX: [x + 1 for x in t],
derived.RH_MIN: rh, derived.RH_MEAN: rh, derived.RH_MAX: rh,
snow.COL_RAIN: [0.0] * n if rain is None else rain,
snow.COL_SOLAR: nan if solar is None else solar,
})
def _field(station, bbch=None):
return Field(id=f"ST{station}", name=f"field {station}", station_id=station, bbch=bbch)
def _set(base, at, values):
out = list(base)
out[at:at + len(values)] = values
return out
def _one(df, sensors=NO_SOLAR, bbch=None, day=RUN_DAY):
[a] = snow.detect(df, [_field(3, bbch)], day, station_sensors={3: sensors})
return a
def test_window_photo_time_and_lookback():
assert snow.RUN_HOUR == 7 and snow.LOOKBACK_H == 12
assert snow.day_window(RUN_DAY) == (datetime(2026, 2, 9, 7), datetime(2026, 2, 10, 7))
assert snow.photo_time(RUN_DAY) == datetime(2026, 2, 10, 9)
def test_warm_day_no_snow_possible():
a = _one(_day(3, WARM, rain=_set([0.0] * 36, LB + 5, [4.0])))
assert a.status == NO_EVENT and a.severity == Severity.NONE
assert a.details["cold_hours"] == 0 and a.details["signals"] == []
assert "camera_request" not in a.details
assert a.summary.startswith("No snow possible: lowest wet-bulb")
def test_signal_a_cold_precipitation_is_likely_and_asks_for_a_photo():
t = _set(WARM, LB + 15, [-1.0, -1.5, -1.0]) # 23:00, 00:00, 01:00
rain = _set([0.0] * 36, LB + 15, [1.2, 2.0, 0.8])
a = _one(_day(3, t, rh=[90.0] * 36, rain=rain))
assert a.status == EVENT and a.severity == Severity.NONE and a.severity_score == 0.0
d = a.details
assert d["signals"] == ["A"] and d["suspicion"] == "likely"
assert d["snow_water_mm"] == 4.0 and d["est_new_snow_cm"] == 4.0 # × 10, mm -> cm
assert (a.duration_h, a.start, a.end) == (3, datetime(2026, 2, 9, 23), datetime(2026, 2, 10, 1))
req = camera_request(d)
assert req == {"phenomenon": "snow", "station_id": 3, "photo_at": "2026-02-10T09:00",
"level": "likely", "signals": ["A"], "reason": req["reason"]}
assert "Photo requested for 10 Feb 09:00" in a.summary and "4 cm" in a.summary
def test_signal_b_rain_soon_after_cold_hours_only_within_12h():
t = _set(WARM, 0, [-2.0] * LB) # cold dry night before the window
rain = _set([0.0] * 36, LB + 2, [3.0]) # 10:00, 3 h after the last cold hour
a = _one(_day(3, t, rain=rain))
assert a.status == EVENT and a.details["signals"] == ["B"] and a.details["suspicion"] == "likely"
assert a.details["melt_water_mm"] == 3.0 and a.details["est_new_snow_cm"] == 0.0
assert a.details["est_new_snow_max_cm"] == 3.0
late = _one(_day(3, t, rain=_set([0.0] * 36, LB + 20, [3.0]))) # 04:00: 21 h later
assert late.status == NO_EVENT
def test_signal_c_only_is_possible():
t = _set(WARM, LB + 18, [-0.5] * 4)
rh = _set([70.0] * 36, LB + 18, [98.0] * 4)
a = _one(_day(3, t, rh=rh))
assert a.status == EVENT and a.details["signals"] == ["C"] and a.details["suspicion"] == "possible"
assert a.details["saturated_hours"] == 4 and a.duration_h == 4
def test_signal_d_raises_c_to_likely_but_never_triggers_alone():
t = _set(WARM, LB, [-0.5] * 3) # 08:00 ... 10:00 cold and saturated
rh = _set([70.0] * 36, LB, [98.0] * 3)
dark = _set([0.0] * 36, LB + 3, [10.0] * 4) # midday records 11:00 ... 14:00
a = _one(_day(3, t, rh=rh, solar=dark), sensors=ALL)
assert a.details["signals"] == ["C", "D"] and a.details["suspicion"] == "likely"
assert a.details["sensor_covered"] is True and a.details["midday_solar_max_wm2"] == 10
grey = _one(_day(3, t, rh=rh, solar=_set(dark, LB + 3, [150.0] * 4)), sensors=ALL)
assert grey.details["signals"] == ["C"] and grey.details["sensor_covered"] is False
dry_cold = _one(_day(3, [-1.0] * 36, solar=dark), sensors=ALL) # cold, dark, no other sign
assert dry_cold.status == NO_EVENT and dry_cold.details["cold_hours"] == 24
def test_no_humidity_uses_air_temperature():
t = _set(WARM, LB + 15, [0.5])
rain = _set([0.0] * 36, LB + 15, [2.0])
sensors = {"TemperaturaAria", "Pioggia"}
a = _one(_day(3, t, rh=[np.nan] * 36, rain=rain), sensors=sensors)
assert a.status == EVENT and a.details["signals"] == ["A"]
assert a.details["cold_basis"] == "air" and a.details["air_fallback_hours"] == 24
assert any("No humidity sensor" in n for n in a.data_notes)
dry = _one(_day(3, [-1.0] * 36, rh=[np.nan] * 36), sensors=sensors) # C cannot fire
assert dry.status == NO_EVENT
def test_no_rain_gauge_only_saturated_air_can_flag():
rh = _set([70.0] * 36, LB + 18, [98.0] * 2)
sensors = {"TemperaturaAria", "UmiditaRelativa"}
a = _one(_day(3, [-1.0] * 36, rh=rh, rain=[np.nan] * 36), sensors=sensors)
assert a.status == EVENT and a.details["signals"] == ["C"] and a.details["rain_checked"] is False
assert any("No rain gauge" in n for n in a.data_notes)
def test_missing_temperature_and_missing_station():
a = _one(_day(3, [np.nan] * 36))
assert a.status == NO_DATA and "No air temperature data" in a.data_notes[0]
part = _one(_day(3, _set(WARM, LB, [np.nan] * 4)))
assert part.status == NO_EVENT and part.details["records"] == 20
assert any("missing for 4 of 24" in n for n in part.data_notes)
[none] = snow.detect(_day(3, WARM), [_field(5)], RUN_DAY)
assert none.status == NO_DATA and "No records" in none.data_notes[0]
def test_solar_note_and_cover_window():
a = _one(_day(3, WARM))
assert any("No solar radiation sensor" in n for n in a.data_notes)
assert snow.in_cover_window(date(2026, 11, 15), 60) is False
assert snow.in_cover_window(date(2026, 4, 1), None) is None
assert snow.in_cover_window(date(2026, 4, 1), 40) is False # dormant
assert snow.in_cover_window(date(2026, 4, 1), 60) is True
def test_event_stores_and_reloads_as_json():
t = _set(WARM, LB + 15, [-1.0])
a = _one(_day(3, t, rain=_set([0.0] * 36, LB + 15, [1.0])), bbch=55)
assert a.details["in_cover_window"] is True and a.details["bbch"] == 55
back = Analysis.from_dict(json.loads(json.dumps(a.to_dict())))
assert camera_request(back.details)["photo_at"] == "2026-02-10T09:00"