-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgpx_simplify.py
More file actions
2512 lines (2169 loc) · 101 KB
/
Copy pathgpx_simplify.py
File metadata and controls
2512 lines (2169 loc) · 101 KB
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
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
gpx_simplify.py — Simplify large GPX files for sailing track archives.
Merges all tracks/segments from multiple sources into a single chronologically
sorted track, filters speed anomalies, elevation spikes, and geometric
cross-track outliers, then decimates to a target point spacing.
Usage:
python gpx_simplify.py -i voyage.gpx -o simplified.gpx
python gpx_simplify.py -i voyage.gpx -d 200 -s 40 -vv
python gpx_simplify.py -i voyage.gpx --passes 5 --dry-run -vvv
python gpx_simplify.py -i voyage.gpx --max-ele-change 100
Requirements:
pip install gpxpy rich
"""
import argparse
import math
import sys
import urllib.request
import urllib.parse
import json
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
# ── dependency check ──────────────────────────────────────────────────────────
try:
import gpxpy
import gpxpy.gpx
except ImportError:
print("ERROR: gpxpy is not installed. Run: pip install gpxpy", file=sys.stderr)
sys.exit(1)
try:
from rich.console import Console
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
SpinnerColumn,
TaskProgressColumn,
TextColumn,
TimeElapsedColumn,
TimeRemainingColumn,
)
from rich.table import Table
from rich import print as rprint
from rich.panel import Panel
from rich.text import Text
from rich.theme import Theme
except ImportError:
print("ERROR: rich is not installed. Run: pip install rich", file=sys.stderr)
sys.exit(1)
# ── theme / console ───────────────────────────────────────────────────────────
THEME = Theme(
{
"info": "cyan",
"debug": "bright_black",
"trace": "blue",
"warn": "yellow bold",
"error": "red bold",
"good": "green bold",
"stat": "magenta",
"heading": "bold white",
}
)
console = Console(theme=THEME, highlight=False)
VERBOSITY_QUIET = 0 # only errors + final summary
VERBOSITY_INFO = 1 # -v : phase headers + key numbers
VERBOSITY_DEBUG = 2 # -vv : per-segment details, filter decisions
VERBOSITY_TRACE = 3 # -vvv : every point examined
def log(level: int, verbosity: int, style: str, msg: str) -> None:
if verbosity >= level:
console.print(f"[{style}]{msg}[/{style}]")
# ── data structures ───────────────────────────────────────────────────────────
@dataclass
class Point:
lat: float
lon: float
ele: Optional[float]
time: Optional[datetime]
source: str = "" # track/segment label for debug output
# filled in during speed-filter pass
speed_to_next: float = 0.0 # knots
# ── geometry helpers ──────────────────────────────────────────────────────────
EARTH_RADIUS_M = 6_371_000.0
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Return great-circle distance in metres between two lat/lon points."""
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(a))
def cross_track_distance_m(
lat_a: float, lon_a: float, # start of baseline segment
lat_b: float, lon_b: float, # end of baseline segment
lat_p: float, lon_p: float, # candidate point
) -> float:
"""
Return the cross-track (perpendicular) distance in metres from point P to
the great-circle path A→B.
Uses the spherical cross-track formula:
d_xt = asin(sin(d_AP/R) * sin(θ_AP − θ_AB)) * R
where d_AP is the angular distance A→P and θ are bearings.
Returns the absolute cross-track deviation (always ≥ 0).
"""
lat_a_r = math.radians(lat_a)
lon_a_r = math.radians(lon_a)
lat_b_r = math.radians(lat_b)
lon_b_r = math.radians(lon_b)
lat_p_r = math.radians(lat_p)
lon_p_r = math.radians(lon_p)
# Angular distance A→P
d_ap_r = haversine_m(lat_a, lon_a, lat_p, lon_p) / EARTH_RADIUS_M
# Bearing A→P
def bearing(la, lo, lb, lb2):
dlo = lb2 - lo
x = math.cos(lb) * math.sin(dlo)
y = math.cos(la) * math.sin(lb) - math.sin(la) * math.cos(lb) * math.cos(dlo)
return math.atan2(x, y)
theta_ap = bearing(lat_a_r, lon_a_r, lat_p_r, lon_p_r)
theta_ab = bearing(lat_a_r, lon_a_r, lat_b_r, lon_b_r)
# If A and B are the same point, cross-track = distance A→P
if haversine_m(lat_a, lon_a, lat_b, lon_b) < 1.0:
return haversine_m(lat_a, lon_a, lat_p, lon_p)
xt = math.asin(math.sin(d_ap_r) * math.sin(theta_ap - theta_ab)) * EARTH_RADIUS_M
return abs(xt)
def destination_point(
lat: float, lon: float, bearing_rad: float, distance_m: float
) -> tuple[float, float]:
"""
Return the lat/lon of a point reached by travelling `distance_m` metres
from (lat, lon) along `bearing_rad` (radians, clockwise from north) on a
spherical Earth. Uses the spherical law of cosines (destination formula).
"""
d = distance_m / EARTH_RADIUS_M # angular distance (radians)
lat1 = math.radians(lat)
lon1 = math.radians(lon)
lat2 = math.asin(
math.sin(lat1) * math.cos(d)
+ math.cos(lat1) * math.sin(d) * math.cos(bearing_rad)
)
lon2 = lon1 + math.atan2(
math.sin(bearing_rad) * math.sin(d) * math.cos(lat1),
math.cos(d) - math.sin(lat1) * math.sin(lat2),
)
return math.degrees(lat2), math.degrees(lon2)
def initial_bearing_rad(
lat1: float, lon1: float, lat2: float, lon2: float
) -> float:
"""Return the initial great-circle bearing (radians) from point 1 to point 2."""
la1, lo1, la2, lo2 = (
math.radians(lat1), math.radians(lon1),
math.radians(lat2), math.radians(lon2),
)
dlo = lo2 - lo1
x = math.sin(dlo) * math.cos(la2)
y = math.cos(la1) * math.sin(la2) - math.sin(la1) * math.cos(la2) * math.cos(dlo)
return math.atan2(x, y)
def speed_knots(p1: Point, p2: Point) -> Optional[float]:
"""Return speed in knots between two timed points, or None if no timestamps."""
if p1.time is None or p2.time is None:
return None
dt = abs((p2.time - p1.time).total_seconds())
if dt < 1e-6:
return None
dist_m = haversine_m(p1.lat, p1.lon, p2.lat, p2.lon)
mps = dist_m / dt
return mps * 1.94384 # m/s → knots
def time_gap_hours(p1: Point, p2: Point) -> Optional[float]:
"""Return absolute time difference in hours between two points."""
if p1.time is None or p2.time is None:
return None
return abs((p2.time - p1.time).total_seconds()) / 3600.0
# ── stats accumulator ────────────────────────────────────────────────────────
@dataclass
class Stats:
tracks_in: int = 0
segments_in: int = 0
points_in: int = 0
points_no_time: int = 0
points_speed_drop: int = 0
points_lonjump_drop: int = 0
points_ele_drop: int = 0
points_crosstrack_drop: int = 0
points_merge_drop: int = 0
points_duptime_drop: int = 0
points_duppos_drop: int = 0
points_zerospd_drop: int = 0
points_bridge_fill: int = 0
points_gap_fill: int = 0
gaps_found: int = 0
gaps_filled: int = 0
segments_merged: int = 0
points_out: int = 0
segments_out: int = 0
waypoints_in: int = 0
waypoints_out: int = 0
total_dist_km: float = 0.0
crosstrack_passes: int = 0
bbox: list = field(default_factory=lambda: [90.0, 180.0, -90.0, -180.0])
# bbox = [min_lat, min_lon, max_lat, max_lon]
def update_bbox(self, lat: float, lon: float) -> None:
self.bbox[0] = min(self.bbox[0], lat)
self.bbox[1] = min(self.bbox[1], lon)
self.bbox[2] = max(self.bbox[2], lat)
self.bbox[3] = max(self.bbox[3], lon)
# ── phase 1: parse ────────────────────────────────────────────────────────────
def parse_gpx(path: Path, verbosity: int, stats: Stats) -> tuple[list[Point], list]:
"""
Parse a GPX file, returning (all_track_points, waypoints).
Track points are *not* yet sorted — that happens after collection.
"""
log(VERBOSITY_INFO, verbosity, "info", f"📂 Parsing {path} …")
file_size = path.stat().st_size
log(VERBOSITY_INFO, verbosity, "debug", f" File size: {file_size / 1_048_576:.1f} MB")
all_points: list[Point] = []
waypoints = []
with Progress(
SpinnerColumn(),
TextColumn("[info]Parsing GPX …"),
BarColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
console=console,
transient=True,
) as progress:
with open(path, "rb") as fh:
gpx = gpxpy.parse(fh)
stats.tracks_in = len(gpx.tracks)
stats.waypoints_in = len(gpx.waypoints)
waypoints = list(gpx.waypoints)
total_segs = sum(len(t.segments) for t in gpx.tracks)
task = progress.add_task("segments", total=total_segs)
for ti, track in enumerate(gpx.tracks):
track_name = track.name or f"track_{ti}"
for si, seg in enumerate(track.segments):
stats.segments_in += 1
seg_label = f"{track_name}/seg{si}"
log(
VERBOSITY_DEBUG, verbosity, "debug",
f" Segment [{seg_label}]: {len(seg.points)} raw points",
)
for pt in seg.points:
stats.points_in += 1
if pt.time is None:
stats.points_no_time += 1
log(
VERBOSITY_DEBUG, verbosity, "debug",
f" DROP (no timestamp) [{seg_label}] "
f"{pt.latitude:.5f},{pt.longitude:.5f}",
)
# Points without timestamps cannot be speed-checked,
# cross-track-checked, or placed correctly in the
# chronological timeline. Drop them at parse time
# rather than silently appending them at the end of
# the sorted list where they would produce a spurious
# 'segment' of untimed fixes potentially far from the
# rest of the track.
continue
# Normalise to UTC-aware datetime
t = pt.time
if t.tzinfo is None:
t = t.replace(tzinfo=timezone.utc)
p = Point(
lat=pt.latitude,
lon=pt.longitude,
ele=pt.elevation,
time=t,
source=seg_label,
)
all_points.append(p)
progress.advance(task)
log(
VERBOSITY_INFO, verbosity, "info",
f" Loaded {stats.points_in:,} points from "
f"{stats.tracks_in} track(s) / {stats.segments_in} segment(s).",
)
if stats.points_no_time:
log(
VERBOSITY_INFO, verbosity, "warn",
f" ⚠ {stats.points_no_time:,} points had no timestamp and were dropped.",
)
return all_points, waypoints
# ── phase 2: sort ─────────────────────────────────────────────────────────────
def sort_points(points: list[Point], verbosity: int) -> list[Point]:
"""Sort all points chronologically. Points without timestamps go to the end."""
log(VERBOSITY_INFO, verbosity, "info", "🔀 Sorting points chronologically …")
def sort_key(p: Point):
if p.time is None:
return datetime.max.replace(tzinfo=timezone.utc)
return p.time
points.sort(key=sort_key)
log(VERBOSITY_DEBUG, verbosity, "debug",
f" Sort complete. First point: {points[0].time} "
f"Last point: {points[-1].time if points[-1].time else '(no time)'}")
return points
# ── phase 3: speed-anomaly filter ─────────────────────────────────────────────
def filter_speed_anomalies(
points: list[Point],
max_speed_knots: float,
verbosity: int,
stats: Stats,
) -> list[Point]:
"""
Remove points that imply an impossible speed or position.
Normal case (dt > 0): a point is dropped if BOTH of the following are true:
• the speed from the previous *kept* point to this point exceeds max_speed_knots
• the speed from this point to the next point also exceeds max_speed_knots
(This avoids dropping a valid point when two consecutive GPS fixes are very
close in time but the boat just happened to be moving fast.)
The additional one-sided "impossible distance" check drops a point immediately
when no amount of look-ahead can make it geometrically reachable.
Zero-dt case (two points share the same timestamp): speed_knots() returns None
so the normal speed maths cannot be applied. Instead we use a pure spatial
check against the *raw* previous point in the sorted array (not kept[-1], which
may itself be an outlier from a different GPS source). If the raw predecessor
shares the same timestamp and is more than ~1 second's worth of travel away
(i.e. > max_speed × 1 s ≈ 26 m), the point is a ghost fix from a second GPS
recording the same instant but placing the vessel thousands of km away.
These clusters of simultaneous phantom positions cannot be caught by the
impossible-distance check because dt = 0 makes max_plausible_m = 0, which
would incorrectly flag every duplicate-position fix too. The 1-second budget
is a deliberate conservative floor: a genuinely stationary duplicate should be
within a few metres of its twin; anything beyond 26 m at the same timestamp
is not a duplicate, it is a ghost.
"""
log(VERBOSITY_INFO, verbosity, "info",
f"🚀 Filtering speed anomalies > {max_speed_knots:.0f} kn …")
if not points:
return points
# One second's worth of travel at the speed cap — used as the zero-dt distance floor.
_one_sec_max_m = max_speed_knots / 1.94384 # metres
kept: list[Point] = [points[0]]
# Track which raw indices were kept so zero-dt duplicates of dropped points
# are themselves dropped (propagate-drop rule — see zero-dt branch below).
_kept_indices: set[int] = {0}
with Progress(
SpinnerColumn(),
TextColumn("[info]Speed filter …"),
BarColumn(),
TaskProgressColumn(),
MofNCompleteColumn(),
TimeRemainingColumn(),
console=console,
transient=True,
) as progress:
task = progress.add_task("points", total=len(points) - 1)
for i in range(1, len(points)):
cur = points[i]
prev = kept[-1]
prev_raw = points[i - 1] # immediate predecessor in sorted array
nxt = points[i + 1] if i + 1 < len(points) else None
# ── zero-dt branch ────────────────────────────────────────────────
# Two points share the same timestamp. speed_knots() would return None
# (dt ≈ 0), causing the normal branch to keep the point unconditionally.
#
# We handle this in two steps:
#
# (a) Propagate-drop: if the raw predecessor was itself dropped (it was a
# ghost fix), drop this point too. A ghost fix often appears in the
# sorted list as a pair — the original ghost and then a duplicate of it
# from a second logger — both at the same impossible position. Without
# this rule the duplicate would be kept and corrupt kept[-1].
#
# (b) Spatial check: if the raw predecessor was kept, compare positions.
# If they share a timestamp but are more than one second's worth of
# travel apart (~26 m at 50 kn), this is a ghost fix from a second GPS
# recording the same instant but placing the vessel somewhere else.
if (cur.time is not None
and prev_raw.time is not None
and abs((cur.time - prev_raw.time).total_seconds()) < 1e-6):
if (i - 1) not in _kept_indices:
# (a) raw predecessor was dropped → propagate drop
stats.points_speed_drop += 1
log(
VERBOSITY_DEBUG, verbosity, "warn",
f" DROP (zero-dt propagate) [{cur.source}] "
f"{cur.lat:.5f},{cur.lon:.5f} raw_prev was dropped",
)
else:
# (b) raw predecessor was kept → spatial check
d_raw_prev = haversine_m(prev_raw.lat, prev_raw.lon, cur.lat, cur.lon)
if d_raw_prev > _one_sec_max_m:
stats.points_speed_drop += 1
log(
VERBOSITY_DEBUG, verbosity, "warn",
f" DROP (zero-dt ghost) [{cur.source}] "
f"{cur.lat:.5f},{cur.lon:.5f} "
f"d_raw_prev={d_raw_prev:.0f} m at dt=0",
)
else:
log(VERBOSITY_TRACE, verbosity, "trace",
f" [{cur.source}] {cur.lat:.5f},{cur.lon:.5f} "
f"— zero-dt duplicate, kept")
kept.append(cur)
_kept_indices.add(i)
progress.advance(task)
continue
# ── normal branch (dt > 0) ────────────────────────────────────────
s_from_prev = speed_knots(prev, cur)
s_to_next = speed_knots(cur, nxt) if nxt else None
# If we can't compute speed (missing timestamps on THIS point), keep it
if s_from_prev is None:
log(VERBOSITY_TRACE, verbosity, "trace",
f" [{cur.source}] {cur.lat:.5f},{cur.lon:.5f} — no timestamp, kept")
kept.append(cur)
_kept_indices.add(i)
progress.advance(task)
continue
over_limit_from = s_from_prev > max_speed_knots
# One-sided drop: if the distance from the last *kept* point is
# physically impossible regardless of what comes next, drop immediately.
# This catches cases where two interleaved GPS sources alternate such
# that the OUTGOING leg always looks slow (next point is back on the
# same source), allowing the bad point to sneak through the two-sided
# check. A sailboat genuinely cannot be more than
# max_speed × elapsed_time away from the previous fix.
if prev.time and cur.time:
dt_s = abs((cur.time - prev.time).total_seconds())
max_plausible_m = (max_speed_knots / 1.94384) * dt_s
actual_m = haversine_m(prev.lat, prev.lon, cur.lat, cur.lon)
impossible = actual_m > max_plausible_m
else:
impossible = False
# Dropped-predecessor impossibility check.
#
# The standard impossible-distance check uses kept[-1] for BOTH position
# and time. This fails for ghost clusters where kept[-1] is months or
# years old: max_plausible_m becomes enormous and a 3000 km ghost looks
# reachable.
#
# The ghost cluster scenario:
# - prev_raw (points[i-1]) was itself a ghost and was DROPPED
# - prev_raw.time is very close to cur.time (seconds apart)
# - kept[-1].time is months/years earlier
#
# Fix: when prev_raw was dropped, use prev_raw.time as the elapsed-time
# reference (tight, seconds) while keeping kept[-1]'s position as the
# "where was the boat?" anchor. A real boat cannot travel from kept[-1]
# to cur in the few seconds between prev_raw and cur.
#
# Why this is safe for legitimate dropped points:
# - A normal noise-spike dropped in the normal branch has a prev_raw
# that is only slightly displaced from the real track; cur is also on
# the real track. The distance from kept[-1] to cur is small (the
# boat barely moved in those seconds) → check does not fire.
# - A dropout resumption where a zero-dt duplicate was dropped is
# handled by the zero-dt branch above, never reaching this code.
# After that, kept[-1] is the just-resumed point (fresh), so the
# standard check handles subsequent normal points correctly.
# - A dropout resumption where the dropped predecessor had dt > 0:
# prev_raw.time is close to cur.time AND kept[-1].time is also close
# (the dropout is a gap in data, not a gap in kept[]). Wait — if
# kept[-1] is old (e.g. 2h ago) and prev_raw.time ≈ cur.time, then
# dist(kept[-1]→cur) could be ~72 km while max_plausible ≈ 50 m.
# To guard this: only apply when dist(kept[-1]→cur) also exceeds
# the STANDARD max_plausible (i.e. the normal check would have
# caught it if not for the stale time). In other words, require
# that the normal check failed only because dt_s was too large, not
# because the distance is genuinely OK.
# Concretely: the point is suspicious only when it is impossible
# relative to prev_raw.time AND also impossible relative to kept[-1]
# if we tighten the time to prev_raw.time. For a legit 2h-dropout
# point, dt_s (kept[-1] to prev_raw) is 2h, max_plausible is 74 km,
# actual is 72 km → the STANDARD check barely passes. Adding the
# tighter constraint would wrongly drop it. Guard: only fire when
# the standard dt_s (kept[-1] to cur) itself already gives a very
# large budget (> _STALE_BUDGET_M) — meaning kept[-1] is truly stale.
_STALE_BUDGET_M = 500_000 # 500 km — only act when budget is enormous
if (not impossible
and (i - 1) not in _kept_indices
and prev_raw.time is not None
and cur.time is not None
and prev.time is not None):
dt_s_normal = abs((cur.time - prev.time).total_seconds())
normal_budget_m = (max_speed_knots / 1.94384) * dt_s_normal
if normal_budget_m > _STALE_BUDGET_M:
# kept[-1] is genuinely stale — apply tighter check
dt_raw_s = abs((cur.time - prev_raw.time).total_seconds())
if dt_raw_s > 0:
max_plausible_tight_m = (max_speed_knots / 1.94384) * dt_raw_s
actual_m_from_prev = haversine_m(
prev.lat, prev.lon, cur.lat, cur.lon
)
if actual_m_from_prev > max_plausible_tight_m:
impossible = True
log(VERBOSITY_DEBUG, verbosity, "debug",
f" impossible (dropped-pred) [{cur.source}] "
f"{cur.lat:.5f},{cur.lon:.5f} "
f"d_from_kept={actual_m_from_prev/1000:.0f}km "
f"max={max_plausible_tight_m/1000:.3f}km "
f"dt_raw={dt_raw_s:.0f}s "
f"normal_budget={normal_budget_m/1000:.0f}km")
# s_to_next is None when:
# (a) nxt has no timestamp → genuinely unknown → be conservative, keep
# (b) cur and nxt share the same timestamp (dt=0) → degenerate step,
# treat the same as being at the end of the list (drop if over limit)
nxt_has_no_time = (nxt is not None and nxt.time is None)
nxt_simultaneous = (nxt is not None and s_to_next is None and not nxt_has_no_time)
if s_to_next is not None:
over_limit_to = s_to_next > max_speed_knots
elif nxt is None or nxt_simultaneous:
# Last point, or next point is simultaneous (dt=0): outgoing leg
# is degenerate — count as over limit if incoming is already over
over_limit_to = over_limit_from
else:
# Next point has no timestamp — can't assess, be conservative
over_limit_to = False
if impossible or (over_limit_from and over_limit_to):
stats.points_speed_drop += 1
log(
VERBOSITY_DEBUG, verbosity, "warn",
f" DROP [{cur.source}] {cur.lat:.5f},{cur.lon:.5f} "
f"speed {s_from_prev:.0f} kn → "
f"{f'{s_to_next:.0f}' if s_to_next is not None else '?'} kn",
)
else:
log(VERBOSITY_TRACE, verbosity, "trace",
f" keep [{cur.source}] {cur.lat:.5f},{cur.lon:.5f} "
f"speed {s_from_prev:.1f} kn")
kept.append(cur)
_kept_indices.add(i)
progress.advance(task)
log(VERBOSITY_INFO, verbosity, "info",
f" Dropped {stats.points_speed_drop:,} anomalous points. "
f"{len(kept):,} remain.")
return kept
# ── phase 3b: longitude-jump filter ──────────────────────────────────────────
def filter_longitude_jumps(
points: list[Point],
max_lon_jump_deg: float,
verbosity: int,
stats: Stats,
) -> list[Point]:
"""
Remove points whose longitude differs from BOTH neighbours by more than
max_lon_jump_deg degrees.
This catches GPS glitches near the antimeridian (±180°) that the speed
filter misses. When a boat is crossing the date line near lon=±179°, a
bad fix can land at e.g. lon=-60° (South Atlantic). Haversine wraps the
longitude correctly and returns the geodesic distance, but if the bad
point is between two points that are themselves on opposite sides of ±180°
the geodesic distance to the nearest neighbour can appear deceptively small
(the sphere's shortest path crosses the antimeridian), fooling the speed
filter.
The longitude-jump check is purely angular and does NOT wrap at ±180: we
compare raw degree differences. A point at lon=-60 surrounded by points
at lon=+179 and lon=-179 has |Δlon| of 239° and 119° respectively — both
far above any plausible single-step longitude change.
For filtering, we require BOTH neighbours to show a large jump: this
prevents the filter from dropping valid points at the start or end of a
long eastward or westward passage.
Default threshold: 90°. A sailboat cannot legitimately move 90° of
longitude (~10,000 km at the equator) in a single GPS fix interval.
"""
log(VERBOSITY_INFO, verbosity, "info",
f"🌐 Longitude-jump filter: dropping points with |Δlon| > "
f"{max_lon_jump_deg:.0f}° from BOTH neighbours …")
if len(points) < 3:
return points
kept: list[Point] = [points[0]]
dropped = 0
for i in range(1, len(points) - 1):
prev = kept[-1]
cur = points[i]
nxt = points[i + 1]
d_prev = abs(cur.lon - prev.lon)
d_next = abs(cur.lon - nxt.lon)
if d_prev > max_lon_jump_deg and d_next > max_lon_jump_deg:
dropped += 1
stats.points_lonjump_drop += 1
log(
VERBOSITY_DEBUG, verbosity, "warn",
f" lonjump-DROP [{cur.source}] {cur.lat:.5f},{cur.lon:.5f} "
f"|Δlon_prev|={d_prev:.1f}° |Δlon_next|={d_next:.1f}°",
)
else:
kept.append(cur)
kept.append(points[-1])
log(VERBOSITY_INFO, verbosity, "info",
f" Dropped {dropped:,} longitude-jump points. "
f"{len(kept):,} remain.")
return kept
# ── phase 4: cross-track sanity filter (iterative) ───────────────────────────
def _crosstrack_pass(
points: list[Point],
max_crosstrack_m: float,
max_crosstrack_rate_m_per_h: float,
verbosity: int,
) -> tuple[list[Point], int]:
"""
Single pass: examine every interior point and drop it if it is a geometric
outlier relative to its neighbours, *unless* the time gap to its neighbours
is large enough that a big positional deviation is plausible (i.e. the track
legitimately crossed over itself on a different passage).
The test is:
cross_track_distance > max_crosstrack_m
AND
cross_track_distance / time_gap_hours > max_crosstrack_rate_m_per_h
The second condition is the self-crossing guard: if the two neighbours are
days apart, a deviation of many km is fine. The rate threshold converts the
raw distance limit into a distance-per-hour budget — at the default of
50 kn ≈ 93 km/h, a 1,000 m deviation is only suspicious if the neighbours
are less than ~10 minutes apart.
Returns (kept_points, n_dropped).
"""
if len(points) < 3:
return points, 0
kept: list[Point] = [points[0]]
dropped = 0
for i in range(1, len(points) - 1):
prev = kept[-1] # last kept point (not necessarily points[i-1])
cur = points[i]
nxt = points[i + 1]
# Cannot do geometry without two valid neighbours
xt = cross_track_distance_m(
prev.lat, prev.lon,
nxt.lat, nxt.lon,
cur.lat, cur.lon,
)
if xt <= max_crosstrack_m:
log(VERBOSITY_TRACE, verbosity, "trace",
f" xt-keep {cur.lat:.5f},{cur.lon:.5f} xt={xt:.0f} m")
kept.append(cur)
continue
# Point is geometrically far off the prev→next line.
# Check if the time span to its *nearest* neighbour is large — if so,
# the boat was simply in a different part of the ocean at a different
# time and the self-crossing guard should let it through.
#
# We use the MINIMUM of the two leg gaps (prev→cur, cur→nxt) so that
# a spike which is only 2 minutes from one neighbour is caught even
# if the other neighbour is hours away.
gap_prev_h = time_gap_hours(prev, cur)
gap_next_h = time_gap_hours(cur, nxt)
if gap_prev_h is None and gap_next_h is None:
# No timestamps — can't apply rate guard, keep the point
log(VERBOSITY_TRACE, verbosity, "trace",
f" xt-keep (no time) {cur.lat:.5f},{cur.lon:.5f} xt={xt:.0f} m")
kept.append(cur)
continue
# Use whichever gap we have; prefer the smaller one
available = [g for g in (gap_prev_h, gap_next_h) if g is not None]
gap_h = min(available) # tightest constraint wins
if gap_h < 1e-6:
# Simultaneous neighbour — deviation is definitely an error
rate = float("inf")
else:
rate = xt / gap_h # metres per hour
if rate > max_crosstrack_rate_m_per_h:
dropped += 1
log(
VERBOSITY_DEBUG, verbosity, "warn",
f" xt-DROP [{cur.source}] {cur.lat:.5f},{cur.lon:.5f} "
f"xt={xt:.0f} m min-gap={gap_h*60:.1f} min rate={rate:.0f} m/h",
)
else:
# Large deviation but spread over many hours — legitimate crossing
log(
VERBOSITY_DEBUG, verbosity, "debug",
f" xt-keep (self-crossing guard) {cur.lat:.5f},{cur.lon:.5f} "
f"xt={xt:.0f} m min-gap={gap_h*60:.1f} min rate={rate:.0f} m/h",
)
kept.append(cur)
# Always keep the last point
kept.append(points[-1])
return kept, dropped
def filter_crosstrack_anomalies(
points: list[Point],
max_crosstrack_m: float,
max_crosstrack_rate_m_per_h: float,
max_passes: int,
verbosity: int,
stats: Stats,
) -> list[Point]:
"""
Iteratively apply the cross-track filter until no more points are dropped
or max_passes is reached.
Iteration is necessary because dropping one outlier can unmask the next:
e.g. a cluster of three consecutive bad points where each looks reasonable
relative to its immediate neighbours will only be fully cleaned after
successive passes.
"""
log(
VERBOSITY_INFO, verbosity, "info",
f"📐 Cross-track sanity filter: max deviation {max_crosstrack_m:.0f} m, "
f"rate guard {max_crosstrack_rate_m_per_h:.0f} m/h, "
f"up to {max_passes} pass(es) …",
)
pass_num = 0
total_dropped = 0
while pass_num < max_passes:
pass_num += 1
points, n_dropped = _crosstrack_pass(
points, max_crosstrack_m, max_crosstrack_rate_m_per_h, verbosity
)
total_dropped += n_dropped
stats.crosstrack_passes = pass_num
log(
VERBOSITY_INFO, verbosity, "info",
f" Pass {pass_num}: dropped {n_dropped:,} points "
f"({len(points):,} remain).",
)
if n_dropped == 0:
log(VERBOSITY_INFO, verbosity, "good",
f" ✓ Converged after {pass_num} pass(es).")
break
else:
if total_dropped > 0:
log(VERBOSITY_INFO, verbosity, "warn",
f" ⚠ Reached pass limit ({max_passes}); "
f"{total_dropped:,} total points dropped. "
"Consider increasing --passes.")
stats.points_crosstrack_drop += total_dropped
return points
# ── phase 5: distance decimation ──────────────────────────────────────────────
def decimate_points(
points: list[Point],
min_distance_m: float,
merge_distance_m: float,
verbosity: int,
stats: Stats,
) -> list[Point]:
"""
Walk the sorted point list and produce a new list where:
• Points closer than merge_distance_m to the previous *output* point are dropped.
• Once accumulated distance >= min_distance_m, the centroid of accumulated
points is emitted as the next output point.
The centroid approach avoids simply picking one of a cluster of GPS fixes;
instead it averages them so the emitted point sits in the middle of a tight
cluster from overlapping tracks.
"""
log(VERBOSITY_INFO, verbosity, "info",
f"📏 Decimating: keep points ≥ {min_distance_m:.0f} m apart "
f"(merge threshold {merge_distance_m:.0f} m) …")
if not points:
return points
output: list[Point] = []
# Accumulator for the current "cluster" being merged
cluster_lats: list[float] = []
cluster_lons: list[float] = []
cluster_eles: list[float] = []
cluster_times: list[datetime] = []
cluster_source: str = ""
# Last *emitted* position — used for distance check
last_emitted_lat: float = points[0].lat
last_emitted_lon: float = points[0].lon
accumulated_dist: float = 0.0
def mean_longitude(lons: list[float]) -> float:
"""
Compute the mean longitude, correctly handling the antimeridian (±180°).
Naive averaging of e.g. [-179.99, +179.99] gives 0°, which is wrong —
the two points are less than 0.02° apart across the date line.
We convert each longitude to a unit vector on the circle (cos, sin),
average the vectors, and take atan2 of the result. This gives the
correct circular mean regardless of whether the cluster straddles ±180°.
"""
import math as _math
sx = sum(_math.cos(_math.radians(lon)) for lon in lons)
sy = sum(_math.sin(_math.radians(lon)) for lon in lons)
return _math.degrees(_math.atan2(sy, sx))
def flush_cluster() -> Optional[Point]:
"""Emit the centroid of the current cluster as one output point."""
if not cluster_lats:
return None
avg_lat = sum(cluster_lats) / len(cluster_lats)
avg_lon = mean_longitude(cluster_lons)
avg_ele = (sum(cluster_eles) / len(cluster_eles)) if cluster_eles else None
# Use median time (middle index) to keep a real timestamp
t = sorted(cluster_times)[len(cluster_times) // 2] if cluster_times else None
return Point(lat=avg_lat, lon=avg_lon, ele=avg_ele, time=t, source=cluster_source)
def reset_cluster(p: Point) -> None:
cluster_lats.clear()
cluster_lons.clear()
cluster_eles.clear()
cluster_times.clear()
cluster_lats.append(p.lat)
cluster_lons.append(p.lon)
if p.ele is not None:
cluster_eles.append(p.ele)
if p.time is not None:
cluster_times.append(p.time)
nonlocal cluster_source
cluster_source = p.source
with Progress(
SpinnerColumn(),
TextColumn("[info]Decimating …"),
BarColumn(),
TaskProgressColumn(),
MofNCompleteColumn(),
TimeRemainingColumn(),
console=console,
transient=True,
) as progress:
task = progress.add_task("points", total=len(points))
for pt in points:
d = haversine_m(last_emitted_lat, last_emitted_lon, pt.lat, pt.lon)
if d < merge_distance_m:
# Too close to the last emitted point — merge into current cluster
stats.points_merge_drop += 1
log(VERBOSITY_TRACE, verbosity, "trace",
f" merge [{pt.source}] {pt.lat:.5f},{pt.lon:.5f} d={d:.1f} m")
cluster_lats.append(pt.lat)
cluster_lons.append(pt.lon)
if pt.ele is not None:
cluster_eles.append(pt.ele)
if pt.time is not None:
cluster_times.append(pt.time)
else:
accumulated_dist += d
if accumulated_dist >= min_distance_m:
# Emit the cluster centroid
emitted = flush_cluster()
if emitted:
output.append(emitted)
stats.points_out += 1
last_emitted_lat = emitted.lat
last_emitted_lon = emitted.lon
# Running distance tally
if len(output) > 1:
stats.total_dist_km += haversine_m(
output[-2].lat, output[-2].lon,
emitted.lat, emitted.lon
) / 1000.0
stats.update_bbox(emitted.lat, emitted.lon)
log(VERBOSITY_TRACE, verbosity, "trace",
f" emit {emitted.lat:.5f},{emitted.lon:.5f} "
f"(cluster of {len(cluster_lats)} pts, "
f"cum dist {accumulated_dist:.0f} m)")
accumulated_dist = 0.0
reset_cluster(pt)
else:
# Not far enough yet — accumulate
cluster_lats.append(pt.lat)
cluster_lons.append(pt.lon)
if pt.ele is not None:
cluster_eles.append(pt.ele)
if pt.time is not None:
cluster_times.append(pt.time)
progress.advance(task)
# Flush any remaining cluster
emitted = flush_cluster()
if emitted:
output.append(emitted)
stats.points_out += 1
stats.update_bbox(emitted.lat, emitted.lon)
log(VERBOSITY_INFO, verbosity, "info",
f" Emitted {stats.points_out:,} output points "
f"({stats.points_merge_drop:,} merged).")
return output
# ── phase 6: elevation-spike filter (post-decimation) ────────────────────────
def filter_elevation_anomalies(
points: list[Point],
max_ele_change_m: float,
verbosity: int,
stats: Stats,
) -> list[Point]:
"""
Scrub implausible elevation values from the already-decimated point list.
This runs AFTER decimation deliberately. Running it before decimation
caused a severe problem: dropping 30%+ of points before the distance
accumulator ran meant that tiny track fragments survived decimation as
individual points, inflating the output count from ~5k to ~114k on a
real 40 MB file.