File size: 8,075 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
"""
Real river centerline geometry, digitized from road-map traces (traced
polylines over an OSM-style map, exported as PNG, then extracted via
color-threshold + skeletonization + shortest-path ordering, and
georeferenced using known town locations visible on each map).

This REPLACES the straight-line-between-gauges approximation in
river_line.py with the actual river shape. Gauges are "snapped" onto the
nearest point of the real centerline so we can measure true along-river
distance instead of great-circle distance between two points.

ACCURACY NOTE: georeferencing used ~6-7 manually-read reference points
per map (town label positions cross-referenced against their real
coordinates), fit with a least-squares affine transform. Residuals at
the reference points were on the order of a few km — good enough to
turn "straight line" into "recognizably the right river shape", but
not survey-grade. If precise positions matter (e.g. for the physics-
informed model), replace these CSVs with a real vector source (IGN BD
TOPO hydrography or similar) later; every function below only depends
on having a DataFrame of [longitude, latitude] points in order, so the
swap doesn't require touching calling code.
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

import numpy as np
import pandas as pd

from .river_graph import _haversine_km


@dataclass
class CenterlinePoint:
    """A point on (or projected onto) a river centerline."""
    longitude: float
    latitude: float
    distance_from_mouth_km: float   # cumulative distance along the line from its first vertex
    total_length_km: float


def load_centerline(csv_path: Path) -> pd.DataFrame:
    """Load a digitized centerline CSV: [seq, longitude, latitude], ordered
    from one end of the river to the other (mouth -> source or vice versa,
    whichever the source trace started at)."""
    df = pd.read_csv(csv_path).sort_values("seq").reset_index(drop=True)
    return df[["longitude", "latitude"]]


def cumulative_distance_km(centerline: pd.DataFrame) -> np.ndarray:
    """Cumulative along-line distance (km) at each vertex, starting at 0."""
    lons, lats = centerline["longitude"].values, centerline["latitude"].values
    dists = [0.0]
    for i in range(1, len(centerline)):
        dists.append(dists[-1] + _haversine_km(lats[i-1], lons[i-1], lats[i], lons[i]))
    return np.array(dists)


def interpolate_by_fraction(centerline: pd.DataFrame, fraction: float) -> CenterlinePoint:
    """Interpolate a point at `fraction` (0-1) of the centerline's total length."""
    fraction = float(np.clip(fraction, 0.0, 1.0))
    cum = cumulative_distance_km(centerline)
    total = cum[-1]
    target = fraction * total

    idx = np.searchsorted(cum, target)
    idx = max(1, min(idx, len(cum) - 1))
    seg_start, seg_end = cum[idx-1], cum[idx]
    seg_frac = 0.0 if seg_end == seg_start else (target - seg_start) / (seg_end - seg_start)

    lon = centerline["longitude"].iloc[idx-1] + seg_frac * (
        centerline["longitude"].iloc[idx] - centerline["longitude"].iloc[idx-1])
    lat = centerline["latitude"].iloc[idx-1] + seg_frac * (
        centerline["latitude"].iloc[idx] - centerline["latitude"].iloc[idx-1])

    return CenterlinePoint(longitude=lon, latitude=lat,
                            distance_from_mouth_km=target, total_length_km=total)


def nearest_point_on_centerline(
    centerline: pd.DataFrame, latitude: float, longitude: float
) -> CenterlinePoint:
    """Project an arbitrary (e.g. clicked) lat/lon onto the nearest point
    on the centerline, returning its position along the line."""
    cum = cumulative_distance_km(centerline)
    lons, lats = centerline["longitude"].values, centerline["latitude"].values

    best = None
    for i in range(len(centerline) - 1):
        ax, ay = lons[i], lats[i]
        bx, by = lons[i+1], lats[i+1]
        px, py = longitude, latitude
        abx, aby = bx - ax, by - ay
        denom = abx**2 + aby**2
        t = 0.0 if denom == 0 else np.clip(((px-ax)*abx + (py-ay)*aby) / denom, 0.0, 1.0)
        proj_lon, proj_lat = ax + t*abx, ay + t*aby
        dist_to_line = _haversine_km(latitude, longitude, proj_lat, proj_lon)
        if best is None or dist_to_line < best[0]:
            seg_len = _haversine_km(ay, ax, by, bx)
            along_km = cum[i] + t * seg_len
            best = (dist_to_line, proj_lon, proj_lat, along_km)

    _, proj_lon, proj_lat, along_km = best
    return CenterlinePoint(longitude=proj_lon, latitude=proj_lat,
                            distance_from_mouth_km=along_km, total_length_km=cum[-1])


