File size: 14,945 Bytes
a74054f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
"""
River basin graph construction for La Eure and La Risle.

Ordering is elevation-first (falling back to latitude per-basin when any
station in that basin is missing elevation β€” see `order_stations_by_elevation`),
and edges are built by simply connecting consecutive stations in that order
within each basin. This guarantees every edge in the graph is monotonically
downhill by construction: there is no way for a "downstream" edge to point
to a higher point, because the ordering itself defines what "downstream"
means (previous versions of this module inferred edges from a nearest-lower-
elevation-within-radius heuristic instead, which could leave stations
disconnected β€” this version favors always producing a connected path per
basin as the default).

VISUALIZATION NOTE: a lat/lon map plot with north up will show "downstream"
pointing UP the page whenever the river flows north (true for both La Eure
and La Risle in this dataset). That's geographically correct, not a bug β€”
but it reads as backwards at a glance. `plot_elevation_profile` avoids this
ambiguity entirely by plotting elevation directly: downstream always goes
down and to the right, regardless of the river's compass direction.

IMPORTANT β€” still a heuristic, not ground truth:
  - `basin_id` is inferred from the station code prefix (H4xx.. vs
    H6xx..). Confirm against real basin delineation (e.g. the watershed
    shapefile) before trusting it.
  - Connecting stations strictly by sorted order assumes each basin is a
    single unbranched chain. Real confluences (a basin with more than one
    headwater branch merging into a shared downstream reach) will be
    flattened into one path. If you have known branch structure, build
    edges per-branch and merge instead of relying on the whole-basin order.
"""
from dataclasses import dataclass
from typing import Optional, Dict, List, Tuple

import pandas as pd
import matplotlib.pyplot as plt

_PALETTE = ["#2E6F95", "#D1495B", "#3E8914", "#E1A730", "#7C6BAF", "#3D9992"]


def _style_axes(ax: plt.Axes, title: str, xlabel: str, ylabel: str) -> None:
    ax.set_title(title, fontsize=13, fontweight="bold", pad=12)
    ax.set_xlabel(xlabel, fontsize=10)
    ax.set_ylabel(ylabel, fontsize=10)
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.grid(True, alpha=0.25, linewidth=0.6)
    ax.tick_params(labelsize=9)


def _haversine_km(lat1, lon1, lat2, lon2) -> float:
    import numpy as np
    R = 6371.0
    lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
    dlat, dlon = lat2 - lat1, lon2 - lon1
    a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
    return R * 2 * np.arcsin(np.sqrt(a))


def assign_basin_id(
    station_code: str,
    prefix_map: Optional[Dict[str, int]] = None,
) -> Optional[int]:
    """
    Infer basin_id from a Hub'Eau station code prefix.

    Default mapping (heuristic, based on observed codes in this project):
        'H4' -> 0  (La Eure)
        'H6' -> 1  (La Risle)
    """
    prefix_map = prefix_map or {"H4": 0, "H6": 1}
    for prefix, basin_id in prefix_map.items():
        if station_code.startswith(prefix):
            return basin_id
    return None


@dataclass
class StationNode:
    station_id: str
    basin_id: int
    latitude: float
    longitude: float
    elevation: Optional[float] = None


class NodeBuilder:
    @staticmethod
    def build_from_dataframe(
        elevations_df: pd.DataFrame,
        prefix_map: Optional[Dict[str, int]] = None,
    ) -> List[StationNode]:
        """
        Build StationNode objects from a station elevations DataFrame.
        `elevation_m` may contain NaN β€” those become `elevation=None`
        rather than being dropped, so `order_stations_by_elevation` can
        apply its per-basin latitude fallback instead of silently losing
        stations (the earlier version of this module dropped them).
        """
        nodes = []
        for _, row in elevations_df.iterrows():
            basin_id = assign_basin_id(row["station_code"], prefix_map)
            if basin_id is None:
                print(f"NodeBuilder: '{row['station_code']}' matched no basin prefix, skipping")
                continue
            elev = row.get("elevation_m")
            nodes.append(StationNode(
                station_id=row["station_code"],
                basin_id=basin_id,
                latitude=row["latitude"],
                longitude=row["longitude"],
                elevation=None if pd.isna(elev) else float(elev),
            ))
        return nodes


