Weather-Crop-Damage/src/damage_engine/detectors/strong_wind.py
2026-10-27 14:13:32 +01:00

311 lines
15 KiB
Python

"""
Strong wind (storm) detector: wind of at least 50 km/h (13.9 m/s). Runs every
24 h, on the 24 h ending at 07:00 of the run day.
Contract (UnipolSai Model 1506, p.8): strong wind = "at least force 7 on the
Beaufort scale - a speed of 50 km/h or more (14 m/s)", measured below 10 m height
(the 3A anemometers are at 2 m, so every reading is valid). The contract does not
say over what time the speed is measured. All values are read from
damage_engine.thresholds; sources as tagged there:
[CONTRACT] UnipolSai Model 1506, Ed. 01/2021
[SCALE] Beaufort scale
[ENGINEERING] project default, the contract gives no number (to calibrate)
[PROJECT] pipeline setting, not a physical threshold
Input (Diagram.png): wind speed only (3A sensor VelocitaVento2m, 3A sends hourly values only:
- VelocitaVento2mMax = highest wind of the hour, m/s
- VelocitaVento2mMedia = mean wind of the hour, m/s
STRONG_WIND["use_mean_or_gust"] = "hourly_max" [ENGINEERING, chosen by the user
2026-10-22] picks the value that is compared with the trigger: the hourly
maximum, so a storm shorter than one hour is still found. 3A does not say if
its maximum is a 10-min mean (= the contract's measure) or a gust (higher than
the contract's measure): see README, Known issues. "hourly_mean" would use the
hourly mean instead. The other value is reported only.
Event day - STRONG_WIND_SCHEDULE["run_hour"] = 7 [PROJECT]: records after 07:00
of the previous day up to 07:00 of the run day (24 hourly records; the hourly
Timestamp is taken as the end of the hour, as in Flood).
An hour is a strong-wind hour when (STRONG_WIND):
- wind (the value chosen above) >= wind_trigger_ms = 13.9 m/s, operator
wind_trigger_op = ">=" [CONTRACT p.8, "50 km/h or more"; 50 km/h and the
Beaufort 7 lower bound are both 13.9 m/s].
Event when the strong-wind time reaches min_duration_min = 10 min [ENGINEERING]
(one 10-min averaging period). One hourly record counts as 60 min, so one hour
is enough.
Severity, the same for every field and crop stage: the highest wind of the
strong-wind hours graded with STRONG_WIND_BANDS_MS [SCALE, Beaufort 7/8/9/10]:
>= 13.9 / 17.2 / 20.8 / 24.5 m/s -> light / moderate / severe / extreme.
The bands only grade an event that already passed the trigger.
Cover window: COVER_WINDOWS["strong_wind"] = fruit set -> 31 Oct [CONTRACT Special
Art.5 p.31], checked on the day of the first strong-wind hour. Fruit set = BBCH
above the last "post_bloom" code of APPLE_BBCH_TO_STAGE [ENGINEERING, approx.],
the same rule as Flood (a copy: a detector may not import another detector).
Not checked by the block: the contract's rule that the effects must also show on
orchards within GENERAL["effect_radius_km"] = 3 km (no sensor can tell).
Missing sensors / missing values - the detector still runs:
- wind is essential: a station without a wind sensor (VelocitaVento2m; 7 of the
14 stations) gives "no_data" ("station lacks required sensors"); hours with
no wind value at all (no record, or both values empty) are not checked; a day
without any usable hour gives "no_data".
- hourly maximum missing but hourly mean present (trigger "hourly_max"): the
hourly mean is used for that hour. It is never above the maximum, so a hit is
still a hit, but a short storm in that hour may be missed.
- hourly mean missing (trigger "hourly_max"): only not reported for that hour.
- trigger "hourly_mean" with the mean missing: that hour is not checked (the
maximum would overstate the mean).
Missing columns and missing hours are read as empty (_col, hourly grid), so no
sensor causes an error. Every case is written to Analysis.data_notes (sensor
missing at the station, no data, or N of M hours missing, plus what was done).
"""
import argparse
import logging
import operator
from datetime import date, datetime, time, timedelta
import numpy as np
import pandas as pd
from damage_engine.schemas import EVENT, NO_DATA, NO_EVENT, Analysis, Severity
from damage_engine.severity import grade
from damage_engine.sources.fields import Field, all_fields
from damage_engine.sources.stations import station_by_id, stations_with
from damage_engine.sources.threea import fetch_hourly
from damage_engine.thresholds import (
APPLE_BBCH_TO_STAGE,
COVER_WINDOWS,
STRONG_WIND,
STRONG_WIND_BANDS_MS,
STRONG_WIND_SCHEDULE,
)
log = logging.getLogger(__name__)
BLOCK = "strong_wind"
PHENOMENON = "strong_wind" # contract name, key in COVER_WINDOWS
RUN_HOUR = STRONG_WIND_SCHEDULE["run_hour"]
MINUTES_PER_RECORD = 60 # hourly data
HOUR = timedelta(hours=1)
MS_TO_KMH = 3.6
OPS = {"<": operator.lt, "<=": operator.le, ">": operator.gt, ">=": operator.ge}
# ─────────────────────────── inputs (3A api_parameters) ───────────────────────────
REQUIRED_SENSORS = ("VelocitaVento2m",)
COL_MAX = "VelocitaVento2mMax" # highest wind of the hour, m/s
COL_MEAN = "VelocitaVento2mMedia" # mean wind of the hour, m/s
COLUMNS = [COL_MAX, COL_MEAN]
# STRONG_WIND["use_mean_or_gust"] -> (column compared with the trigger, fallback column)
TRIGGER_COLUMNS = {
"hourly_max": (COL_MAX, COL_MEAN), # the mean is never above the max: a safe lower bound
"hourly_mean": (COL_MEAN, None), # the max would overstate the mean: no fallback
}
TRIGGER_LABEL = {"hourly_max": "hourly maximum", "hourly_mean": "hourly mean"}
def trigger_columns() -> tuple[str, str | None]:
"""(trigger column, fallback column) for STRONG_WIND["use_mean_or_gust"]."""
choice = STRONG_WIND["use_mean_or_gust"]
if choice not in TRIGGER_COLUMNS:
raise ValueError(f"STRONG_WIND['use_mean_or_gust'] = {choice!r}: "
f"expected one of {sorted(TRIGGER_COLUMNS)}")
return TRIGGER_COLUMNS[choice]
# ─────────────────────────── cover window (same rule as Flood) ───────────────────────────
def fruit_set(bbch: int) -> bool:
"""Fruit set (start of the strong wind cover): BBCH above the last post-bloom code."""
return bbch > max(c for c, s in APPLE_BBCH_TO_STAGE.items() if s == "post_bloom")
def in_cover_window(day: date, bbch: int | None):
"""Contract cover window (fruit set -> end date). None when the stage is unknown."""
_, end_mmdd = COVER_WINDOWS[PHENOMENON]
if day.strftime("%m-%d") > end_mmdd:
return False
if bbch is None:
return None
return fruit_set(bbch)
# ─────────────────────────── window ───────────────────────────
def day_window(run_day: date):
"""(start, end): the 24 h checked are the records after start up to and including end."""
end = datetime.combine(run_day, time(RUN_HOUR))
return end - timedelta(days=1), end
# ─────────────────────────── detection ───────────────────────────
def _col(df, name):
return df[name] if name in df else pd.Series(np.nan, index=df.index)
def day_hours(rows: pd.DataFrame, run_day: date) -> pd.DataFrame:
"""One station's rows on the full hourly grid of the 24 h (missing hour = empty row), with
wind_ms (the value compared with the trigger, fallback filled in), fallback_used and strong."""
start, end = day_window(run_day)
grid = pd.date_range(start + HOUR, end, freq="h", name="Timestamp")
rows = rows.assign(Timestamp=pd.to_datetime(rows["Timestamp"])).drop_duplicates("Timestamp")
df = rows.set_index("Timestamp").reindex(grid).reset_index() # rows outside the 24 h dropped
main_col, fallback_col = trigger_columns()
wind = _col(df, main_col)
df["fallback_used"] = False
if fallback_col is not None:
df["fallback_used"] = wind.isna() & _col(df, fallback_col).notna()
wind = wind.fillna(_col(df, fallback_col))
df["wind_ms"] = wind
df["strong"] = OPS[STRONG_WIND["wind_trigger_op"]](wind, STRONG_WIND["wind_trigger_ms"]) # NaN -> False
return df
def data_notes(df: pd.DataFrame) -> list[str]:
"""Missing data of one station day (df = day_hours()) and how it was handled."""
notes, n = [], len(df)
main_col, fallback_col = trigger_columns()
label = TRIGGER_LABEL[STRONG_WIND["use_mean_or_gust"]]
no_wind = int(df["wind_ms"].isna().sum())
if no_wind == n:
return [f"No wind data in the {n} hours checked: strong wind cannot be judged."]
if no_wind:
notes.append(f"Wind missing for {no_wind} of {n} hours: those hours not checked.")
fallback = int(df["fallback_used"].sum())
if fallback:
notes.append(f"Hourly maximum wind missing for {fallback} of {n} hours: hourly mean used "
f"(a short storm in those hours may be missed).")
if fallback_col is not None: # trigger = hourly max: the mean is reported only
no_mean = int((_col(df, main_col).notna() & _col(df, COL_MEAN).isna()).sum())
if no_mean:
notes.append(f"Hourly mean wind missing for {no_mean} of {n} hours: not reported for "
f"those hours (the check uses the {label}).")
return notes
def _round(x, digits=1):
return None if x is None or pd.isna(x) else round(float(x), digits)
def _iso(t):
return None if t is None or pd.isna(t) else pd.Timestamp(t).isoformat(timespec="minutes")
def analyse_station(df: pd.DataFrame) -> dict:
"""Station-level result of one day: status, duration, times, highest wind, data notes."""
notes = data_notes(df)
checked = df[df["wind_ms"].notna()]
if checked.empty:
return {"status": NO_DATA, "details": {"reason": "no wind records in the 24 h"}, "notes": notes}
top = checked.loc[checked["wind_ms"].idxmax()]
base = {"records": int(len(checked)),
"trigger": STRONG_WIND["use_mean_or_gust"],
"max_wind_ms": _round(top["wind_ms"]),
"max_wind_kmh": _round(top["wind_ms"] * MS_TO_KMH),
"max_wind_at": _iso(top["Timestamp"]),
"max_mean_wind_ms": _round(_col(checked, COL_MEAN).max()),
"fallback_hours": int(checked["fallback_used"].sum())}
hits = checked[checked["strong"]]
minutes = len(hits) * MINUTES_PER_RECORD
if minutes < STRONG_WIND["min_duration_min"]:
return {"status": NO_EVENT, "details": base, "notes": notes}
return {
"status": EVENT,
"notes": notes,
"duration_h": minutes / 60,
"start": hits["Timestamp"].min().to_pydatetime(),
"end": hits["Timestamp"].max().to_pydatetime(),
"max_wind_ms": float(hits["wind_ms"].max()),
"details": {**base, "strong_wind_hours": int(len(hits))},
}
def _summary(res, sev):
if res["status"] == NO_DATA:
return "No wind data from the station for the last 24 hours."
d = res["details"]
wind = f"{d['max_wind_ms']:g} m/s ({d['max_wind_kmh']:g} km/h)"
if res["status"] == NO_EVENT:
return f"No strong wind: highest wind {wind}."
return f"Strong wind for {res['duration_h']:g} h, highest wind {wind}: severity {sev.value}."
def detect(df: pd.DataFrame, fields: list[Field], run_day: date,
station_names: dict[int, str] | None = None) -> list[Analysis]:
"""One Analysis per field: the 24 h of its station, graded with the Beaufort bands.
df: hourly rows (CodiceStazione, Timestamp, COLUMNS) covering day_window(run_day);
rows outside it are ignored.
"""
station_names = station_names or {}
by_station = {}
if not df.empty:
for sid, rows in df.groupby("CodiceStazione"):
by_station[int(sid)] = analyse_station(day_hours(rows, run_day))
out = []
for f in fields:
res = by_station.get(f.station_id, {"status": NO_DATA, "details": {"reason": "no records"},
"notes": ["No records from the station for the last "
"24 hours."]})
sev, score = Severity.NONE, 0.0
details = dict(res["details"])
if res["status"] == EVENT:
sev, score = grade(res["max_wind_ms"], STRONG_WIND_BANDS_MS)
details["in_cover_window"] = in_cover_window(res["start"].date(), f.bbch)
if f.bbch is not None:
details["bbch"] = f.bbch
out.append(Analysis(
block=BLOCK, run_day=run_day,
field_id=f.id, field_name=f.name,
station_id=f.station_id,
station_name=station_names.get(f.station_id, str(f.station_id)),
status=res["status"], severity=sev, severity_score=score,
duration_h=res.get("duration_h", 0.0),
start=res.get("start"), end=res.get("end"),
summary=_summary(res, sev), details=details,
data_notes=res.get("notes", []),
))
return out
def run(run_day: date | None = None) -> list[Analysis]:
"""Fetch the last 24 h of hourly wind for every field's station and detect strong wind."""
run_day = run_day or date.today()
start, end = day_window(run_day)
stations = {s.id: s for s in stations_with(*REQUIRED_SENSORS)}
fields = all_fields()
usable = [f for f in fields if f.station_id in stations]
ids = sorted({f.station_id for f in usable})
df = fetch_hourly(ids, start + HOUR, end, COLUMNS) if ids else pd.DataFrame()
out = detect(df, usable, run_day, {i: s.name for i, s in stations.items()})
for f in fields:
if f.station_id not in stations:
known = station_by_id(f.station_id)
note = ("Station not in the 3A catalogue: strong wind cannot be judged." if known is None
else "No wind sensor (VelocitaVento2m) at this station: strong wind cannot be judged.")
out.append(Analysis(block=BLOCK, run_day=run_day, field_id=f.id, field_name=f.name,
station_id=f.station_id,
station_name=known.name if known else str(f.station_id),
status=NO_DATA, summary="The station has no wind sensor.",
details={"reason": "station lacks required sensors"},
data_notes=[note]))
n_events = sum(a.detected for a in out)
log.info("Strong wind %s: %d event(s) over %d field(s), %d station(s)",
run_day, n_events, len(out), len(ids))
return out
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
p = argparse.ArgumentParser(description="Run the Strong wind detector once (prints, does not save).")
p.add_argument("--date", type=date.fromisoformat, help="run day to evaluate (default: today)")
args = p.parse_args()
for a in run(args.date):
print(a.to_dict())