Spaces:
Running on Zero
Running on Zero
File size: 7,000 Bytes
f2046b4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | """
Identifies real historical high-flow events from discharge and
water-level records, station by station. This is the ground truth
against which any flood-prediction claim eventually has to be checked
-- "beats a naive mean-baseline" and "would have flagged the real
floods that actually happened" are different questions, and only the
second one matters for this project's actual purpose.
Threshold: each station's own historical 95th/99th percentile, not a
shared absolute value across stations. These rivers have very
different scales (median discharge ranges from under 1,000 to over
9,000 L/s across real stations in this project), so a fixed absolute
threshold would flag "high flow" at a small tributary while missing
real extremes at a larger one. A station-relative threshold puts every
station's real flood-scale events on the same footing.
Events, not days: a real flood spans several consecutive days above
threshold, and counting each day as a separate "event" would both
inflate the count and obscure how long real high-flow periods actually
last -- duration matters for anything downstream (evacuation windows,
infrastructure impact), not just peak magnitude.
Usage:
python -m scripts.identify_flood_events --data-root datasets
"""
import argparse
from pathlib import Path
import numpy as np
import pandas as pd
try:
from src.graph.dynamic_features import build_discharge_timeseries, build_waterlevel_timeseries
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.graph.dynamic_features import build_discharge_timeseries, build_waterlevel_timeseries
def find_events(series: pd.Series, threshold: float, min_duration_days: int = 1) -> pd.DataFrame:
"""
Groups consecutive above-threshold days into discrete events for a
single station's real time series.
A gap of even one day below threshold ends the current event --
real recession rarely means the flow dropped and immediately rose
again, so treating a brief dip as "still the same event" would
understate how variable real hydrographs actually are.
Returns one row per event: start date, end date, duration, peak
value, peak date. Events shorter than min_duration_days are
dropped -- a single anomalous reading (a possible data error, or a
genuinely brief spike) is a different thing from a sustained high-
flow event, and conflating them would dilute what "event" means for
later evaluation.
"""
above = series >= threshold
if not above.any():
return pd.DataFrame(columns=["start_date", "end_date", "duration_days", "peak_value", "peak_date"])
# Assign a group id that increments each time the series drops
# below threshold -- consecutive True values share a group.
group_id = (~above).cumsum()
events = []
for gid, group in series[above].groupby(group_id[above]):
duration = len(group)
if duration < min_duration_days:
continue
events.append({
"start_date": group.index.min(),
"end_date": group.index.max(),
"duration_days": duration,
"peak_value": group.max(),
"peak_date": group.idxmax(),
})
return pd.DataFrame(events)
def identify_station_events(
wide: pd.DataFrame, percentile: float = 95.0, min_duration_days: int = 1,
) -> pd.DataFrame:
"""
Runs find_events for every station in a [date x station_code] wide
table, using each station's own real historical percentile as its
threshold. Stations with too little real data to compute a
meaningful percentile (fewer than 30 real observations, an
arbitrary but reasonable floor -- a percentile from a handful of
readings isn't a real threshold) are skipped, not given a
threshold computed from noise.
"""
all_events = []
for station_code in wide.columns:
series = wide[station_code].dropna()
if len(series) < 30:
continue
threshold = np.percentile(series, percentile)
station_events = find_events(series, threshold, min_duration_days)
if station_events.empty:
continue
station_events["station_code"] = station_code
station_events["threshold"] = threshold
station_events["threshold_percentile"] = percentile
all_events.append(station_events)
if not all_events:
return pd.DataFrame(columns=["station_code", "start_date", "end_date", "duration_days",
"peak_value", "peak_date", "threshold", "threshold_percentile"])
return pd.concat(all_events, ignore_index=True).sort_values(["station_code", "start_date"])
def main() -> None:
parser = argparse.ArgumentParser(description="Identify real historical high-flow events")
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
parser.add_argument("--stations-path", type=Path, default=None)
parser.add_argument("--start-date", type=str, default="2013-01-01")
parser.add_argument("--end-date", type=str, default="2026-12-31")
parser.add_argument("--percentile", type=float, default=95.0,
help="Station-relative percentile defining the threshold for a high-flow day.")
parser.add_argument("--min-duration-days", type=int, default=1)
parser.add_argument("--output-dir", type=Path, default=None)
args = parser.parse_args()
stations_path = args.stations_path or (args.data_root / "station_elevations.csv")
nodes_df = pd.read_csv(stations_path)
date_range = (args.start_date, args.end_date)
output_dir = args.output_dir or args.data_root
discharge_wide = build_discharge_timeseries(nodes_df, args.data_root / "hydrometric", date_range)
discharge_events = identify_station_events(discharge_wide, args.percentile, args.min_duration_days)
print(f"Discharge: {len(discharge_events)} real event(s) across "
f"{discharge_events['station_code'].nunique() if not discharge_events.empty else 0} station(s)")
if not discharge_events.empty:
print(discharge_events.to_string(index=False))
discharge_out = output_dir / "flood_events_discharge.csv"
discharge_events.to_csv(discharge_out, index=False)
print(f"Saved to {discharge_out}\n")
waterlevel_wide = build_waterlevel_timeseries(nodes_df, args.data_root / "hydrometric", date_range)
waterlevel_events = identify_station_events(waterlevel_wide, args.percentile, args.min_duration_days)
print(f"Water level: {len(waterlevel_events)} real event(s) across "
f"{waterlevel_events['station_code'].nunique() if not waterlevel_events.empty else 0} station(s)")
if not waterlevel_events.empty:
print(waterlevel_events.to_string(index=False))
waterlevel_out = output_dir / "flood_events_waterlevel.csv"
waterlevel_events.to_csv(waterlevel_out, index=False)
print(f"Saved to {waterlevel_out}")
if __name__ == "__main__":
main() |