File size: 24,347 Bytes
a1f52bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
"""
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()