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 +128 -0
- trackclean/__main__.py +7 -0
- trackclean/clean.py +399 -0
- trackclean/cli.py +238 -0
- trackclean/filters.py +329 -0
- trackclean/geodesy.py +203 -0
- trackclean/io/__init__.py +83 -0
- trackclean/io/csv.py +224 -0
- trackclean/io/geojson.py +268 -0
- trackclean/io/gpx.py +325 -0
- trackclean/io/tcx.py +240 -0
- trackclean/metrics.py +301 -0
- trackclean/modes.py +83 -0
- trackclean/privacy.py +53 -0
- trackclean/py.typed +0 -0
- trackclean/resample.py +108 -0
- trackclean/segment.py +300 -0
- trackclean/simplify.py +172 -0
- trackclean/synthetic.py +233 -0
- trackclean/timeutil.py +155 -0
- trackclean/track.py +338 -0
- trackclean-0.1.0.dist-info/METADATA +287 -0
- trackclean-0.1.0.dist-info/RECORD +27 -0
- trackclean-0.1.0.dist-info/WHEEL +5 -0
- trackclean-0.1.0.dist-info/entry_points.txt +2 -0
- trackclean-0.1.0.dist-info/licenses/LICENSE +21 -0
- trackclean-0.1.0.dist-info/top_level.txt +1 -0
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
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)
|