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.
- microsegments/__init__.py +42 -0
- microsegments/_version.py +24 -0
- microsegments/aggregate.py +101 -0
- microsegments/cli.py +201 -0
- microsegments/config.py +142 -0
- microsegments/contract.py +42 -0
- microsegments/hotspots.py +337 -0
- microsegments/html/__init__.py +6 -0
- microsegments/html/export.py +96 -0
- microsegments/html/leaflet.min.css +2 -0
- microsegments/html/template.html +863 -0
- microsegments/io/__init__.py +9 -0
- microsegments/io/coverage.py +114 -0
- microsegments/io/events.py +98 -0
- microsegments/io/gtfsrt.py +168 -0
- microsegments/io/ids.py +34 -0
- microsegments/io/stib.py +217 -0
- microsegments/io/tabular.py +188 -0
- microsegments/io/timeutil.py +84 -0
- microsegments/locate/__init__.py +47 -0
- microsegments/locate/common.py +149 -0
- microsegments/locate/linear.py +317 -0
- microsegments/locate/mapmatch.py +390 -0
- microsegments/locate/resample.py +71 -0
- microsegments/metrics.py +520 -0
- microsegments/network/__init__.py +10 -0
- microsegments/network/calendar.py +218 -0
- microsegments/network/geometry.py +224 -0
- microsegments/network/keys.py +97 -0
- microsegments/network/patterns.py +328 -0
- microsegments/pipeline.py +308 -0
- microsegments/plot.py +476 -0
- microsegments/py.typed +0 -0
- microsegments/report.py +258 -0
- microsegments/schema.py +257 -0
- microsegments/segments.py +253 -0
- microsegments/simulate.py +612 -0
- microsegments/tracks.py +391 -0
- microsegments/tune.py +518 -0
- microsegments-0.1.0.dist-info/METADATA +184 -0
- microsegments-0.1.0.dist-info/RECORD +44 -0
- microsegments-0.1.0.dist-info/WHEEL +4 -0
- microsegments-0.1.0.dist-info/entry_points.txt +2 -0
- 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")
|