def order_stations_by_elevation(
    nodes: List[StationNode],
    descending: bool = True
) -> List[str]:
    """
    Order station IDs by elevation β€” a physically-grounded proxy for a station's position
    along a river's course (higher elevation = further upstream, lower = further
    downstream) β€” for use as `station_order` in sequential edge building.

    This matters because a station list's raw row order commonly does NOT follow the
    river's actual course (verified for this dataset: station_list.csv's row order jumps
    around in both latitude and elevation, not monotonic either way).

    Stations are grouped by basin_id first (so ordering is computed independently per
    river). Within each basin:
    - If every station in the basin has a known elevation, sort by elevation (the
      physically-grounded signal β€” holds regardless of which way a river flows on a map).
    - If any station in the basin is missing elevation (e.g. a topology-only station with
      no time-series data, using virtual/placeholder data), the WHOLE basin falls back to
      latitude instead. Mixing elevation and latitude values as sort keys within one basin
      would be comparing incompatible scales (elevation in meters vs. latitude in degrees)
      and silently produce garbage ordering β€” falling back per-basin, not per-node, avoids
      that. Note this latitude convention (ascending latitude = downstream) was verified
      against this dataset's two rivers, both of which flow south -> north; it is not a
      universal rule for all river networks.

    Args:
        nodes: StationNode objects (e.g. from NodeBuilder.build_from_dataframe / .nodes)
        descending: True for upstream -> downstream order (highest elevation / lowest
            latitude first, the convention `EdgeBuilder.build_sequential` expects).

    Returns:
        Station IDs ordered upstream -> downstream within each basin
    """
    basins: Dict[int, List[StationNode]] = {}
    for n in nodes:
        basins.setdefault(n.basin_id, []).append(n)

    ordered_ids: List[str] = []
    for basin_id in sorted(basins.keys()):
        group = basins[basin_id]
        has_full_elevation = all(n.elevation is not None for n in group)

        if has_full_elevation:
            group_sorted = sorted(group, key=lambda n: -n.elevation if descending else n.elevation)
        else:
            missing = sum(1 for n in group if n.elevation is None)
            print(
                f"order_stations_by_elevation: basin {basin_id} has {missing}/{len(group)} "
                f"station(s) without elevation β€” ordering this basin by latitude instead "
                f"(mixing elevation and latitude as one sort key would be inconsistent)."
            )
            group_sorted = sorted(group, key=lambda n: n.latitude if descending else -n.latitude)

        ordered_ids.extend(n.station_id for n in group_sorted)

    return ordered_ids


class EdgeBuilder:
    @staticmethod
    def build_sequential(nodes: List[StationNode], station_order: List[str]) -> pd.DataFrame:
        """
        Connect consecutive stations within `station_order`, one edge per
        adjacent pair that share a basin_id (a basin boundary in the order
        list breaks the chain rather than creating a cross-basin edge).

        Returns:
            edges_df: [source, target, basin_id, distance_km, elevation_drop_m]
        """
        by_id = {n.station_id: n for n in nodes}
        edges = []
        for a_id, b_id in zip(station_order, station_order[1:]):
            a, b = by_id[a_id], by_id[b_id]
            if a.basin_id != b.basin_id:
                continue  # basin boundary: don't chain across rivers
            elev_drop = None
            if a.elevation is not None and b.elevation is not None:
                elev_drop = round(a.elevation - b.elevation, 2)
            edges.append({
                "source": a.station_id,
                "target": b.station_id,
                "basin_id": a.basin_id,
                "distance_km": round(_haversine_km(a.latitude, a.longitude, b.latitude, b.longitude), 3),
                "elevation_drop_m": elev_drop,
            })
        return pd.DataFrame(edges)


def build_basin_graph(
    elevations_df: pd.DataFrame,
    prefix_map: Optional[Dict[str, int]] = None,
) -> Tuple[pd.DataFrame, pd.DataFrame]:
    """
    Build station_nodes and station_edges tables for both basins using
    elevation-ordered (latitude-fallback) sequential chaining.

    Args:
        elevations_df: [station_code, latitude, longitude, elevation_m]
            (as returned by StationElevationsLoader.load()). elevation_m
            may be NaN for some rows β€” those stations still get placed via
            the per-basin latitude fallback, not dropped.
        prefix_map: optional override for basin assignment, see `assign_basin_id`.

    Returns:
        (nodes_df, edges_df):
            nodes_df: [station_code, basin_id, latitude, longitude, elevation_m]
            edges_df: [source, target, basin_id, distance_km, elevation_drop_m]
    """
    nodes = NodeBuilder.build_from_dataframe(elevations_df, prefix_map)
    station_order = order_stations_by_elevation(nodes, descending=True)
    edges_df = EdgeBuilder.build_sequential(nodes, station_order)

    nodes_df = pd.DataFrame([{
        "station_code": n.station_id, "basin_id": n.basin_id,
        "latitude": n.latitude, "longitude": n.longitude, "elevation_m": n.elevation,
    } for n in nodes])

    return nodes_df, edges_df


def save_graph(nodes_df: pd.DataFrame, edges_df: pd.DataFrame, output_dir) -> None:
    """Write station_nodes.csv and station_edges.csv."""
    from pathlib import Path
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    nodes_df.to_csv(output_dir / "station_nodes.csv", index=False)
    edges_df.to_csv(output_dir / "station_edges.csv", index=False)
    print(f"Saved {len(nodes_df)} nodes, {len(edges_df)} edges to {output_dir}")


