microsegments 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.
Files changed (44) hide show
  1. microsegments/__init__.py +42 -0
  2. microsegments/_version.py +24 -0
  3. microsegments/aggregate.py +101 -0
  4. microsegments/cli.py +201 -0
  5. microsegments/config.py +142 -0
  6. microsegments/contract.py +42 -0
  7. microsegments/hotspots.py +337 -0
  8. microsegments/html/__init__.py +6 -0
  9. microsegments/html/export.py +96 -0
  10. microsegments/html/leaflet.min.css +2 -0
  11. microsegments/html/template.html +863 -0
  12. microsegments/io/__init__.py +9 -0
  13. microsegments/io/coverage.py +114 -0
  14. microsegments/io/events.py +98 -0
  15. microsegments/io/gtfsrt.py +168 -0
  16. microsegments/io/ids.py +34 -0
  17. microsegments/io/stib.py +217 -0
  18. microsegments/io/tabular.py +188 -0
  19. microsegments/io/timeutil.py +84 -0
  20. microsegments/locate/__init__.py +47 -0
  21. microsegments/locate/common.py +149 -0
  22. microsegments/locate/linear.py +317 -0
  23. microsegments/locate/mapmatch.py +390 -0
  24. microsegments/locate/resample.py +71 -0
  25. microsegments/metrics.py +520 -0
  26. microsegments/network/__init__.py +10 -0
  27. microsegments/network/calendar.py +218 -0
  28. microsegments/network/geometry.py +224 -0
  29. microsegments/network/keys.py +97 -0
  30. microsegments/network/patterns.py +328 -0
  31. microsegments/pipeline.py +308 -0
  32. microsegments/plot.py +476 -0
  33. microsegments/py.typed +0 -0
  34. microsegments/report.py +258 -0
  35. microsegments/schema.py +257 -0
  36. microsegments/segments.py +253 -0
  37. microsegments/simulate.py +612 -0
  38. microsegments/tracks.py +391 -0
  39. microsegments/tune.py +518 -0
  40. microsegments-0.1.0.dist-info/METADATA +184 -0
  41. microsegments-0.1.0.dist-info/RECORD +44 -0
  42. microsegments-0.1.0.dist-info/WHEEL +4 -0
  43. microsegments-0.1.0.dist-info/entry_points.txt +2 -0
  44. microsegments-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,390 @@
