""" 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())