156 lines
7.2 KiB
Python
156 lines
7.2 KiB
Python
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"
|