trackclean 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
trackclean/__init__.py ADDED
@@ -0,0 +1,128 @@
1
+ """trackclean - clean, smooth, segment and measure raw GPS trajectories.
2
+
3
+ Quick start::
4
+
5
+ import trackclean as tc
6
+
7
+ track = tc.read_track("ride.gpx")
8
+ result = tc.clean(track, mode="bike")
9
+ print(result.report)
10
+ print(tc.summarize(result.track, mode="bike").format())
11
+ tc.write(result.track, "ride.clean.gpx")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .clean import (
17
+ CleanResult,
18
+ clean,
19
+ collapse_stationary,
20
+ detect_jumps,
21
+ remove_invalid,
22
+ remove_outliers,
23
+ shift_naive_time,
24
+ sort_and_dedup,
25
+ split_segments_on_gaps,
26
+ )
27
+ from .filters import hampel, hampel_filter, kalman_rts, kalman_smooth, smooth_elevation
28
+ from .geodesy import (
29
+ LocalProjection,
30
+ bearing,
31
+ cumulative_distance,
32
+ haversine,
33
+ step_distances,
34
+ vincenty,
35
+ )
36
+ from .io import read, read_track, write
37
+ from .metrics import (
38
+ TrackStats,
39
+ elapsed_time,
40
+ elevation_gain_loss,
41
+ moving_time,
42
+ pace_min_per_km,
43
+ radius_of_gyration,
44
+ speed_profile,
45
+ stops_summary,
46
+ summarize,
47
+ track_distance,
48
+ )
49
+ from .modes import MODES, ModeProfile, get_mode
50
+ from .privacy import trim_ends
51
+ from .resample import resample_distance, resample_time
52
+ from .segment import (
53
+ ModeGuess,
54
+ Segmentation,
55
+ StayPoint,
56
+ Trip,
57
+ detect_stay_points,
58
+ infer_mode,
59
+ segment_trips,
60
+ split_on_gaps,
61
+ )
62
+ from .simplify import douglas_peucker, sed_error, simplify, td_tr, visvalingam
63
+ from .timeutil import format_time, parse_time, parse_times
64
+ from .track import Track, TrackSet, Waypoint
65
+
66
+ __version__ = "0.1.0"
67
+
68
+ __all__ = [
69
+ "MODES",
70
+ "CleanResult",
71
+ "LocalProjection",
72
+ "ModeGuess",
73
+ "ModeProfile",
74
+ "Segmentation",
75
+ "StayPoint",
76
+ "Track",
77
+ "TrackSet",
78
+ "TrackStats",
79
+ "Trip",
80
+ "Waypoint",
81
+ "__version__",
82
+ "bearing",
83
+ "clean",
84
+ "collapse_stationary",
85
+ "cumulative_distance",
86
+ "detect_jumps",
87
+ "detect_stay_points",
88
+ "douglas_peucker",
89
+ "elapsed_time",
90
+ "elevation_gain_loss",
91
+ "format_time",
92
+ "get_mode",
93
+ "hampel",
94
+ "hampel_filter",
95
+ "haversine",
96
+ "infer_mode",
97
+ "kalman_rts",
98
+ "kalman_smooth",
99
+ "moving_time",
100
+ "pace_min_per_km",
101
+ "parse_time",
102
+ "parse_times",
103
+ "radius_of_gyration",
104
+ "read",
105
+ "read_track",
106
+ "remove_invalid",
107
+ "remove_outliers",
108
+ "resample_distance",
109
+ "resample_time",
110
+ "sed_error",
111
+ "segment_trips",
112
+ "shift_naive_time",
113
+ "simplify",
114
+ "smooth_elevation",
115
+ "sort_and_dedup",
116
+ "speed_profile",
117
+ "split_on_gaps",
118
+ "split_segments_on_gaps",
119
+ "step_distances",
120
+ "stops_summary",
121
+ "summarize",
122
+ "td_tr",
123
+ "track_distance",
124
+ "trim_ends",
125
+ "vincenty",
126
+ "visvalingam",
127
+ "write",
128
+ ]
trackclean/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m trackclean``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
trackclean/clean.py ADDED
@@ -0,0 +1,399 @@
1
+ """Trajectory cleaning: ordering, outliers, teleports, standstill drift and the full pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional, Set, Tuple, Union
7
+
8
+ import numpy as np
9
+
10
+ from .filters import hampel_filter, kalman_smooth, measurement_sigma, smooth_elevation
11
+ from .geodesy import LocalProjection
12
+ from .modes import ModeProfile, get_mode
13
+ from .segment import detect_stay_points
14
+ from .timeutil import TzLike, resolve_tz
15
+ from .track import Track
16
+
17
+ __all__ = [
18
+ "CleanResult",
19
+ "clean",
20
+ "collapse_stationary",
21
+ "detect_jumps",
22
+ "remove_invalid",
23
+ "remove_outliers",
24
+ "shift_naive_time",
25
+ "sort_and_dedup",
26
+ "split_segments_on_gaps",
27
+ ]
28
+
29
+
30
+ def remove_invalid(track: Track, *, drop_null_island: bool = True) -> Tuple[Track, int]:
31
+ """Drop points with missing/out-of-range coordinates (and exact ``0, 0`` fixes)."""
32
+ ok = (
33
+ np.isfinite(track.lat)
34
+ & np.isfinite(track.lon)
35
+ & (np.abs(track.lat) <= 90.0)
36
+ & (np.abs(track.lon) <= 180.0)
37
+ )
38
+ if drop_null_island:
39
+ ok &= ~((track.lat == 0.0) & (track.lon == 0.0))
40
+ return track.take(ok), int((~ok).sum())
41
+
42
+
43
+ def sort_and_dedup(track: Track, *, keep: str = "first") -> Tuple[Track, Dict[str, int]]:
44
+ """Sort by time (stable), drop points without a time and points sharing a timestamp.
45
+
46
+ Segment order is preserved: points are sorted within the track as a whole, then
47
+ segment ids are re-numbered in order of appearance. ``keep`` is ``"first"`` or
48
+ ``"best"`` (the fix with the smallest ``accuracy``/``hdop`` among duplicates).
49
+ """
50
+ stats = {"no_time": 0, "duplicates": 0, "reordered": 0}
51
+ if not len(track):
52
+ return track.copy(), stats
53
+ has_t = np.isfinite(track.time)
54
+ stats["no_time"] = int((~has_t).sum())
55
+ tr = track.take(has_t)
56
+ order = np.argsort(tr.time, kind="stable")
57
+ stats["reordered"] = int(np.count_nonzero(order != np.arange(order.size)))
58
+ tr = tr.take(order)
59
+ if len(tr) < 2:
60
+ return tr, stats
61
+ quality = None
62
+ if keep == "best":
63
+ q = tr.fields.get("accuracy", tr.fields.get("hdop"))
64
+ if q is not None:
65
+ quality = np.where(np.isfinite(q), q, np.inf)
66
+ same = np.concatenate([[False], np.diff(tr.time) == 0])
67
+ if quality is None:
68
+ keep_mask = ~same
69
+ else:
70
+ keep_mask = np.ones(len(tr), dtype=bool)
71
+ group = np.cumsum(~same) - 1
72
+ for g in np.unique(group[same]):
73
+ idx = np.flatnonzero(group == g)
74
+ best = idx[int(np.argmin(quality[idx]))]
75
+ keep_mask[idx] = False
76
+ keep_mask[best] = True
77
+ stats["duplicates"] = int((~keep_mask).sum())
78
+ return tr.take(keep_mask), stats
79
+
80
+
81
+ def shift_naive_time(track: Track, tz: TzLike) -> Track:
82
+ """Re-interpret times that were read as UTC but were really local time in ``tz``.
83
+
84
+ Useful for loggers that write local clock time without an offset. Handles daylight
85
+ saving transitions for IANA zones.
86
+ """
87
+ from datetime import datetime, timezone
88
+
89
+ tzi = resolve_tz(tz)
90
+ out = track.copy()
91
+ for i, t in enumerate(track.time):
92
+ if np.isfinite(t):
93
+ naive = datetime.fromtimestamp(float(t), tz=timezone.utc).replace(tzinfo=None)
94
+ out.time[i] = naive.replace(tzinfo=tzi).timestamp() + (t - np.floor(t))
95
+ return out
96
+
97
+
98
+ def _step_xy(track: Track) -> Tuple[np.ndarray, np.ndarray]:
99
+ proj = LocalProjection.for_points(track.lat, track.lon)
100
+ return proj.forward(track.lat, track.lon)
101
+
102
+
103
+ def _gate(
104
+ d: np.ndarray,
105
+ dt: np.ndarray,
106
+ v_ref: np.ndarray,
107
+ profile: ModeProfile,
108
+ noise: np.ndarray,
109
+ k_sigma: float,
110
+ ) -> np.ndarray:
111
+ """True where a step of length d over dt seconds is kinematically implausible."""
112
+ v_allowed = np.minimum(profile.max_speed, v_ref + profile.max_accel * dt)
113
+ return np.asarray(d > v_allowed * dt + k_sigma * noise)
114
+
115
+
116
+ def _robust_speed(d: np.ndarray, dt: np.ndarray, half: int = 4) -> np.ndarray:
117
+ v = d / np.maximum(dt, 1e-3)
118
+ if v.size == 0:
119
+ return v
120
+ pad = np.concatenate([np.full(half, np.nan), v, np.full(half, np.nan)])
121
+ win = np.lib.stride_tricks.sliding_window_view(pad, 2 * half + 1)
122
+ import warnings
123
+
124
+ with warnings.catch_warnings():
125
+ warnings.simplefilter("ignore", RuntimeWarning)
126
+ return np.asarray(np.nanmedian(win, axis=1))
127
+
128
+
129
+ def detect_jumps(
130
+ track: Track,
131
+ mode: Union[str, ModeProfile] = "walk",
132
+ *,
133
+ k_sigma: float = 4.0,
134
+ ) -> np.ndarray:
135
+ """Indices ``i`` of steps ``i -> i+1`` that are kinematically impossible.
136
+
137
+ A step is a jump when its length exceeds ``min(max_speed, v_ref + max_accel*dt) * dt``
138
+ plus ``k_sigma`` times the combined position noise of the two fixes, where ``v_ref`` is
139
+ a robust (median) local speed. The noise allowance keeps ordinary GPS scatter on slow
140
+ modes from being flagged.
141
+ """
142
+ prof = get_mode(mode)
143
+ if len(track) < 2 or not track.has_time:
144
+ return np.zeros(0, dtype=np.int64)
145
+ x, y = _step_xy(track)
146
+ d = np.hypot(np.diff(x), np.diff(y))
147
+ dt = np.diff(track.time)
148
+ sig = measurement_sigma(track, prof.gps_sigma)
149
+ noise = np.hypot(sig[:-1], sig[1:])
150
+ v_ref = _robust_speed(d, dt)
151
+ bad = _gate(d, dt, v_ref, prof, noise, k_sigma)
152
+ bad &= np.diff(track.segment) == 0
153
+ return np.flatnonzero(bad)
154
+
155
+
156
+ def remove_outliers(
157
+ track: Track,
158
+ mode: Union[str, ModeProfile] = "walk",
159
+ *,
160
+ k_sigma: float = 4.0,
161
+ max_block: int = 30,
162
+ max_block_time: float = 300.0,
163
+ min_segment_points: int = 3,
164
+ max_iter: int = 10,
165
+ ) -> Tuple[Track, Dict[str, int]]:
166
+ """Remove speed/acceleration outliers and resolve teleports.
167
+
168
+ 1. Steps that violate the kinematic gate (see :func:`detect_jumps`) are found.
169
+ 2. A jump away followed, within ``max_block`` points and ``max_block_time`` seconds, by
170
+ a jump back to where the track left off (the bridging step passes the gate) marks
171
+ the points in between as outliers: single spikes and short displaced bursts
172
+ (multipath, cell-tower fixes) both fall into this case.
173
+ 3. Jumps that never come back are genuine discontinuities (receiver re-acquired after a
174
+ tunnel, logger restarted elsewhere): the track is split into a new segment there.
175
+ Resulting fragments shorter than ``min_segment_points`` are dropped.
176
+
177
+ Returns the cleaned track and counters ``{"spikes", "teleports", "fragments"}``.
178
+ """
179
+ prof = get_mode(mode)
180
+ stats = {"spikes": 0, "teleports": 0, "fragments": 0}
181
+ tr = track.copy()
182
+ if len(tr) < 3 or not tr.has_time:
183
+ return tr, stats
184
+ sig_all = measurement_sigma(tr, prof.gps_sigma)
185
+ tr.fields["__sig"] = sig_all
186
+ for _ in range(max_iter):
187
+ jumps = detect_jumps(tr, prof, k_sigma=k_sigma)
188
+ if jumps.size == 0:
189
+ break
190
+ x, y = _step_xy(tr)
191
+ t = tr.time
192
+ sig = tr.fields["__sig"]
193
+ d_all = np.hypot(np.diff(x), np.diff(y))
194
+ v_ref = _robust_speed(d_all, np.diff(t))
195
+ drop = np.zeros(len(tr), dtype=bool)
196
+ used: Set[int] = set()
197
+ for a_pos, a in enumerate(jumps):
198
+ if a in used:
199
+ continue
200
+ for b in jumps[a_pos + 1 :]:
201
+ if b - a > max_block or t[b + 1] - t[a] > max_block_time:
202
+ break
203
+ if tr.segment[a] != tr.segment[b + 1]:
204
+ break
205
+ dd = float(np.hypot(x[b + 1] - x[a], y[b + 1] - y[a]))
206
+ dtt = float(t[b + 1] - t[a])
207
+ vr = float(np.nanmin([v_ref[a], v_ref[min(b + 1, v_ref.size - 1)]]))
208
+ noise = float(np.hypot(sig[a], sig[b + 1]))
209
+ vr = vr if np.isfinite(vr) else 0.0
210
+ implausible = _gate(
211
+ np.array([dd]),
212
+ np.array([dtt]),
213
+ np.array([vr]),
214
+ prof,
215
+ np.array([noise]),
216
+ k_sigma,
217
+ )[0]
218
+ if not implausible:
219
+ drop[a + 1 : b + 1] = True
220
+ used.update(int(j) for j in jumps if a <= j <= b)
221
+ break
222
+ if drop.any():
223
+ stats["spikes"] += int(drop.sum())
224
+ tr = tr.take(~drop)
225
+ continue
226
+ # No bridging found: unresolved jumps are discontinuities -> new segments.
227
+ cut = np.diff(tr.segment) != 0
228
+ cut[jumps] = True
229
+ tr.segment = np.concatenate([[0], np.cumsum(cut)]).astype(np.int64)
230
+ stats["teleports"] += int(jumps.size)
231
+ break
232
+ # Drop fragments that are too short to be trusted.
233
+ keep = np.ones(len(tr), dtype=bool)
234
+ for s in tr.segment_slices():
235
+ if s.stop - s.start < min_segment_points:
236
+ keep[s] = False
237
+ stats["fragments"] += s.stop - s.start
238
+ tr = tr.take(keep)
239
+ tr.fields.pop("__sig", None)
240
+ return tr.renumber_segments(), stats
241
+
242
+
243
+ def split_segments_on_gaps(track: Track, max_gap: float) -> Tuple[Track, int]:
244
+ """Start a new segment wherever the time step exceeds ``max_gap`` seconds."""
245
+ if len(track) < 2 or not track.has_time:
246
+ return track.copy(), 0
247
+ cut = np.diff(track.time) > max_gap
248
+ cut |= np.diff(track.segment) != 0
249
+ out = track.copy()
250
+ out.segment = np.concatenate([[0], np.cumsum(cut)]).astype(np.int64)
251
+ n_gaps = int((np.diff(track.time) > max_gap).sum())
252
+ return out, n_gaps
253
+
254
+
255
+ def collapse_stationary(
256
+ track: Track,
257
+ radius: float = 25.0,
258
+ min_duration: float = 120.0,
259
+ ) -> Tuple[Track, int]:
260
+ """Collapse GPS drift while standing still.
261
+
262
+ Stays of at least ``min_duration`` seconds within ``radius`` metres (stay-point
263
+ detection, :func:`trackclean.detect_stay_points`) have every point moved to the median
264
+ centre of the stay. Timestamps are kept, so dwell time survives while the spurious
265
+ "jitter distance" (often hundreds of metres per hour) disappears.
266
+ Returns the new track and the number of stays collapsed.
267
+ """
268
+ out = track.copy()
269
+ count = 0
270
+ for s in track.segment_slices():
271
+ part = track.take(s)
272
+ if len(part) < 3 or not part.has_time:
273
+ continue
274
+ stays = detect_stay_points(part, radius, min_duration, centroid="median")
275
+ if not stays:
276
+ continue
277
+ proj = part.projection()
278
+ x, y = proj.forward(part.lat, part.lon)
279
+ # Wandering indoor drift often splits one stop into several adjacent stays:
280
+ # merge neighbours separated by a few fixes whose centres are close.
281
+ groups = [[stays[0].start_index, stays[0].end_index]]
282
+ for sp in stays[1:]:
283
+ ga, gb = groups[-1]
284
+ cx0, cy0 = np.median(x[ga : gb + 1]), np.median(y[ga : gb + 1])
285
+ cx1, cy1 = (
286
+ np.median(x[sp.start_index : sp.end_index + 1]),
287
+ np.median(y[sp.start_index : sp.end_index + 1]),
288
+ )
289
+ short_gap = part.time[sp.start_index] - part.time[gb] <= min_duration / 2
290
+ if short_gap and np.hypot(cx1 - cx0, cy1 - cy0) <= radius:
291
+ groups[-1][1] = sp.end_index
292
+ else:
293
+ groups.append([sp.start_index, sp.end_index])
294
+ for ga, gb in groups:
295
+ cx = float(np.median(x[ga : gb + 1]))
296
+ cy = float(np.median(y[ga : gb + 1]))
297
+ lat, lon = proj.inverse(cx, cy)
298
+ out.lat[s.start + ga : s.start + gb + 1] = float(lat)
299
+ out.lon[s.start + ga : s.start + gb + 1] = float(lon)
300
+ count += 1
301
+ return out, count
302
+
303
+
304
+ @dataclass
305
+ class CleanResult:
306
+ """Output of :func:`clean`: the cleaned track and what each step did."""
307
+
308
+ track: Track
309
+ report: Dict[str, Any] = field(default_factory=dict)
310
+ steps: List[str] = field(default_factory=list)
311
+
312
+ def summary(self) -> str:
313
+ lines = [f"{k}: {v}" for k, v in self.report.items()]
314
+ return "\n".join(lines)
315
+
316
+
317
+ def clean(
318
+ track: Track,
319
+ mode: Union[str, ModeProfile] = "walk",
320
+ *,
321
+ outliers: bool = True,
322
+ hampel: bool = True,
323
+ kalman: bool = True,
324
+ collapse_stops: bool = True,
325
+ elevation: bool = True,
326
+ max_gap: Optional[float] = None,
327
+ process_noise: Optional[float] = None,
328
+ gps_sigma: Optional[float] = None,
329
+ k_sigma: float = 4.0,
330
+ store_speed: bool = False,
331
+ ) -> CleanResult:
332
+ """Run the full cleaning pipeline.
333
+
334
+ Steps, in order (each can be switched off):
335
+
336
+ 1. drop invalid coordinates, points without time, duplicates; sort by time;
337
+ 2. split segments at signal gaps longer than ``max_gap`` (default: mode profile);
338
+ 3. remove speed/acceleration outliers and split at teleports (:func:`remove_outliers`);
339
+ 4. Hampel filter on east/north coordinates (moderate outliers the gate cannot see);
340
+ 5. Kalman constant-velocity filter + RTS smoother, per segment, using per-point
341
+ ``accuracy``/``hdop`` when present;
342
+ 6. collapse standstill drift to a single position per stay (:func:`collapse_stationary`);
343
+ 7. elevation spike removal and smoothing.
344
+
345
+ ``mode`` is ``walk``, ``run``, ``bike``, ``car``, ``transit`` or a custom
346
+ :class:`~trackclean.ModeProfile`.
347
+ """
348
+ prof = get_mode(mode)
349
+ if gps_sigma is not None:
350
+ prof = prof.with_(gps_sigma=gps_sigma)
351
+ if process_noise is not None:
352
+ prof = prof.with_(process_noise=process_noise)
353
+ report: Dict[str, Any] = {"mode": prof.name, "input_points": len(track)}
354
+ steps: List[str] = []
355
+
356
+ tr, n_invalid = remove_invalid(track)
357
+ report["invalid_removed"] = n_invalid
358
+ steps.append("remove_invalid")
359
+ if not len(tr):
360
+ report["output_points"] = 0
361
+ return CleanResult(tr, report, steps)
362
+ has_time = bool(np.isfinite(tr.time).any())
363
+ if has_time:
364
+ tr, st = sort_and_dedup(tr)
365
+ report["no_time_removed"] = st["no_time"]
366
+ report["duplicates_removed"] = st["duplicates"]
367
+ report["reordered_points"] = st["reordered"]
368
+ steps.append("sort_and_dedup")
369
+ tr, n_gaps = split_segments_on_gaps(tr, max_gap if max_gap is not None else prof.max_gap)
370
+ report["gaps_split"] = n_gaps
371
+ steps.append("split_gaps")
372
+ if outliers and has_time:
373
+ tr, st = remove_outliers(tr, prof, k_sigma=k_sigma)
374
+ report["outliers_removed"] = st["spikes"]
375
+ report["teleports_split"] = st["teleports"]
376
+ report["fragments_dropped"] = st["fragments"]
377
+ steps.append("remove_outliers")
378
+ if hampel:
379
+ tr, n_h = hampel_filter(tr)
380
+ report["hampel_replaced"] = n_h
381
+ steps.append("hampel")
382
+ if kalman and has_time:
383
+ tr = kalman_smooth(
384
+ tr,
385
+ process_noise=prof.process_noise,
386
+ sigma=prof.gps_sigma,
387
+ store_speed=store_speed,
388
+ )
389
+ steps.append("kalman_rts")
390
+ if collapse_stops and has_time:
391
+ tr, n_c = collapse_stationary(tr, prof.jitter_radius, prof.jitter_time)
392
+ report["stationary_collapsed"] = n_c
393
+ steps.append("collapse_stationary")
394
+ if elevation and tr.has_elevation:
395
+ tr = smooth_elevation(tr)
396
+ steps.append("smooth_elevation")
397
+ report["output_points"] = len(tr)
398
+ report["segments"] = tr.n_segments
399
+ return CleanResult(tr, report, steps)