1
+ """Lat/lon fixes (GTFS-RT VehiclePositions, AVL exports) -> Placed.
2
+
3
+ Each fix is projected (local metric frame, shapely STRtree over the shape segments) onto the
4
+ patterns running that service date: the main pattern of each direction and the day's detours /
5
+ other patterns (short workings ride their parent main). Candidates farther than
6
+ ``params.max_lateral_m`` are rejected; a fix with no candidate is ``off_pattern`` (dropped,
7
+ reported). On a self-overlapping pattern (loop, out-and-back) a fix has several candidate passes;
8
+ the vehicle's previous position picks the next pass along (monotone ``s``).
9
+
10
+ Pattern choice, first that applies:
11
+
12
+ 1. ``trip_id`` found among the GTFS trips of the network (pattern of the trip; short working ->
13
+ its parent main, flag SHORT_WORKING);
14
+ 2. ``direction_id``: candidates of that direction only (then as below among them);
15
+ 3. per-vehicle continuity (``vehicle_id``): over a sliding window of the vehicle's fixes, the
16
+ candidate along which the vehicle progresses (``s`` increasing) wins; several progressing
17
+ candidates (a detour sharing the trunk): lowest lateral error, main preferred;
18
+ 4. ``bearing`` vs the shape tangent (within 45 degrees);
19
+ 5. a single candidate within reach; else ``ambiguous`` (dropped, reported).
20
+
21
+ Positions: ``s_m`` along the pattern (a fix less than ``at_stop_m`` past a stop is standing at it:
22
+ snapped to the stop, flag AT_STOP), ``link_idx`` / ``pos_m`` with the same half-open ``(lo, hi]``
23
+ links as the linear locator (a vehicle at stop B is at the end of link A->B). A fix within
24
+ ``params.layover_m`` of the pattern's (or the short working's) first stop, or at / past its last
25
+ stop (within ``layover_m`` of it when the direction was inferred), is terminus layover: kept for
26
+ tracking, flag LAYOVER, ``count`` False. ``track_id`` =
27
+ ``vehicle_id`` (else ``trip_id``).
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from collections import Counter
32
+
33
+ import numpy as np
34
+ import polars as pl
35
+ import shapely
36
+
37
+ from .. import schema
38
+ from ..config import Params
39
+ from ..network.geometry import LocalFrame, cumlen
40
+ from ..schema import Flag
41
+ from .common import PatInfo, Placed, date_versions, ensure_local_time, finish, link_of_s, pattern_infos
42
+
43
+ WINDOW = 3 # fixes on each side for the continuity score
44
+ MIN_PROGRESS_M = 15.0 # net progress over the window to call a direction
45
+ MARGIN_M = 15.0
46
+ MAX_STEP_M = 300.0
47
+ BEARING_TOL = 45.0
48
+ MAIN_PREF_M = 2.0
49
+ CHUNK = 200_000
50
+ AT_STOP_M = 10.0
51
+
52
+
53
+ class _Shape:
54
+ """A pattern's polyline in the local frame with along-pattern metres at each vertex."""
55
+
56
+ def __init__(self, P: PatInfo, coords_ll: np.ndarray, frame: LocalFrame):
57
+ ll = np.asarray(coords_ll, dtype=np.float64).reshape(-1, 2)
58
+ keep = np.ones(len(ll), bool)
59
+ keep[1:] = np.any(np.diff(ll, axis=0) != 0, axis=1)
60
+ ll = ll[keep]
61
+ self.xy = frame.xy(ll)
62
+ cum = cumlen(ll)
63
+ self.cum = cum * (P.length / cum[-1]) if cum[-1] > 0 else cum
64
+ self.tree = shapely.STRtree(shapely.linestrings(np.stack([self.xy[:-1], self.xy[1:]], axis=1)))
65
+ v = np.diff(self.xy, axis=0)
66
+ self.bearing = np.degrees(np.arctan2(v[:, 0], v[:, 1])) % 360
67
+
68
+ def project(self, pts: np.ndarray, max_d: float):
69
+ """Candidate passes within max_d: arrays (point idx, s, lateral d, bearing) one per pass."""
70
+ outs = []
71
+ for c0 in range(0, len(pts), CHUNK):
72
+ p = pts[c0:c0 + CHUNK]
73
+ q = self.tree.query(shapely.points(p), predicate="dwithin", distance=max_d)
74
+ if not q.shape[1]:
75
+ continue
76
+ i, k = q[0], q[1]
77
+ a = self.xy[k]
78
+ v = self.xy[k + 1] - a
79
+ vv = (v * v).sum(axis=1)
80
+ rel = p[i] - a
81
+ t = np.clip(np.where(vv > 0, (rel * v).sum(axis=1) / np.where(vv > 0, vv, 1), 0), 0, 1)
82
+ d = np.hypot(*(rel - t[:, None] * v).T)
83
+ s = self.cum[k] + t * (self.cum[k + 1] - self.cum[k])
84
+ o = np.lexsort((s, i))
85
+ i, s, d, k = i[o], s[o], d[o], k[o]
86
+ # passes: contiguous runs of s per point (a gap of more than 2 x max_d = another pass)
87
+ new = np.ones(len(i), bool)
88
+ new[1:] = (i[1:] != i[:-1]) | (s[1:] - s[:-1] > 2 * max_d)
89
+ cid = np.cumsum(new) - 1
90
+ best = np.full(cid[-1] + 1, np.inf)
91
+ np.minimum.at(best, cid, d)
92
+ pick = d <= best[cid]
93
+ first = np.zeros(len(i), bool)
94
+ first[np.flatnonzero(pick)[np.unique(cid[pick], return_index=True)[1]]] = True
95
+ outs.append((i[first] + c0, s[first], d[first], self.bearing[k[first]]))
96
+ if not outs:
97
+ return np.zeros(0, np.int64), np.zeros(0), np.zeros(0), np.zeros(0)
98
+ return tuple(np.concatenate(x) for x in zip(*outs))
99
+
100
+
101
+ def _angle(a, b):
102
+ x = np.abs((np.asarray(a) - np.asarray(b) + 180) % 360 - 180)
103
+ return x
104
+
105
+
106
+ def place_latlon(obs, net, params: Params | None = None, *, tz: str = "Europe/Brussels",
107
+ service_day_start: str = "04:00", window: int = WINDOW, at_stop_m: float = AT_STOP_M) -> Placed:
108
+ """Map-match lat/lon observations onto the network's patterns. See the module docstring."""
109
+ params = params or Params()
110
+ if isinstance(obs, pl.LazyFrame):
111
+ obs = obs.collect()
112
+ df = schema.conform(obs, schema.OBSERVATION, schema.OBSERVATION_REQUIRED)
113
+ extras = [c for c in df.columns if c not in schema.OBSERVATION and c not in ("service_date", "hour", "dow")]
114
+ df = ensure_local_time(df, tz, service_day_start).with_row_index("obs_row")
115
+ report: Counter = Counter()
116
+ report["total"] = df.height
117
+ bad = df["lat"].is_null() | df["lon"].is_null()
118
+ report["no_position"] = int(bad.sum())
119
+ df = df.filter(~bad)
120
+
121
+ infos = pattern_infos(net)
122
+ dv, versions, n_fb = date_versions(net, df["service_date"].unique().to_list(), infos)
123
+ df = df.join(dv.select("service_date", "_ver"), on="service_date", how="left")
124
+ geo = dict(zip(net.patterns["pattern_uid"].to_list(), net.patterns["geometry"].to_list()))
125
+ allpts = [np.asarray(g, dtype=np.float64).reshape(-1, 2) for g in geo.values() if g]
126
+ frame = LocalFrame.around(*allpts) if allpts else LocalFrame(4.35, 50.85)
127
+ shapes: dict[str, _Shape] = {}
128
+
129
+ # trip_id -> pattern (restricted to the patterns of the row's version below)
130
+ trips = net.trips if getattr(net, "trips", None) is not None else None
131
+ max_d = float(params.max_lateral_m)
132
+ parts = []
133
+ for vi, V in enumerate(versions):
134
+ x = df.filter(pl.col("_ver") == vi)
135
+ if not x.height:
136
+ continue
137
+ cands = V.mains + V.others
138
+ for P in cands:
139
+ if P.uid not in shapes:
140
+ shapes[P.uid] = _Shape(P, geo[P.uid], frame)
141
+ n, C = x.height, len(cands)
142
+ pts = frame.xy(x.select("lon", "lat").to_numpy())
143
+ S = np.full((C, n), np.nan)
144
+ Dd = np.full((C, n), np.inf)
145
+ B = np.full((C, n), np.nan)
146
+ multi: dict[int, tuple] = {}
147
+ for c, P in enumerate(cands):
148
+ i, s, d, b = shapes[P.uid].project(pts, max_d)
149
+ # nearest pass first; several passes are resolved by continuity below
150
+ o = np.lexsort((d, i))
151
+ i, s, d, b = i[o], s[o], d[o], b[o]
152
+ f = np.ones(len(i), bool)
153
+ f[1:] = i[1:] != i[:-1]
154
+ S[c, i[f]], Dd[c, i[f]], B[c, i[f]] = s[f], d[f], b[f]
155
+ if (~f).any():
156
+ rep = np.unique(i[~f])
157
+ m = np.isin(i, rep)
158
+ multi[c] = (i[m], s[m], d[m])
159
+ has = np.isfinite(Dd)
160
+
161
+ # ---- 1. trip_id
162
+ choice = np.full(n, -1)
163
+ how = np.full(n, "", dtype=object)
164
+ uid_idx = {P.uid: c for c, P in enumerate(cands)}
165
+ short_of = np.full(n, None, dtype=object)
166
+ if trips is not None and x["trip_id"].null_count() < n:
167
+ tp = trips.select("trip_id", "pattern_uid").filter(pl.col("pattern_uid").is_in(list(V.kinds)))
168
+ tj = x.select("trip_id").with_row_index("_i").join(tp, on="trip_id", how="inner").unique("_i", keep="first")
169
+ for r, uid in zip(tj["_i"].to_list(), tj["pattern_uid"].to_list()):
170
+ kind, parent, _ = V.kinds.get(uid, (None, None, None))
171
+ tgt = parent if kind == "short" and parent in uid_idx else uid
172
+ if tgt in uid_idx:
173
+ choice[r] = uid_idx[tgt]
174
+ how[r] = "trip"
175
+ if tgt != uid:
176
+ short_of[r] = uid
177
+ # ---- 2. direction_id restricts the candidates
178
+ dirs = np.array([P.direction for P in cands])
179
+ xd = x["direction_id"].to_numpy()
180
+ xd = np.where(x["direction_id"].is_null().to_numpy(), -1, xd).astype(np.int64)
181
+ allowed = has & ((xd[None, :] < 0) | (dirs[:, None] == xd[None, :]))
182
+ # ---- 3. continuity per vehicle
183
+ veh = x["vehicle_id"].to_list()
184
+ t = x["ts"].dt.epoch("us").to_numpy() / 1e6
185
+ score = np.full((C, n), np.nan)
186
+ if any(v is not None for v in veh):
187
+ vid = x.select(pl.col("vehicle_id").rank("dense")).to_series().fill_null(0).to_numpy()
188
+ order = np.lexsort((t, vid))
189
+ vs, ts_ = vid[order], t[order]
190
+ same = np.concatenate([[False], (vs[1:] == vs[:-1]) & (vs[1:] > 0) & (np.diff(ts_) < 300)])
191
+ for c in range(C):
192
+ so = S[c, order]
193
+ step = np.where(same, np.clip(np.nan_to_num(np.diff(so, prepend=np.nan), nan=0.0),
194
+ -MAX_STEP_M, MAX_STEP_M), 0.0)
195
+ step[~same] = 0.0
196
+ cs = np.concatenate([[0.0], np.cumsum(step)])
197
+ # segment id so the window never crosses another vehicle / a long gap
198
+ seg = np.cumsum(~same)
199
+ idx = np.arange(n)
200
+ lo = np.maximum(idx - window, 0)
201
+ hi = np.minimum(idx + window, n - 1)
202
+ # clamp the window inside the run of the same segment
203
+ start_of = np.zeros(n, np.int64)
204
+ starts = np.flatnonzero(~same)
205
+ start_of[starts] = starts
206
+ start_of = np.maximum.accumulate(start_of)
207
+ ends = np.r_[starts[1:] - 1, n - 1]
208
+ end_of = ends[seg - 1]
209
+ lo = np.maximum(lo, start_of)
210
+ hi = np.minimum(hi, end_of)
211
+ sc = cs[hi + 1] - cs[lo + 1]
212
+ score[c, order] = sc
213
+ brg = x["bearing"].cast(pl.Float64).to_numpy()
214
+ for r in range(n):
215
+ if choice[r] >= 0:
216
+ continue
217
+ ok = np.flatnonzero(allowed[:, r])
218
+ if not len(ok):
219
+ how[r] = "off" if not has[:, r].any() else "off_direction"
220
+ continue
221
+ if len(ok) == 1:
222
+ choice[r], how[r] = ok[0], "single"
223
+ continue
224
+ sc = score[ok, r]
225
+ pick = None
226
+ if np.isfinite(sc).any():
227
+ best = np.nanmax(sc)
228
+ good = ok[(sc >= best - MARGIN_M) & (sc >= MIN_PROGRESS_M)]
229
+ if len(good):
230
+ pick, how_r = good, "continuity"
231
+ if pick is None and np.isfinite(brg[r]):
232
+ ang = _angle(B[ok, r], brg[r])
233
+ good = ok[ang < BEARING_TOL]
234
+ if len(good):
235
+ pick, how_r = good, "bearing"
236
+ if pick is None:
237
+ how[r] = "ambiguous"
238
+ continue
239
+ if len(pick) > 1:
240
+ # both directions look right (standing, noise): cannot tell
241
+ if len({int(dirs[c]) for c in pick}) > 1:
242
+ how[r] = "ambiguous"
243
+ continue
244
+ dmin = Dd[pick, r].min()
245
+ mains = [c for c in pick if c < len(V.mains) and Dd[c, r] <= dmin + MAIN_PREF_M]
246
+ pick = [mains[0]] if mains else [pick[np.argmin(Dd[pick, r])]]
247
+ choice[r], how[r] = pick[0], how_r
248
+
249
+ placed = choice >= 0
250
+ report["off_pattern"] += int(np.isin(how, ["off", "off_direction"]).sum())
251
+ report["ambiguous"] += int((how == "ambiguous").sum())
252
+ for k in ("trip", "single", "continuity", "bearing"):
253
+ report[f"by_{k}"] += int((how == k).sum())
254
+ rows = np.flatnonzero(placed)
255
+ ch = choice[rows]
256
+ s = S[ch, rows]
257
+ err = Dd[ch, rows]
258
+ # ---- loops: several passes -> the one following the vehicle's previous position
259
+ if multi:
260
+ s = _resolve_passes(rows, ch, s, err, multi, x, t)
261
+ flags = np.zeros(len(rows), np.int64)
262
+ srel = np.zeros(len(rows))
263
+ cnt = np.ones(len(rows), bool)
264
+ uid = np.empty(len(rows), dtype=object)
265
+ li = np.zeros(len(rows), np.int16)
266
+ pos = np.zeros(len(rows))
267
+ key = np.empty(len(rows), dtype=object)
268
+ pdir = np.zeros(len(rows), np.int8)
269
+ term = np.full(len(rows), np.nan)
270
+ for c, P in enumerate(cands):
271
+ m = ch == c
272
+ if not m.any():
273
+ continue
274
+ uid[m] = P.uid
275
+ pdir[m] = P.direction
276
+ # within at_stop_m after a stop: standing at it (GPS noise), end of the link arriving there,
277
+ # as the linear rule (feed distance < 10 m after the stop = at the stop)
278
+ k_ = np.searchsorted(P.s, s[m], side="right") - 1
279
+ snap = (k_ >= 1) & (k_ < len(P.s) - 1) & (s[m] - P.s[np.clip(k_, 0, len(P.s) - 1)] < at_stop_m)
280
+ sm = np.where(snap, P.s[np.clip(k_, 0, len(P.s) - 1)], s[m])
281
+ s[m] = sm
282
+ a, b = link_of_s(sm, P.s)
283
+ li[m], pos[m] = a, b
284
+ key[m] = np.array(P.keys, dtype=object)[a]
285
+ start, end = 0.0, P.length
286
+ st = np.full(m.sum(), start)
287
+ en = np.full(m.sum(), end)
288
+ sw = short_of[rows[m]]
289
+ for k_, su in enumerate(sw):
290
+ if su is not None:
291
+ _, parent, off = V.kinds[su]
292
+ Ps = infos[su]
293
+ st[k_] = P.s[off]
294
+ en[k_] = P.s[min(off + len(Ps.s) - 1, len(P.s) - 1)]
295
+ is_sw = np.array([v is not None for v in sw])
296
+ # end zone: with a known trip / direction a fix is on its trip until the last stop (as the
297
+ # linear feed, which reports the stop once reached); an inferred direction is not trusted
298
+ # within layover_m of the end (a vehicle standing there fits both directions)
299
+ known = np.isin(how[rows[m]], ["trip", "single"]) & (xd[rows[m]] >= 0) | (how[rows[m]] == "trip")
300
+ end_m = np.where(known, 0.5, params.layover_m)
301
+ lay = (s[m] - st < params.layover_m) | (s[m] > en - end_m)
302
+ f = np.where(lay, Flag.LAYOVER, 0) | np.where(is_sw, Flag.SHORT_WORKING, 0)
303
+ f |= np.where(snap, Flag.AT_STOP, 0)
304
+ flags[m] = f
305
+ cnt[m] = ~lay
306
+ srel[m] = s[m] - st
307
+ # a known trip on the link leading to its terminus: a per-trip feed stops once the trip has
308
+ # arrived, so the track is taken to reach that terminus (term_s_m, as the linear locator)
309
+ ei = np.searchsorted(P.s, en, side="left")
310
+ last_link = s[m] > P.s[np.clip(ei - 1, 0, len(P.s) - 1)]
311
+ term[m] = np.where(known & last_link, en, np.nan)
312
+ xr = x[rows]
313
+ part = xr.with_columns(
314
+ pl.Series("pattern_uid", uid.tolist(), dtype=pl.Utf8),
315
+ pl.Series("link_idx", li, dtype=pl.Int16),
316
+ pl.Series("link_key", key.tolist(), dtype=pl.Utf8),
317
+ pl.Series("pos_m", pos, dtype=pl.Float32),
318
+ pl.Series("s_m", s, dtype=pl.Float32),
319
+ pl.Series("match_err_m", err, dtype=pl.Float32),
320
+ pl.Series("count", cnt),
321
+ pl.Series("_srel", srel),
322
+ pl.Series("flags", flags, dtype=pl.UInt16),
323
+ pl.Series("direction_id_", pdir, dtype=pl.Int8),
324
+ pl.Series("term_s_m", term, dtype=pl.Float64, nan_to_null=True),
325
+ pl.coalesce("vehicle_id", "trip_id").alias("track_id"),
326
+ )
327
+ parts.append(part)
328
+ if parts:
329
+ out = _start_layover(pl.concat(parts, how="vertical_relaxed"), params.layover_m).sort("obs_row")
330
+ out = out.drop("direction_id").rename({"direction_id_": "direction_id"})
331
+ else:
332
+ out = df.head(0)
333
+ out = finish(out, ["direction_id", "obs_row", "term_s_m"] + extras)
334
+ report["counted"] = int(out["count"].sum()) if out.height else 0
335
+ report["layover"] = int((~out["count"]).sum()) if out.height else 0
336
+ report["gtfs_date_fallback_days"] = n_fb
337
+ return Placed(frame=out, report={k: int(v) for k, v in report.items()})
338
+
339
+
340
+ def _start_layover(df: pl.DataFrame, layover_m: float) -> pl.DataFrame:
341
+ """A vehicle waiting at its first stop jitters around it (GPS noise): every fix of a run before it
342
+ clearly leaves (3 x layover_m) that is not past its last fix under layover_m is layover too."""
343
+ tr = pl.coalesce("vehicle_id", "trip_id")
344
+ d = df.with_columns(tr.alias("_tr")).sort("_tr", "ts", maintain_order=True)
345
+ run = ((pl.col("_tr") != pl.col("_tr").shift(1)) | (pl.col("pattern_uid") != pl.col("pattern_uid").shift(1))
346
+ | ((pl.col("ts") - pl.col("ts").shift(1)).dt.total_seconds() > 600)).fill_null(True).cum_sum()
347
+ d = d.with_columns(run.alias("_run"))
348
+ dep = pl.when(pl.col("_srel") >= 3 * layover_m).then(pl.col("ts")).min().over("_run")
349
+ d = d.with_columns(dep.alias("_dep"))
350
+ last = (pl.when((pl.col("_srel") < layover_m) & ((pl.col("ts") < pl.col("_dep")) | pl.col("_dep").is_null()))
351
+ .then(pl.col("ts")).max().over("_run"))
352
+ lay = (pl.col("_tr").is_not_null() & (pl.col("_srel") < 3 * layover_m) & (pl.col("ts") <= last)
353
+ & ((pl.col("ts") < pl.col("_dep")) | pl.col("_dep").is_null()))
354
+ d = d.with_columns(
355
+ pl.when(lay).then(False).otherwise(pl.col("count")).alias("count"),
356
+ pl.when(lay).then(pl.col("flags") | Flag.LAYOVER).otherwise(pl.col("flags")).cast(pl.UInt16).alias("flags"))
357
+ return d.drop("_tr", "_run", "_dep", "_srel")
358
+
359
+
360
+ def _resolve_passes(rows, ch, s, err, multi, x, t):
361
+ """Fixes with several passes on their pattern: per track, the first pass at or after the
362
+ previous position (minus 50 m of noise); none -> the earliest pass (new run)."""
363
+ s = s.copy()
364
+ cand: dict[tuple[int, int], list] = {}
365
+ for c, (i, ss, dd) in multi.items():
366
+ for a, b, d in zip(i.tolist(), ss.tolist(), dd.tolist()):
367
+ cand.setdefault((c, a), []).append((b, d))
368
+ if not cand:
369
+ return s
370
+ track = x["vehicle_id"].fill_null(x["trip_id"]).to_list()
371
+ pos_in = {r: k for k, r in enumerate(rows.tolist())}
372
+ by_track: dict = {}
373
+ for k, r in enumerate(rows.tolist()):
374
+ by_track.setdefault((track[r], int(ch[k])), []).append(r)
375
+ for (tr, c), rs in by_track.items():
376
+ if tr is None or not any((c, r) in cand for r in rs):
377
+ continue
378
+ rs.sort(key=lambda r: t[r])
379
+ prev, prev_t = None, None
380
+ for r in rs:
381
+ k = pos_in[r]
382
+ opts = sorted(cand.get((c, r), [(s[k], err[k])]))
383
+ if prev is None or t[r] - prev_t > 600:
384
+ pick = opts[0][0]
385
+ else:
386
+ ahead = [o for o in opts if o[0] >= prev - 50]
387
+ pick = ahead[0][0] if ahead else opts[0][0]
388
+ s[k] = pick
389
+ prev, prev_t = pick, t[r]
390
+ return s
@@ -0,0 +1,71 @@
1
+ """Per-vehicle resampling of placed fixes onto a global time grid (one virtual poll every ``tick_s``).
2
+
3
+ A snapshot feed (STIB) sees every vehicle at each poll; a per-vehicle feed (GTFS-RT, AVL) reports
4
+ fixes at its own pace. To count comparably, each track is sampled at the grid instants
5
+ ``k * tick_s`` lying between two of its consecutive fixes on the same pattern that are at most
6
+ ``max_gap_s`` apart (or within half an interval before / after a run's first / last fix), taking
7
+ the nearer fix (position, status, flags). Interpolating positions would
8
+ spread dwells: between the last standing fix and the first moving one the vehicle mostly still
9
+ stands. Unlike keeping one fix per tick, this does not over-count where a track ends (a vehicle unseen
10
+ after its arrival) nor under-count where it starts.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+ import polars as pl
16
+
17
+
18
+
19
+ def _within(n: np.ndarray) -> np.ndarray:
20
+ """0..n_i-1 for each group, concatenated."""
21
+ first = np.repeat(np.cumsum(n) - n, n)
22
+ return np.arange(n.sum()) - first
23
+
24
+
25
+ def resample(placed, tick_s: float = 20.0, max_gap_s: float = 90.0) -> pl.DataFrame:
26
+ """Resample a PLACED frame (or Placed) with ``track_id`` onto the ``tick_s`` grid. Rows without
27
+ ``track_id`` are returned unchanged. Extra columns are taken from the nearer fix."""
28
+ frame = getattr(placed, "frame", placed)
29
+ none = frame.filter(pl.col("track_id").is_null() | pl.col("s_m").is_null())
30
+ d = (frame.filter(pl.col("track_id").is_not_null() & pl.col("s_m").is_not_null())
31
+ .with_columns((pl.col("ts").dt.epoch("us") / 1e6).alias("_t"))
32
+ .sort("track_id", "_t", maintain_order=True))
33
+ if not d.height:
34
+ return frame
35
+ t = d["_t"].to_numpy()
36
+ same = np.zeros(len(t), bool)
37
+ same[:-1] = ((d["track_id"].to_numpy()[1:] == d["track_id"].to_numpy()[:-1])
38
+ & (d["pattern_uid"].to_numpy()[1:] == d["pattern_uid"].to_numpy()[:-1])
39
+ & (np.diff(t) <= max_gap_s) & (np.diff(t) > 0))
40
+ i = np.flatnonzero(same)
41
+ k0 = np.floor(t[i] / tick_s).astype(np.int64) # grid instants in (t_i, t_i+1]
42
+ k1 = np.floor(t[i + 1] / tick_s).astype(np.int64)
43
+ n = k1 - k0
44
+ pair = np.repeat(i, n)
45
+ first = np.repeat(np.cumsum(n) - n, n)
46
+ g = (np.repeat(k0, n) + 1 + (np.arange(n.sum()) - first)) * tick_s
47
+ a, b = t[pair], t[pair + 1]
48
+ w = (g - a) / (b - a)
49
+ near = np.where(w < 0.5, pair, pair + 1)
50
+ # run ends / starts: the end fix is also the nearest fix of the instants up to half its last
51
+ # interval after it (and the start fix of those half its first interval before it)
52
+ prev_ok = np.zeros(len(t), bool)
53
+ prev_ok[1:] = same[:-1]
54
+ e = np.flatnonzero(~same & prev_ok)
55
+ he = (t[e] - t[e - 1]) / 2
56
+ ne = np.floor((t[e] + he) / tick_s).astype(np.int64) - np.floor(t[e] / tick_s).astype(np.int64)
57
+ st = np.flatnonzero(same & ~prev_ok)
58
+ hs = (t[st + 1] - t[st]) / 2
59
+ ns = np.ceil(t[st] / tick_s).astype(np.int64) - np.ceil((t[st] - hs) / tick_s).astype(np.int64)
60
+ ge = (np.repeat(np.floor(t[e] / tick_s), ne) + 1 + _within(ne)) * tick_s
61
+ gs = (np.repeat(np.ceil(t[st] / tick_s), ns) - 1 - _within(ns)) * tick_s
62
+ near = np.concatenate([near, np.repeat(e, ne), np.repeat(st, ns)])
63
+ g = np.concatenate([g, ge, gs])
64
+ out = d[near].with_columns(
65
+ pl.Series("_g", np.round(g * 1e6).astype(np.int64)).cast(pl.Datetime("us")).dt.replace_time_zone("UTC")
66
+ .cast(d.schema["ts"]).alias("ts"))
67
+ # local time of the grid instant: shift hour by whole hours crossed (service_date kept)
68
+ out = out.with_columns(
69
+ (pl.col("hour").cast(pl.Int32) + ((pl.col("ts").dt.epoch("s") // 3600) - (pl.col("_t") // 3600)).cast(pl.Int32))
70
+ .cast(pl.Int8).alias("hour")).drop("_t")
71
+ return pl.concat([out.select(frame.columns), none.select(frame.columns)], how="vertical_relaxed")