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