311 lines
15 KiB
Python
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())
|