Spaces:
Running on Zero
Running on Zero
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 |