def snap_gauges_to_centerline(
    centerline: pd.DataFrame, nodes_df: pd.DataFrame
) -> pd.DataFrame:
    """
    For each gauge station in `nodes_df`, find its nearest point on the
    real centerline and record that position's along-line distance.
    Adds columns: centerline_lon, centerline_lat, centerline_km, snap_distance_km
    (snap_distance_km is how far the gauge's actual coordinates are from
    the digitized line — a large value flags a likely-misplaced gauge or
    georeferencing error worth checking).
    """
    out = nodes_df.copy()
    snapped = out.apply(
        lambda r: nearest_point_on_centerline(centerline, r["latitude"], r["longitude"]),
        axis=1,
    )
    out["centerline_lon"] = [p.longitude for p in snapped]
    out["centerline_lat"] = [p.latitude for p in snapped]
    out["centerline_km"] = [p.distance_from_mouth_km for p in snapped]
    out["snap_distance_km"] = [
        _haversine_km(r["latitude"], r["longitude"], p.latitude, p.longitude)
        for (_, r), p in zip(out.iterrows(), snapped)
    ]
    return out.sort_values("centerline_km").reset_index(drop=True)


def resample_centerline_for_clicks(centerline: pd.DataFrame, gauges_snapped: pd.DataFrame, n_points: int = 300) -> pd.DataFrame:
    """
    Densely resample a centerline into `n_points` evenly-spaced-by-distance
    points, each with precomputed elevation — used as click targets for
    the Plotly chart (Streamlit's chart-click selection fires on marker
    points, so we need many closely-spaced markers along the line for
    clicking to feel continuous).

    Returns:
        DataFrame [longitude, latitude, distance_from_mouth_km, elevation_m]
    """
    total_km = cumulative_distance_km(centerline)[-1]
    rows = []
    for i in range(n_points):
        frac = i / (n_points - 1)
        cp = interpolate_by_fraction(centerline, frac)
        elev = elevation_at_km(gauges_snapped, cp.distance_from_mouth_km)
        rows.append({
            "longitude": cp.longitude, "latitude": cp.latitude,
            "distance_from_mouth_km": cp.distance_from_mouth_km, "elevation_m": elev,
        })
    return pd.DataFrame(rows)


def elevation_at_km(gauges_snapped: pd.DataFrame, km: float) -> Optional[float]:
    """
    Piecewise-linear elevation estimate at a given along-centerline
    distance, interpolated between the two nearest snapped gauges (by
    centerline_km). Clamps to the nearest gauge's elevation outside the
    gauged range rather than extrapolating.

    Args:
        gauges_snapped: output of `snap_gauges_to_centerline`, must be
            sorted by centerline_km (it is, by default).
        km: along-centerline distance from the mouth.

    Returns:
        Interpolated elevation in meters, or None if no gauges are available.
    """
    g = gauges_snapped.dropna(subset=["elevation_m"]).sort_values("centerline_km")
    if g.empty:
        return None
    if km <= g["centerline_km"].iloc[0]:
        return float(g["elevation_m"].iloc[0])
    if km >= g["centerline_km"].iloc[-1]:
        return float(g["elevation_m"].iloc[-1])

    for i in range(len(g) - 1):
        a, b = g.iloc[i], g.iloc[i+1]
        if a["centerline_km"] <= km <= b["centerline_km"]:
            span = b["centerline_km"] - a["centerline_km"]
            t = 0.0 if span == 0 else (km - a["centerline_km"]) / span
            return float(a["elevation_m"] + t * (b["elevation_m"] - a["elevation_m"]))
    return None