def plot_basin_graphs(
    nodes_df: pd.DataFrame,
    edges_df: pd.DataFrame,
    basin_names: Optional[Dict[int, str]] = None,
    figsize: tuple = (14, 7),
    save_path=None,
) -> plt.Figure:
    """
    Geographic map of both basin graphs: stations positioned by lon/lat,
    colored by elevation, with arrows showing downstream flow.

    Reminder: "downstream" will point UP the page for a north-flowing
    river β€” that's correct geography, not a bug. Use
    `plot_elevation_profile` if you want flow direction to always read
    top-to-bottom / left-to-right regardless of compass direction.
    """
    basin_names = basin_names or {b: f"Basin {b}" for b in nodes_df["basin_id"].unique()}
    basin_ids = sorted(nodes_df["basin_id"].unique())

    fig, axes = plt.subplots(1, len(basin_ids), figsize=figsize)
    if len(basin_ids) == 1:
        axes = [axes]

    for ax, basin_id in zip(axes, basin_ids):
        b_nodes = nodes_df[nodes_df["basin_id"] == basin_id]
        b_edges = edges_df[edges_df["basin_id"] == basin_id] if not edges_df.empty else edges_df

        for _, edge in b_edges.iterrows():
            src = b_nodes[b_nodes["station_code"] == edge["source"]].iloc[0]
            tgt = b_nodes[b_nodes["station_code"] == edge["target"]].iloc[0]
            ax.annotate(
                "", xy=(tgt["longitude"], tgt["latitude"]),
                xytext=(src["longitude"], src["latitude"]),
                arrowprops=dict(arrowstyle="-|>", color="#888888", lw=1.4,
                                 shrinkA=8, shrinkB=8),
            )

        scatter = ax.scatter(
            b_nodes["longitude"], b_nodes["latitude"], c=b_nodes["elevation_m"],
            cmap="terrain", s=140, edgecolor="black", linewidth=0.6, zorder=3,
        )
        for _, row in b_nodes.iterrows():
            ax.annotate(row["station_code"], (row["longitude"], row["latitude"]),
                        fontsize=6.5, xytext=(4, 4), textcoords="offset points")

        cbar = plt.colorbar(scatter, ax=ax, fraction=0.046, pad=0.04)
        cbar.set_label("Elevation (m)", fontsize=9)
        cbar.ax.tick_params(labelsize=8)

        _style_axes(ax, basin_names.get(basin_id, f"Basin {basin_id}"), "Longitude", "Latitude")
        ax.set_aspect("equal")

    plt.tight_layout()
    if save_path:
        plt.savefig(save_path, dpi=150, bbox_inches="tight")
        print(f"Plot saved to {save_path}")

    return fig


def plot_elevation_profile(
    nodes_df: pd.DataFrame,
    edges_df: pd.DataFrame,
    basin_names: Optional[Dict[int, str]] = None,
    figsize: tuple = (11, 6),
    save_path=None,
) -> plt.Axes:
    """
    Schematic river profile: x = downstream sequence position, y = elevation.
    Unlike the geographic map, this is unambiguous regardless of which
    compass direction the river actually flows β€” downstream always reads
    left-to-right and (by construction, since edges are built from sorted
    order) downhill.
    """
    basin_names = basin_names or {b: f"Basin {b}" for b in nodes_df["basin_id"].unique()}
    n = nodes_df.set_index("station_code")

    fig, ax = plt.subplots(figsize=figsize)
    for i, basin_id in enumerate(sorted(nodes_df["basin_id"].unique())):
        b_edges = edges_df[edges_df["basin_id"] == basin_id]
        if b_edges.empty:
            continue
        # Walk the chain in edge order to get sequence position.
        chain = [b_edges.iloc[0]["source"]] + b_edges["target"].tolist()
        elevations = [n.loc[s, "elevation_m"] for s in chain]
        color = _PALETTE[i % len(_PALETTE)]

        ax.plot(range(len(chain)), elevations, marker="o", markersize=7,
                 linewidth=2, color=color, label=basin_names.get(basin_id, f"Basin {basin_id}"))
        for x, (station, elev) in enumerate(zip(chain, elevations)):
            ax.annotate(station, (x, elev), fontsize=6.5, rotation=45,
                        xytext=(0, 8), textcoords="offset points", ha="left")

    _style_axes(ax, "River Elevation Profile (upstream β†’ downstream)",
                "Position along river (upstream β†’ downstream)", "Elevation (m)")
    ax.set_xticks([])
    ax.legend(fontsize=9, frameon=False)
    plt.tight_layout()

    if save_path:
        plt.savefig(save_path, dpi=150, bbox_inches="tight")
        print(f"Plot saved to {save_path}")

    return ax