Spaces:
Running
Running
Download scripts/check_coast.py from lucashudsn/wavereader: direct link, hf CLI and curl.
- Browser
- Download file 24.3 kB
-
https://huggingface.co/spaces/lucashudsn/wavereader/resolve/main/scripts/check_coast.py
- Command line
-
hf download hf://spaces/lucashudsn/wavereader/scripts/check_coast.py
-
curl -L -o check_coast.py https://huggingface.co/spaces/lucashudsn/wavereader/resolve/main/scripts/check_coast.py
24.3 kB
| """ | |
| check_coast.py — verify (and optionally fix) that every break coordinate | |
| sits on the coastline: ≈0 m elevation on the GEBCO 2020 grid. | |
| Why: the seafloor world model (``wavereader.seafloor``) samples a 1.2 km | |
| box centred on the break coordinates. When a break's point sits inland or | |
| well offshore, the depth map / 3D view is mis-anchored (all land, or all | |
| water, with the takeoff marker nowhere near the shore). ``check_coords.py`` | |
| keeps points in the right state/region but says nothing about sea level; | |
| this script closes that gap. | |
| Phase 1 AUDIT (~3 batched API calls) — fetch the GEBCO 2020 elevation of | |
| every break coordinate via OpenTopoData (same free source as | |
| ``wavereader.seafloor``) and classify: | |
| waterline |elev| <= 3 m (target state) | |
| inland elev > 3 m | |
| offshore elev < -3 m | |
| Phase 2 FIX (--fix) — for each non-waterline break, find the nearest 0 m | |
| crossing on the GEBCO grid and move the break there: | |
| 1. one 10x10 grid (1.2 km box, one API call) around the break; take | |
| the nearest cell on the opposite side of 0 (water for inland | |
| breaks, land for offshore ones); expand the box (3, 5, 9, 14, | |
| 18 km) when no opposite-side cell exists; | |
| 2. batch-sampled bisection along the break→cell segment (the anchor | |
| cell's own elevation closes the t=1.0 end), linearly interpolated | |
| to the 0 m crossing, then refined inside the bracket; | |
| 3. deterministic validation before accepting: new elevation within | |
| tolerance, move distance <= 18 km, still inside the state box. | |
| Two breaks snapping near each other is normal (adjacent spots on | |
| one beach): the second point slides shore-parallel to keep both | |
| coordinates distinct instead of being rejected. | |
| A timestamped backup is written before the first change; the file is | |
| saved after every accepted fix. OpenTopoData free tier is ~1 call/s, so a | |
| full audit+fix run takes ~5 minutes for 238 breaks. | |
| Usage: | |
| uv run python scripts/check_coast.py # audit only | |
| uv run python scripts/check_coast.py --fix # snap non-coastal breaks to the shoreline | |
| uv run python scripts/check_coast.py --fix --dedupe | |
| uv run python scripts/check_coast.py --dedupe # spread breaks sharing a shoreline point | |
| uv run python scripts/check_coast.py --fix --max-fixes 10 | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import math | |
| import sys | |
| import time | |
| from datetime import datetime | |
| from pathlib import Path | |
| import httpx | |
| # Bootstrap: repo root + scripts/ on sys.path so `wavereader.*` and the | |
| # sibling check_coords helpers resolve when run directly. | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| from check_coords import _haversine_km, check_point # noqa: E402 | |
| from wavereader.breaks import DATA_PATH as ENRICHED_FILE # noqa: E402 | |
| OPENTOPO_URL = "https://api.opentopodata.org/v1/gebco2020" | |
| WATERLINE_TOL_M = 3.0 # |elev| <= this counts as "on the coast" | |
| ACCEPT_TOL_M = 5.0 # largest elevation an accepted snap may carry | |
| MAX_MOVE_KM = 18.0 # never drag a break farther than the search radius | |
| GRID_N = 10 # 10x10 = 100 pts -> exactly one API call per break | |
| RADII_KM = (1.2, 3.0, 5.0, 9.0, 14.0, 18.0) | |
| KM_PER_DEG_LAT = 111.0 | |
| PACING_S = 1.05 # OpenTopoData free tier: 1 call/sec | |
| BISECT_TS = (0.15, 0.3, 0.45, 0.6, 0.75, 0.9) | |
| _last_call = 0.0 | |
| def _pace() -> None: | |
| global _last_call | |
| wait = PACING_S - (time.time() - _last_call) | |
| if wait > 0: | |
| time.sleep(wait) | |
| _last_call = time.time() | |
| def fetch_elevations(points: list[tuple[float, float]]) -> list[float | None]: | |
| """Elevation (m, GEBCO 2020) for each (lat, lng); None on per-point miss. | |
| Batches 100 locations per call (OpenTopoData's max), paced to 1 call/s, | |
| with short retries on transient failures. | |
| """ | |
| out: list[float | None] = [] | |
| with httpx.Client(timeout=60.0, follow_redirects=True) as client: | |
| for i in range(0, len(points), 100): | |
| chunk = points[i : i + 100] | |
| locs = [f"{la:.5f},{lo:.5f}" for la, lo in chunk] | |
| done = False | |
| for attempt in (1, 2, 3): | |
| _pace() | |
| try: | |
| resp = client.get(OPENTOPO_URL, params={"locations": "|".join(locs)}) | |
| resp.raise_for_status() | |
| body = resp.json() | |
| if body.get("status") != "OK": | |
| raise ValueError(f"OpenTopoData status {body.get('status')}") | |
| out.extend(r.get("elevation") for r in body.get("results", [])) | |
| done = True | |
| break | |
| except (httpx.HTTPError, ValueError) as e: # noqa: BLE001 | |
| if attempt == 3: | |
| print(f" ! elevation fetch failed for {len(chunk)} pts: {e}") | |
| else: | |
| time.sleep(2 * attempt) | |
| if not done: | |
| out.extend([None] * len(chunk)) | |
| return out | |
| def _coords(break_: dict) -> tuple[float, float] | None: | |
| try: | |
| lat = float(break_["location"]["coordinates"]["lat"]) | |
| lng = float(break_["location"]["coordinates"]["lng"]) | |
| if math.isfinite(lat) and math.isfinite(lng): | |
| return lat, lng | |
| except (KeyError, TypeError, ValueError): | |
| pass | |
| return None | |
| # ---------------------------------------------------------------- audit | |
| def audit(breaks: list[dict]) -> list[dict]: | |
| """Classify every break by the GEBCO elevation at its coordinates.""" | |
| pts = [_coords(b) for b in breaks] | |
| elevs = fetch_elevations([p for p in pts if p is not None]) | |
| rows, i = [], 0 | |
| for b, p in zip(breaks, pts): | |
| if p is None: | |
| rows.append({"break": b, "coords": None, "elev_m": None, "cls": "invalid"}) | |
| continue | |
| e = elevs[i] | |
| i += 1 | |
| if e is None: | |
| cls = "unknown" | |
| elif abs(e) <= WATERLINE_TOL_M: | |
| cls = "waterline" | |
| else: | |
| cls = "inland" if e > 0 else "offshore" | |
| rows.append({"break": b, "coords": p, "elev_m": e, "cls": cls}) | |
| return rows | |
| def print_audit(rows: list[dict], header: str) -> None: | |
| counts: dict[str, int] = {} | |
| for r in rows: | |
| counts[r["cls"]] = counts.get(r["cls"], 0) + 1 | |
| print(f"\n{header}: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))) | |
| # ---------------------------------------------------------------- coastline search | |
| def _offsets(lat: float, radius_km: float, n: int) -> tuple[list[float], list[float]]: | |
| """Lat/lng offsets for an n x n grid spanning ±radius_km (same geometry | |
| as ``wavereader.seafloor._deg_offsets``).""" | |
| dlat = radius_km / KM_PER_DEG_LAT | |
| dlon = radius_km / max(20.0, KM_PER_DEG_LAT * math.cos(math.radians(lat))) | |
| return ( | |
| [((i / (n - 1)) * 2 - 1) * dlat for i in range(n)], | |
| [((j / (n - 1)) * 2 - 1) * dlon for j in range(n)], | |
| ) | |
| def find_opposite_cell(lat: float, lng: float, inland: bool) -> tuple[float, float, float | None] | None: | |
| """Nearest cell on the far side of 0 m, searching ever-wider boxes. | |
| Inland breaks look for the nearest water cell (elev < 0); offshore | |
| breaks for the nearest land cell (elev >= 0). One API call per radius. | |
| Returns ``(lat, lng, elev)`` — the elevation lets the bisection treat | |
| the anchor as a known t=1.0 sample. | |
| """ | |
| for radius in RADII_KM: | |
| lats_o, lngs_o = _offsets(lat, radius, GRID_N) | |
| lats = [round(lat + d, 5) for d in lats_o] | |
| lngs = [round(lng + d, 5) for d in lngs_o] | |
| pts = [(la, lo) for la in lats for lo in lngs] | |
| elevs = fetch_elevations(pts) | |
| best, best_d, best_e = None, math.inf, None | |
| for (la, lo), e in zip(pts, elevs): | |
| if e is None: | |
| continue | |
| if (e < 0) if inland else (e >= 0): | |
| d = _haversine_km((lat, lng), (la, lo)) | |
| if d < best_d: | |
| best, best_d, best_e = (la, lo), d, e | |
| if best is not None: | |
| return (*best, best_e) | |
| return None | |
| def _lerp(a: tuple[float, float], b: tuple[float, float], t: float) -> tuple[float, float]: | |
| return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) | |
| def _crossing_t(t0: float, e0: float, t1: float, e1: float) -> float: | |
| """t of the 0 m crossing on the segment (t0, e0)–(t1, e1).""" | |
| return t0 + (e0 - 0.0) / (e0 - e1) * (t1 - t0) | |
| DUP_CLEAR_DEG = 0.006 # clears check_coords' 0.005-degree duplicate box | |
| def _clears_dups(p: tuple[float, float], others: list[tuple[float, float]]) -> bool: | |
| return all(abs(p[0] - o[0]) >= DUP_CLEAR_DEG or abs(p[1] - o[1]) >= DUP_CLEAR_DEG | |
| for o in others) | |
| def walk_along_shore(row: dict, new: tuple[float, float], | |
| others: list[tuple[float, float]]) -> tuple[float, float] | None: | |
| """Slide a snapped point shore-parallel until it clears the duplicate | |
| radius of every other break. | |
| Adjacent spots on one beach (Snapper Rocks / Greenmount / Duranbah) | |
| legitimately snap near each other; instead of rejecting the snap, the | |
| point moves along the shore — the perpendicular of the break→anchor | |
| segment — preferring the offset closest to the break's previous | |
| position. Returns a point, or None when every offset still collides. | |
| """ | |
| ax, ay = row["anchor"] | |
| ux, uy = ax - row["coords"][0], ay - row["coords"][1] | |
| n = math.hypot(ux, uy) or 1.0 | |
| px, py = uy / n, -ux / n | |
| cands = [ | |
| (new[0] + sign * k * 0.002 * px, new[1] + sign * k * 0.002 * py) | |
| for k in (1, 2, 3, 4, 5) for sign in (1, -1) | |
| ] | |
| cands.sort(key=lambda c: _haversine_km(c, row["coords"])) | |
| for cand in cands: | |
| if _clears_dups(cand, others): | |
| return cand | |
| return None | |
| def snap_rows(rows: list[dict]) -> None: | |
| """Compute the 0 m crossing for every non-waterline row in place. | |
| Fills ``row["new_coords"]`` (and ``row["new_elev"]`` where verified), | |
| or sets ``row["error"]`` and leaves the coords alone. | |
| """ | |
| todo = [r for r in rows if r["cls"] in ("inland", "offshore") and r["coords"]] | |
| # 1. opposite-side anchor cell per break (one grid call each). | |
| print(f"\nFinding the nearest coastline cell for {len(todo)} break(s)…") | |
| unresolved: list[str] = [] | |
| for i, row in enumerate(todo): | |
| lat, lng = row["coords"] | |
| opp = find_opposite_cell(lat, lng, row["cls"] == "inland") | |
| if opp is None: | |
| row["error"] = "no opposite-side cell within the search radii" | |
| unresolved.append(row["break"].get("name", "?")) | |
| else: | |
| row["anchor"], row["anchor_elev"] = (opp[0], opp[1]), opp[2] | |
| if (i + 1) % 25 == 0: | |
| print(f" [{i + 1}/{len(todo)}] coastline cells located") | |
| if unresolved: | |
| print(f" ! no coastline found near: {', '.join(unresolved)}") | |
| todo = [r for r in todo if "error" not in r] | |
| # 2. batch-sampled bisection: sample all segments together, then find | |
| # each one's 0 m crossing between the sign-flipping samples. | |
| def sample(ts_for: dict[int, list[float]]) -> None: | |
| pts: list[tuple[float, float]] = [] | |
| idx: list[tuple[int, float]] = [] | |
| for i, ts in ts_for.items(): | |
| row = todo[i] | |
| for t in ts: | |
| pts.append(_lerp(row["coords"], row["anchor"], t)) | |
| idx.append((i, t)) | |
| elevs = fetch_elevations(pts) | |
| for (i, t), e in zip(idx, elevs): | |
| todo[i].setdefault("samples", {})[t] = e | |
| sample({i: list(BISECT_TS) for i in range(len(todo))}) | |
| for row in todo: | |
| s = row["samples"] | |
| # The path spans the whole segment: t=0 is the break's audited | |
| # elevation, t=1 the anchor cell's known elevation, 0.15..0.9 the | |
| # sampled points in between. | |
| path = sorted({0.0, 1.0, *s}) | |
| elevs = [ | |
| row["elev_m"] if t == 0.0 else (row.get("anchor_elev") if t == 1.0 else s.get(t)) | |
| for t in path | |
| ] | |
| row["bracket"] = None | |
| for (t0, t1), (e0, e1) in zip(zip(path, path[1:]), zip(elevs, elevs[1:])): | |
| if e0 is None or e1 is None: | |
| continue | |
| # Same predicate find_opposite_cell used to pick the anchor — | |
| # shoreline cells quantized to exactly 0 m count as land. | |
| flip = (e0 >= 0 > e1) if row["cls"] == "inland" else (e0 < 0 <= e1) | |
| if flip: | |
| row["bracket"] = (t0, e0, t1, e1) | |
| break | |
| # 3. refine inside each bracket, then interpolate. | |
| brack = {i: r["bracket"] for i, r in enumerate(todo) if r["bracket"]} | |
| sample({i: [b[0] + f * (b[2] - b[0]) for f in (0.25, 0.5, 0.75)] | |
| for i, b in brack.items()}) | |
| for row in todo: | |
| if row["bracket"] is None: | |
| row["error"] = "no 0 m crossing along the search segment" | |
| continue | |
| b = row["bracket"] | |
| # Elevation lookup across the whole segment: the endpoints (t=0 the | |
| # break itself, t=1 the anchor cell) were never sampled into | |
| # row["samples"], so they are folded in explicitly — a bracket that | |
| # ends at the anchor must stay resolvable here. | |
| elev_of = {0.0: row["elev_m"], 1.0: row.get("anchor_elev"), **row["samples"]} | |
| path = sorted(t for t in {b[0], b[2], *elev_of} if b[0] <= t <= b[2]) | |
| pts_in = [(t, elev_of.get(t)) for t in path] | |
| cross = None | |
| for (t0, e0), (t1, e1) in zip(pts_in, pts_in[1:]): | |
| if e0 is None or e1 is None: | |
| continue | |
| flip = (e0 >= 0 > e1) if row["cls"] == "inland" else (e0 < 0 <= e1) | |
| if not flip: | |
| continue | |
| cross = _crossing_t(t0, e0, t1, e1) | |
| break | |
| if cross is None: | |
| row["error"] = "no 0 m crossing along the search segment" | |
| continue | |
| row["new_coords"] = _lerp(row["coords"], row["anchor"], cross) | |
| # Elevation of each snapped point gets verified by the caller. | |
| missed = [r["break"].get("name", "?") for r in todo if "error" in r] | |
| if missed: | |
| print(f" ! no 0 m crossing found for {len(missed)}: {', '.join(missed)}") | |
| # ---------------------------------------------------------------- fix | |
| def fix(rows: list[dict], breaks: list[dict], max_fixes: int) -> None: | |
| todo = [r for r in rows if r.get("new_coords")] | |
| todo = todo[:max_fixes] if max_fixes < len(todo) else todo | |
| if not todo: | |
| print("\nNothing to fix (or nothing snappable).") | |
| return | |
| backup = ENRICHED_FILE.with_name( | |
| f"{ENRICHED_FILE.stem}-backup-{datetime.now():%Y%m%d-%H%M%S}{ENRICHED_FILE.suffix}" | |
| ) | |
| backup_written = False | |
| fixed, kept = [], [] | |
| # Verify the elevation of every snapped point in one batched pass. | |
| elevs = fetch_elevations([r["new_coords"] for r in todo]) | |
| for row, e in zip(todo, elevs): | |
| row["new_elev"] = e | |
| for row in todo: | |
| b = row["break"] | |
| label = f"{b.get('name')} ({b.get('state')} / {b.get('region')})" | |
| old, new = row["coords"], row["new_coords"] | |
| e_new = row.get("new_elev") | |
| # The interpolated crossing can still verify a few metres off on | |
| # coarse GEBCO cells; if a sampled point inside the bracket measured | |
| # closer to 0 m, prefer it — its elevation is already known. | |
| if e_new is not None and abs(e_new) > WATERLINE_TOL_M and row.get("bracket"): | |
| b0, _, b2, _ = row["bracket"] | |
| in_bracket = [ | |
| (t, e) for t, e in {**row["samples"], 1.0: row.get("anchor_elev")}.items() | |
| if e is not None and b0 <= t <= b2 | |
| ] | |
| if in_bracket: | |
| t_best, e_best = min(in_bracket, key=lambda te: abs(te[1])) | |
| if abs(e_best) < abs(e_new): | |
| new = _lerp(row["coords"], row["anchor"], t_best) | |
| e_new = e_best | |
| others = [p for p in (_coords(o) for o in breaks if o is not b) if p is not None] | |
| reasons = check_point(new[0], new[1], b.get("state", ""), others) | |
| # A "duplicate" here means two breaks snapped within ~550 m of each | |
| # other — normal for adjacent spots on one beach. Slide the point | |
| # along the shoreline so both keep distinct coordinates; if the | |
| # slid point fails the elevation check, sharing a beach with a | |
| # neighbour still beats being off the coast. | |
| if "duplicate" in reasons: | |
| slid = walk_along_shore(row, new, others) | |
| if slid is not None: | |
| e_slid = fetch_elevations([slid])[0] | |
| if e_slid is not None and abs(e_slid) <= ACCEPT_TOL_M: | |
| new, e_new = slid, e_slid | |
| reasons = [r for r in reasons if r != "duplicate"] | |
| move_km = _haversine_km(old, new) | |
| if e_new is None: | |
| reasons.append("elevation unverified") | |
| elif abs(e_new) > ACCEPT_TOL_M: | |
| reasons.append(f"new point still {e_new:+.1f} m") | |
| if move_km > MAX_MOVE_KM: | |
| reasons.append(f"move {move_km:.1f} km > {MAX_MOVE_KM} km cap") | |
| if reasons: | |
| kept.append(label) | |
| print(f" - kept {label}: {'; '.join(reasons)}") | |
| continue | |
| if not backup_written: | |
| ENRICHED_FILE.replace(backup) | |
| backup_written = True | |
| print(f"Backup written to {backup}") | |
| b["location"]["coordinates"]["lat"] = round(new[0], 6) | |
| b["location"]["coordinates"]["lng"] = round(new[1], 6) | |
| fixed.append(label) | |
| note = f"{row['elev_m']:+.0f} m -> {e_new:+.1f} m" if e_new is not None else "elevation unverified" | |
| print(f" + {label}: {move_km * 1000:.0f} m ashore ({note})") | |
| ENRICHED_FILE.write_text(json.dumps(breaks, indent=2)) | |
| print(f"\nSnapped {len(fixed)} break(s) to the coastline, kept {len(kept)} on old coords.") | |
| # ---------------------------------------------------------------- dedupe | |
| def _shore_axis_probe_points(lat: float, lng: float) -> list[tuple[float, float]]: | |
| """N/S/E/W probes ~280 m out, used to find the local shore direction.""" | |
| d = 0.0025 | |
| dl = d / max(0.2, math.cos(math.radians(lat))) | |
| return [(lat + d, lng), (lat - d, lng), (lat, lng + dl), (lat, lng - dl)] | |
| def _shore_axis_from_probes( | |
| lat: float, lng: float, entries: list[tuple[tuple[float, float], float | None]] | |
| ) -> tuple[float, float]: | |
| """Shore-parallel unit step (dlat, dlng): the water-most probe marks the | |
| seaward side, so the shore runs along the perpendicular axis.""" | |
| known = [(e, p) for p, e in entries if e is not None] | |
| if not known: | |
| return (1.0, 0.0) | |
| _, (wlat, wlng) = min(known) | |
| north_south = abs(wlat - lat) > abs(wlng - lng) | |
| return (0.0, 1.0) if north_south else (1.0, 0.0) | |
| def dedupe(breaks: list[dict]) -> None: | |
| """Spread breaks that snapped onto (nearly) the same shoreline point. | |
| Groups breaks within check_coords' duplicate box, then assigns each | |
| member an offset along the group's shore-parallel axis — the middle of | |
| the group stays put — so adjacent spots keep distinct coordinates | |
| while every point stays on the waterline. Each candidate is verified | |
| at |elev| <= WATERLINE_TOL; a failing candidate keeps its old point. | |
| """ | |
| def coords(b: dict) -> tuple[float, float]: | |
| c = b["location"]["coordinates"] | |
| return (float(c["lat"]), float(c["lng"])) | |
| parent = list(range(len(breaks))) | |
| def find(i: int) -> int: | |
| while parent[i] != i: | |
| parent[i] = parent[parent[i]] | |
| i = parent[i] | |
| return i | |
| pts = [coords(b) for b in breaks] | |
| for i in range(len(breaks)): | |
| for j in range(i + 1, len(breaks)): | |
| if abs(pts[i][0] - pts[j][0]) < 0.005 and abs(pts[i][1] - pts[j][1]) < 0.005: | |
| parent[find(i)] = find(j) | |
| groups: dict[int, list[int]] = {} | |
| for i in range(len(breaks)): | |
| groups.setdefault(find(i), []).append(i) | |
| groups = [m for m in groups.values() if len(m) > 1] | |
| if not groups: | |
| print("No colliding breaks — every point is distinct.") | |
| return | |
| for members in groups: | |
| print(f" group: {', '.join(breaks[i]['name'] for i in members)}") | |
| # One shore axis per group, probed at the group's first point. | |
| probe_pts, probe_owner = [], [] | |
| for gi, members in enumerate(groups): | |
| for p in _shore_axis_probe_points(*pts[members[0]]): | |
| probe_pts.append(p) | |
| probe_owner.append(gi) | |
| probe_elevs = fetch_elevations(probe_pts) | |
| by_group: dict[int, list[tuple[tuple[float, float], float | None]]] = {} | |
| for gi, p, e in zip(probe_owner, probe_pts, probe_elevs): | |
| by_group.setdefault(gi, []).append((p, e)) | |
| axes = {gi: _shore_axis_from_probes(*pts[groups[gi][0]], entries) for gi, entries in by_group.items()} | |
| STEP = 0.004 # ~440 m between adjacent members of a group | |
| backup = ENRICHED_FILE.with_name( | |
| f"{ENRICHED_FILE.stem}-backup-{datetime.now():%Y%m%d-%H%M%S}{ENRICHED_FILE.suffix}" | |
| ) | |
| backup_written = False | |
| moved = 0 | |
| cands = [] | |
| for gi, members in enumerate(groups): | |
| base = pts[members[0]] | |
| s = axes[gi] | |
| for pos, bi in enumerate(members): | |
| k = (pos - (len(members) - 1) / 2) * STEP | |
| cands.append((bi, base[0] + k * s[0], base[1] + k * s[1])) | |
| elevs = fetch_elevations([(la, lo) for _, la, lo in cands]) | |
| for (bi, la, lo), e in zip(cands, elevs): | |
| b = breaks[bi] | |
| old = pts[bi] | |
| if (la, lo) == old or e is None or abs(e) > WATERLINE_TOL_M: | |
| continue | |
| if not backup_written: | |
| ENRICHED_FILE.replace(backup) | |
| backup_written = True | |
| print(f"Backup written to {backup}") | |
| b["location"]["coordinates"]["lat"] = round(la, 6) | |
| b["location"]["coordinates"]["lng"] = round(lo, 6) | |
| moved += 1 | |
| print(f" ~ {b['name']}: spread {_haversine_km(old, (la, lo)) * 1000:.0f} m along the shore (elev {e:+.1f} m)") | |
| print(f"Spread {moved} break(s) across {len(groups)} colliding group(s).") | |
| def _shore_axis_probe_points(lat: float, lng: float) -> list[tuple[float, float]]: | |
| d = 0.0025 | |
| dl = d / max(0.2, math.cos(math.radians(lat))) | |
| return [(lat + d, lng), (lat - d, lng), (lat, lng + dl), (lat, lng - dl)] | |
| def _shore_axis_from_probes(lat, lng, entries) -> tuple[float, float]: | |
| known = [(e, p) for p, e in entries if e is not None] | |
| if not known: | |
| return (1.0, 0.0) | |
| _, (wlat, wlng) = min(known) | |
| north_south = abs(wlat - lat) > abs(wlng - lng) | |
| return (0.0, 1.0) if north_south else (1.0, 0.0) | |
| # ---------------------------------------------------------------- main | |
| def main() -> None: | |
| args = sys.argv[1:] | |
| do_fix = "--fix" in args | |
| do_dedupe = "--dedupe" in args | |
| max_fixes = int(args[args.index("--max-fixes") + 1]) if "--max-fixes" in args else 10**9 | |
| breaks = json.loads(ENRICHED_FILE.read_text(encoding="utf-8")) | |
| if not isinstance(breaks, list): | |
| raise SystemExit(f"{ENRICHED_FILE} is not a JSON list") | |
| print(f"Loaded {len(breaks)} breaks from {ENRICHED_FILE}") | |
| if do_dedupe and not do_fix: | |
| dedupe(breaks) | |
| print_audit(audit(breaks), "Final audit") | |
| return | |
| rows = audit(breaks) | |
| print_audit(rows, "Audit (GEBCO 2020 elevation at each break coordinate)") | |
| if not do_fix: | |
| inland = [r for r in rows if r["cls"] == "inland"] | |
| offshore = [r for r in rows if r["cls"] == "offshore"] | |
| for tag, group in (("inland", inland), ("offshore", offshore)): | |
| for r in sorted(group, key=lambda r: -(r["elev_m"] or 0))[:10]: | |
| print(f" [{tag:8s}] {r['elev_m']:+7.1f} m {r['break'].get('name')}") | |
| print("\nRun with --fix to snap non-waterline breaks to the nearest 0 m crossing.") | |
| return | |
| snap_rows(rows) | |
| fix(rows, breaks, max_fixes) | |
| if do_dedupe: | |
| dedupe(breaks) | |
| final = audit(breaks) | |
| print_audit(final, "Final audit") | |
| for r in final: | |
| if r["cls"] != "waterline": | |
| b = r["break"] | |
| print(f" ! still off-coast: {b.get('name')} ({b.get('state')}) at {r['elev_m']:+.1f} m") | |
| if __name__ == "__main__": | |
| main() | |