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,9 @@
|
|
|
1
|
+
"""Input adapters: any source -> ``schema.OBSERVATION`` / ``SNAPSHOT`` / ``STOP_EVENT``, and feed coverage."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .coverage import coverage, coverage_from_config, snapshots_from_observations
|
|
5
|
+
from .tabular import read_observations, read_snapshots
|
|
6
|
+
from .timeutil import add_local_time
|
|
7
|
+
|
|
8
|
+
__all__ = ["read_observations", "read_snapshots", "add_local_time", "coverage", "coverage_from_config",
|
|
9
|
+
"snapshots_from_observations"]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Feed health per local service date x hour (``schema.COVERAGE``).
|
|
2
|
+
|
|
3
|
+
Each non-frozen poll at t_i covers [t_i, t_i + min(t_next - t_i, gap_cap_s)), t_next being the next
|
|
4
|
+
non-frozen poll (the last poll covers min(tick_s, gap_cap_s)). Each frozen snapshot covers
|
|
5
|
+
[t_i, t_i + min(t_next_any - t_i, gap_cap_s)) of ``frozen_s`` (t_next_any: next snapshot of any kind).
|
|
6
|
+
Intervals are split exactly at local hour boundaries and credited to the hour they fall in.
|
|
7
|
+
``coverage = covered_s / 3600`` clipped to 1. Frozen snapshots never count as polls.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
from ..schema import COVERAGE, SNAPSHOT, UTC, Flag
|
|
17
|
+
from .timeutil import add_local_time, parse_hhmm, utc_offset_s
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def snapshots_from_observations(obs: pl.DataFrame | pl.LazyFrame) -> pl.DataFrame:
|
|
21
|
+
"""Distinct observation times as polls (``frozen`` False, ``n_rows`` = rows at that ts)."""
|
|
22
|
+
lf = obs.lazy() if isinstance(obs, pl.DataFrame) else obs
|
|
23
|
+
return (lf.group_by("ts").agg(pl.len().cast(pl.UInt32).alias("n_rows"))
|
|
24
|
+
.with_columns(pl.lit(False).alias("frozen"))
|
|
25
|
+
.select(pl.col("ts").cast(UTC), "n_rows", "frozen").sort("ts").collect())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _split_by_hour(a: np.ndarray, b: np.ndarray, tz: str) -> tuple[np.ndarray, np.ndarray]:
|
|
29
|
+
"""Split [a, b) epoch-second intervals at local hour boundaries -> (piece start, piece length)."""
|
|
30
|
+
starts, lens = [], []
|
|
31
|
+
a = a.astype(np.float64).copy()
|
|
32
|
+
b = b.astype(np.float64)
|
|
33
|
+
keep = b > a
|
|
34
|
+
a, b = a[keep], b[keep]
|
|
35
|
+
while a.size:
|
|
36
|
+
off = utc_offset_s(a, tz)
|
|
37
|
+
nxt = np.floor((a + off) / 3600.0) * 3600.0 + 3600.0 - off
|
|
38
|
+
nxt = np.where(nxt <= a, a + 3600.0, nxt) # guard (DST edge)
|
|
39
|
+
end = np.minimum(b, nxt)
|
|
40
|
+
starts.append(a.copy())
|
|
41
|
+
lens.append(end - a)
|
|
42
|
+
more = end < b
|
|
43
|
+
a, b = end[more], b[more]
|
|
44
|
+
if not starts:
|
|
45
|
+
return np.zeros(0), np.zeros(0)
|
|
46
|
+
return np.concatenate(starts), np.concatenate(lens)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _hour_table(t: np.ndarray, value: np.ndarray, name: str, tz: str, sds: str) -> pl.DataFrame:
|
|
50
|
+
df = pl.DataFrame({"ts": (np.floor(t) * 1_000_000).astype(np.int64), name: value}).with_columns(
|
|
51
|
+
pl.col("ts").cast(pl.Datetime("us")).dt.replace_time_zone("UTC"))
|
|
52
|
+
return (add_local_time(df, tz, sds).group_by("service_date", "hour")
|
|
53
|
+
.agg(pl.col(name).sum()))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def coverage(snapshots: pl.DataFrame | pl.LazyFrame, tz: str = "Europe/Brussels",
|
|
57
|
+
service_day_start: str = "04:00", tick_s: float = 20.0, gap_cap_s: float = 40.0,
|
|
58
|
+
max_frozen_s: float = 300.0, min_coverage: float = 0.8,
|
|
59
|
+
dates: tuple[dt.date, dt.date] | None = None) -> pl.DataFrame:
|
|
60
|
+
"""SNAPSHOT -> COVERAGE, one row per service date x hour of the service day (24 rows per date,
|
|
61
|
+
from the service-day start hour, e.g. 4..27), zero-filled where the feed was silent.
|
|
62
|
+
|
|
63
|
+
``dates``: (first, last) service dates of the grid; default: span of the snapshots."""
|
|
64
|
+
df = snapshots.collect() if isinstance(snapshots, pl.LazyFrame) else snapshots
|
|
65
|
+
if "frozen" not in df.columns:
|
|
66
|
+
df = df.with_columns(pl.lit(False).alias("frozen"))
|
|
67
|
+
df = (df.select(pl.col("ts").cast(UTC), pl.col("frozen").cast(pl.Boolean).fill_null(False))
|
|
68
|
+
.sort("ts", "frozen").unique("ts", keep="first", maintain_order=True))
|
|
69
|
+
t = df["ts"].dt.epoch("us").to_numpy().astype(np.float64) / 1e6
|
|
70
|
+
fr = df["frozen"].to_numpy().astype(bool)
|
|
71
|
+
cap = float(gap_cap_s) if gap_cap_s is not None else np.inf
|
|
72
|
+
|
|
73
|
+
# polls
|
|
74
|
+
tp = t[~fr]
|
|
75
|
+
gap = np.diff(tp, append=tp[-1] + tick_s if tp.size else np.zeros(0))
|
|
76
|
+
ps, pl_ = _split_by_hour(tp, tp + np.minimum(gap, cap), tz)
|
|
77
|
+
# frozen snapshots
|
|
78
|
+
nxt_any = np.append(t[1:], t[-1] + tick_s) if t.size else t
|
|
79
|
+
tf, gf = t[fr], (nxt_any - t)[fr]
|
|
80
|
+
fs, fl = _split_by_hour(tf, tf + np.minimum(gf, cap), tz)
|
|
81
|
+
|
|
82
|
+
sds = service_day_start
|
|
83
|
+
cov = _hour_table(ps, pl_, "covered_s", tz, sds)
|
|
84
|
+
frz = _hour_table(fs, fl, "frozen_s", tz, sds)
|
|
85
|
+
nsn = _hour_table(tp, np.ones_like(tp), "n_snapshots", tz, sds)
|
|
86
|
+
|
|
87
|
+
if dates is None:
|
|
88
|
+
if t.size == 0:
|
|
89
|
+
return pl.DataFrame(schema=COVERAGE)
|
|
90
|
+
sd = add_local_time(df.select("ts"), tz, sds)["service_date"]
|
|
91
|
+
dates = (sd.min(), sd.max())
|
|
92
|
+
h0 = parse_hhmm(sds) // 3600
|
|
93
|
+
grid = pl.DataFrame({"service_date": pl.date_range(dates[0], dates[1], "1d", eager=True)}).join(
|
|
94
|
+
pl.DataFrame({"hour": pl.Series(range(h0, h0 + 24), dtype=pl.Int8)}), how="cross")
|
|
95
|
+
out = (grid.join(cov, on=["service_date", "hour"], how="left")
|
|
96
|
+
.join(frz, on=["service_date", "hour"], how="left")
|
|
97
|
+
.join(nsn, on=["service_date", "hour"], how="left")
|
|
98
|
+
.with_columns(pl.col("covered_s", "frozen_s", "n_snapshots").fill_null(0.0)))
|
|
99
|
+
c = (pl.col("covered_s") / 3600.0).clip(0.0, 1.0)
|
|
100
|
+
flags = (pl.when(c < min_coverage).then(Flag.LOW_COVERAGE).otherwise(0)
|
|
101
|
+
+ pl.when(pl.col("frozen_s") > max_frozen_s).then(Flag.FROZEN).otherwise(0))
|
|
102
|
+
out = out.with_columns(c.alias("coverage"), flags.alias("flags"))
|
|
103
|
+
return out.select([pl.col(k).cast(v) for k, v in COVERAGE.items()]).sort("service_date", "hour")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def coverage_from_config(snapshots, cfg, dates=None) -> pl.DataFrame:
|
|
107
|
+
"""``coverage`` with the parameters of a ``Config``."""
|
|
108
|
+
return coverage(snapshots, tz=cfg.input.timezone, service_day_start=cfg.input.service_day_start,
|
|
109
|
+
tick_s=cfg.params.tick_s, gap_cap_s=cfg.params.gap_cap_s,
|
|
110
|
+
max_frozen_s=cfg.quality.max_frozen_s, min_coverage=cfg.quality.min_hour_coverage,
|
|
111
|
+
dates=dates)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["coverage", "coverage_from_config", "snapshots_from_observations", "SNAPSHOT"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Stop-call events -> ``schema.STOP_EVENT``.
|
|
2
|
+
|
|
3
|
+
STIB punctuality parquet (MobilityTwin ``stib/punctuality``): one row per trip x stop with
|
|
4
|
+
``journey_id, trip_id, start_date (YYYYMMDD), stop_sequence, stop_id ("5272F"), route_id ("55"),
|
|
5
|
+
arrival_time, departure_time`` (naive UTC), ``observed`` (else inferred).
|
|
6
|
+
Generic CSV / parquet: any column names, mapped canonical -> source.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import polars as pl
|
|
11
|
+
|
|
12
|
+
from .. import schema
|
|
13
|
+
from ..schema import STOP_EVENT, UTC
|
|
14
|
+
from .ids import normalise_expr
|
|
15
|
+
from .tabular import expand_paths, parse_ts, scan_table
|
|
16
|
+
from .timeutil import add_local_time
|
|
17
|
+
|
|
18
|
+
_PUNCT_COLS = ["journey_id", "trip_id", "start_date", "stop_sequence", "stop_id", "route_id",
|
|
19
|
+
"arrival_time", "departure_time", "observed"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_stib_punctuality(paths, route: str | None = None, id_normaliser: str = "leading_digits",
|
|
23
|
+
only_observed: bool = False) -> pl.LazyFrame:
|
|
24
|
+
"""STIB punctuality parquet files -> STOP_EVENT. ``trip_key`` = ``journey_id:trip_id``."""
|
|
25
|
+
files = [f for f in expand_paths(paths) if f.endswith(".parquet")]
|
|
26
|
+
if not files:
|
|
27
|
+
raise FileNotFoundError(f"no punctuality parquet in {paths}")
|
|
28
|
+
lf = pl.concat([pl.scan_parquet(f) for f in files], how="diagonal_relaxed")
|
|
29
|
+
names = lf.collect_schema().names()
|
|
30
|
+
if route is not None:
|
|
31
|
+
lf = lf.filter(pl.col("route_id").cast(pl.Utf8) == str(route))
|
|
32
|
+
if only_observed:
|
|
33
|
+
lf = lf.filter(pl.col("observed"))
|
|
34
|
+
|
|
35
|
+
def utc(c):
|
|
36
|
+
dtype = lf.collect_schema()[c]
|
|
37
|
+
e = pl.col(c)
|
|
38
|
+
if isinstance(dtype, pl.Datetime) and dtype.time_zone is None:
|
|
39
|
+
e = e.dt.replace_time_zone("UTC")
|
|
40
|
+
return e.dt.convert_time_zone("UTC").cast(UTC)
|
|
41
|
+
|
|
42
|
+
jid = pl.col("journey_id").cast(pl.Utf8) if "journey_id" in names else pl.lit("")
|
|
43
|
+
out = lf.select(
|
|
44
|
+
pl.col("start_date").cast(pl.Utf8).str.strptime(pl.Date, "%Y%m%d", strict=False).alias("service_date"),
|
|
45
|
+
pl.col("route_id").cast(pl.Utf8),
|
|
46
|
+
pl.concat_str([jid.fill_null(""), pl.col("trip_id").cast(pl.Utf8)], separator=":").alias("trip_key"),
|
|
47
|
+
normalise_expr(pl.col("stop_id"), id_normaliser).alias("stop_id"),
|
|
48
|
+
pl.col("stop_sequence").cast(pl.Int32),
|
|
49
|
+
utc("arrival_time").alias("arrival"),
|
|
50
|
+
utc("departure_time").alias("departure"),
|
|
51
|
+
(pl.col("observed") if "observed" in names else pl.lit(True)).cast(pl.Boolean).alias("observed"),
|
|
52
|
+
)
|
|
53
|
+
return out.select(list(STOP_EVENT))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_events(paths, columns: dict[str, str] | None = None, ts_unit: str = "s",
|
|
57
|
+
tz: str = "Europe/Brussels", service_day_start: str = "04:00",
|
|
58
|
+
id_normaliser: str = "none", route: str | None = None) -> pl.LazyFrame:
|
|
59
|
+
"""Generic CSV / parquet stop events -> STOP_EVENT.
|
|
60
|
+
|
|
61
|
+
``columns``: canonical -> source names. Needs ``trip_key`` (or ``trip_id``), ``stop_id`` and
|
|
62
|
+
``arrival`` and/or ``departure``. Missing ``service_date``: the service date of the trip's first
|
|
63
|
+
event; missing ``stop_sequence``: rank by time within the trip; missing ``observed``: True."""
|
|
64
|
+
lf = scan_table(expand_paths(paths))
|
|
65
|
+
names = lf.collect_schema().names()
|
|
66
|
+
ren = {src: can for can, src in (columns or {}).items() if src in names and src != can}
|
|
67
|
+
lf = lf.rename(ren)
|
|
68
|
+
names = lf.collect_schema().names()
|
|
69
|
+
if "trip_key" not in names:
|
|
70
|
+
if "trip_id" not in names:
|
|
71
|
+
raise ValueError("stop events need a trip_key or trip_id column")
|
|
72
|
+
lf = lf.with_columns(pl.col("trip_id").cast(pl.Utf8).alias("trip_key"))
|
|
73
|
+
for c in ("arrival", "departure"):
|
|
74
|
+
if c in names:
|
|
75
|
+
lf = parse_ts(lf, c, ts_unit, tz)
|
|
76
|
+
else:
|
|
77
|
+
lf = lf.with_columns(pl.lit(None, dtype=UTC).alias(c))
|
|
78
|
+
if "arrival" not in names and "departure" not in names:
|
|
79
|
+
raise ValueError("stop events need arrival and/or departure")
|
|
80
|
+
t = pl.coalesce("departure", "arrival")
|
|
81
|
+
if "service_date" not in names:
|
|
82
|
+
lf = add_local_time(lf.with_columns(t.min().over("trip_key").alias("_t0")), tz, service_day_start,
|
|
83
|
+
ts_col="_t0").drop("_t0", "hour", "dow")
|
|
84
|
+
elif lf.collect_schema()["service_date"] == pl.Utf8:
|
|
85
|
+
sd = pl.col("service_date").str.replace_all("-", "")
|
|
86
|
+
lf = lf.with_columns(sd.str.strptime(pl.Date, "%Y%m%d", strict=False).alias("service_date"))
|
|
87
|
+
if "stop_sequence" not in names:
|
|
88
|
+
lf = lf.with_columns(t.rank("ordinal").over("trip_key").cast(pl.Int32).alias("stop_sequence"))
|
|
89
|
+
if "observed" not in names:
|
|
90
|
+
lf = lf.with_columns(pl.lit(True).alias("observed"))
|
|
91
|
+
lf = lf.with_columns(normalise_expr(pl.col("stop_id"), id_normaliser).alias("stop_id"))
|
|
92
|
+
if "route_id" in names:
|
|
93
|
+
lf = lf.with_columns(pl.col("route_id").cast(pl.Utf8))
|
|
94
|
+
if route is not None:
|
|
95
|
+
lf = lf.filter(pl.col("route_id") == str(route))
|
|
96
|
+
elif route is not None:
|
|
97
|
+
lf = lf.with_columns(pl.lit(str(route)).alias("route_id"))
|
|
98
|
+
return schema.conform(lf, STOP_EVENT).select(list(STOP_EVENT))
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""GTFS-RT VehiclePositions -> ``schema.OBSERVATION``.
|
|
2
|
+
|
|
3
|
+
Flattened tables (CSV / parquet) are read with their column names auto-detected: protobuf paths
|
|
4
|
+
(``vehicle.position.latitude``, ``vehicle.trip.trip_id``, ``vehicle.vehicle.id``,
|
|
5
|
+
``vehicle.timestamp`` ...), snake / camel variants, or plain ``latitude`` / ``lon`` / ``timestamp``
|
|
6
|
+
/ ``trip_id`` / ``route_id`` / ``vehicle_id`` / ``direction_id`` / ``bearing``.
|
|
7
|
+
Raw ``.pb`` FeedMessages need the ``gtfsrt`` extra (``gtfs-realtime-bindings``).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gzip
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import polars as pl
|
|
16
|
+
|
|
17
|
+
from .. import schema
|
|
18
|
+
from ..schema import OBSERVATION
|
|
19
|
+
|
|
20
|
+
try: # optional extra
|
|
21
|
+
from google.transit import gtfs_realtime_pb2 # type: ignore
|
|
22
|
+
HAS_BINDINGS = True
|
|
23
|
+
except Exception: # pragma: no cover - depends on the environment
|
|
24
|
+
gtfs_realtime_pb2 = None
|
|
25
|
+
HAS_BINDINGS = False
|
|
26
|
+
|
|
27
|
+
# canonical -> candidate suffixes of the normalised column name (dot separated, lower case)
|
|
28
|
+
_CANDIDATES: dict[str, list[str]] = {
|
|
29
|
+
"ts": ["vehicle.timestamp", "position.timestamp", "timestamp", "ts", "time", "datetime", "fix.time"],
|
|
30
|
+
"lat": ["position.latitude", "latitude", "lat"],
|
|
31
|
+
"lon": ["position.longitude", "longitude", "lon", "lng", "long"],
|
|
32
|
+
"bearing": ["position.bearing", "bearing", "heading"],
|
|
33
|
+
"trip_id": ["trip.trip.id", "trip.id"],
|
|
34
|
+
"route_id": ["trip.route.id", "route.id", "route"],
|
|
35
|
+
"direction_id": ["trip.direction.id", "direction.id", "direction"],
|
|
36
|
+
"vehicle_id": ["vehicle.vehicle.id", "vehicle.id", "vehicle.label", "vehicle"],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _norm(name: str) -> str:
|
|
41
|
+
n = re.sub(r"([a-z0-9])([A-Z])", r"\1.\2", name) # camelCase -> camel.Case
|
|
42
|
+
return re.sub(r"[^a-z0-9]+", ".", n.lower()).strip(".")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def detect_columns(names: list[str]) -> dict[str, str]:
|
|
46
|
+
"""Map canonical OBSERVATION names to source columns (canonical -> source)."""
|
|
47
|
+
normed = {c: _norm(c) for c in names if not _norm(c).startswith("header.")}
|
|
48
|
+
out: dict[str, str] = {}
|
|
49
|
+
used: set[str] = set()
|
|
50
|
+
for can, cands in _CANDIDATES.items():
|
|
51
|
+
for cand in cands:
|
|
52
|
+
hits = [c for c, n in normed.items() if c not in used and (n == cand or n.endswith("." + cand))]
|
|
53
|
+
if hits:
|
|
54
|
+
best = min(hits, key=lambda c: len(normed[c]))
|
|
55
|
+
out[can] = best
|
|
56
|
+
used.add(best)
|
|
57
|
+
break
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _guess_unit(lf: pl.LazyFrame, col: str) -> str:
|
|
62
|
+
dtype = lf.collect_schema()[col]
|
|
63
|
+
if dtype == pl.Utf8:
|
|
64
|
+
v = lf.select(pl.col(col)).drop_nulls().head(1).collect()[col]
|
|
65
|
+
if len(v) and not re.fullmatch(r"\s*\d+(\.\d+)?\s*", v[0]):
|
|
66
|
+
return "iso"
|
|
67
|
+
mx = lf.select(pl.col(col).cast(pl.Float64, strict=False).max()).collect().item()
|
|
68
|
+
elif dtype.is_numeric():
|
|
69
|
+
mx = lf.select(pl.col(col).max()).collect().item()
|
|
70
|
+
else:
|
|
71
|
+
return "s"
|
|
72
|
+
if mx is None:
|
|
73
|
+
return "s"
|
|
74
|
+
return "us" if mx > 1e14 else "ms" if mx > 1e11 else "s"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_flat(paths, route: str | None = None, tz: str = "Europe/Brussels",
|
|
78
|
+
columns: dict[str, str] | None = None, ts_unit: str | None = None) -> pl.LazyFrame:
|
|
79
|
+
"""Flattened VehiclePositions (CSV / parquet files or globs) -> OBSERVATION LazyFrame.
|
|
80
|
+
``columns`` overrides the detection (canonical -> source); ``ts_unit`` None = guessed."""
|
|
81
|
+
from .tabular import expand_paths, normalise_frame, scan_table
|
|
82
|
+
lf = scan_table(expand_paths(paths))
|
|
83
|
+
cols = detect_columns(lf.collect_schema().names())
|
|
84
|
+
cols.update(columns or {})
|
|
85
|
+
if "ts" not in cols:
|
|
86
|
+
raise ValueError("no timestamp column detected")
|
|
87
|
+
unit = ts_unit or _guess_unit(lf, cols["ts"])
|
|
88
|
+
return normalise_frame(lf, cols, unit, tz, "none", route)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_feed(content: bytes, route: str | None = None) -> pl.DataFrame:
|
|
92
|
+
"""One serialized FeedMessage -> OBSERVATION rows (vehicle entities only)."""
|
|
93
|
+
if not HAS_BINDINGS:
|
|
94
|
+
raise ImportError("parsing .pb needs the 'gtfsrt' extra: pip install 'microsegments[gtfsrt]'")
|
|
95
|
+
msg = gtfs_realtime_pb2.FeedMessage()
|
|
96
|
+
msg.ParseFromString(content)
|
|
97
|
+
head_ts = msg.header.timestamp if msg.header.HasField("timestamp") else None
|
|
98
|
+
rows = []
|
|
99
|
+
for ent in msg.entity:
|
|
100
|
+
if not ent.HasField("vehicle"):
|
|
101
|
+
continue
|
|
102
|
+
v = ent.vehicle
|
|
103
|
+
if not v.HasField("position"):
|
|
104
|
+
continue
|
|
105
|
+
ts = v.timestamp if v.HasField("timestamp") else head_ts
|
|
106
|
+
if ts is None:
|
|
107
|
+
continue
|
|
108
|
+
tr = v.trip if v.HasField("trip") else None
|
|
109
|
+
rid = tr.route_id if tr is not None and tr.route_id else None
|
|
110
|
+
if route is not None and rid != str(route):
|
|
111
|
+
continue
|
|
112
|
+
p = v.position
|
|
113
|
+
rows.append({
|
|
114
|
+
"ts": int(ts) * 1_000_000,
|
|
115
|
+
"route_id": rid,
|
|
116
|
+
"vehicle_id": (v.vehicle.id or v.vehicle.label or ent.id) if v.HasField("vehicle") else ent.id,
|
|
117
|
+
"trip_id": (tr.trip_id or None) if tr is not None else None,
|
|
118
|
+
"direction_id": tr.direction_id if tr is not None and tr.HasField("direction_id") else None,
|
|
119
|
+
"lat": p.latitude, "lon": p.longitude,
|
|
120
|
+
"bearing": p.bearing if p.HasField("bearing") else None,
|
|
121
|
+
})
|
|
122
|
+
df = pl.DataFrame(rows, schema={"ts": pl.Int64, "route_id": pl.Utf8, "vehicle_id": pl.Utf8,
|
|
123
|
+
"trip_id": pl.Utf8, "direction_id": pl.Int8, "lat": pl.Float64,
|
|
124
|
+
"lon": pl.Float64, "bearing": pl.Float32})
|
|
125
|
+
df = df.with_columns(pl.col("ts").cast(pl.Datetime("us")).dt.replace_time_zone("UTC"))
|
|
126
|
+
return schema.conform(df, OBSERVATION, schema.OBSERVATION_REQUIRED)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_pb(paths, route: str | None = None) -> pl.DataFrame:
|
|
130
|
+
"""``.pb`` / ``.pb.gz`` FeedMessage files -> OBSERVATION, duplicates (same vehicle and ts) dropped."""
|
|
131
|
+
from .tabular import expand_paths
|
|
132
|
+
frames = []
|
|
133
|
+
for f in expand_paths(paths):
|
|
134
|
+
b = Path(f).read_bytes()
|
|
135
|
+
if f.endswith(".gz"):
|
|
136
|
+
b = gzip.decompress(b)
|
|
137
|
+
frames.append(parse_feed(b, route))
|
|
138
|
+
if not frames:
|
|
139
|
+
return schema.empty(OBSERVATION)
|
|
140
|
+
return pl.concat(frames).unique(["ts", "vehicle_id", "lat", "lon"], maintain_order=True).sort("ts")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_feed(obs: pl.DataFrame) -> bytes:
|
|
144
|
+
"""OBSERVATION (lat/lon) -> serialized FeedMessage (for tests and examples)."""
|
|
145
|
+
if not HAS_BINDINGS:
|
|
146
|
+
raise ImportError("needs the 'gtfsrt' extra")
|
|
147
|
+
msg = gtfs_realtime_pb2.FeedMessage()
|
|
148
|
+
msg.header.gtfs_realtime_version = "2.0"
|
|
149
|
+
if len(obs):
|
|
150
|
+
msg.header.timestamp = int(obs["ts"].dt.epoch("s").max())
|
|
151
|
+
for i, r in enumerate(obs.iter_rows(named=True)):
|
|
152
|
+
e = msg.entity.add()
|
|
153
|
+
e.id = str(i)
|
|
154
|
+
v = e.vehicle
|
|
155
|
+
v.timestamp = int(r["ts"].timestamp())
|
|
156
|
+
v.position.latitude = r["lat"]
|
|
157
|
+
v.position.longitude = r["lon"]
|
|
158
|
+
if r.get("bearing") is not None:
|
|
159
|
+
v.position.bearing = r["bearing"]
|
|
160
|
+
if r.get("vehicle_id") is not None:
|
|
161
|
+
v.vehicle.id = r["vehicle_id"]
|
|
162
|
+
if r.get("trip_id") is not None:
|
|
163
|
+
v.trip.trip_id = r["trip_id"]
|
|
164
|
+
if r.get("route_id") is not None:
|
|
165
|
+
v.trip.route_id = r["route_id"]
|
|
166
|
+
if r.get("direction_id") is not None:
|
|
167
|
+
v.trip.direction_id = int(r["direction_id"])
|
|
168
|
+
return msg.SerializeToString()
|
microsegments/io/ids.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Stop / terminus id normalisation, so feed ids match GTFS ids.
|
|
2
|
+
|
|
3
|
+
``"leading_digits"`` mirrors the prototype's ``digits()``: the leading digits without their
|
|
4
|
+
leading zeros ("0626" -> "626", "5272F" -> "5272", "0" -> "0"); not starting with a digit -> null.
|
|
5
|
+
``"none"`` keeps the id as text. Any other string is a regex whose first group is kept.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
import polars as pl
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalise_expr(e: pl.Expr, normaliser: str = "leading_digits") -> pl.Expr:
|
|
15
|
+
e = e.cast(pl.Utf8).str.strip_chars()
|
|
16
|
+
if normaliser in (None, "", "none"):
|
|
17
|
+
return e
|
|
18
|
+
if normaliser == "leading_digits":
|
|
19
|
+
return e.str.extract(r"^0*(\d+)", 1)
|
|
20
|
+
if re.compile(normaliser).groups < 1:
|
|
21
|
+
raise ValueError(f"id normaliser regex needs one group: {normaliser!r}")
|
|
22
|
+
return e.str.extract(normaliser, 1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalise_py(value, normaliser: str = "leading_digits") -> str | None:
|
|
26
|
+
"""Scalar version of ``normalise_expr``."""
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
s = str(value).strip()
|
|
30
|
+
if normaliser in (None, "", "none"):
|
|
31
|
+
return s
|
|
32
|
+
pat = r"^0*(\d+)" if normaliser == "leading_digits" else normaliser
|
|
33
|
+
m = re.search(pat, s)
|
|
34
|
+
return m.group(1) if m else None
|
microsegments/io/stib.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""STIB ``vehicle-distance`` feed (MobilityTwin ``stib/vehicle-distance``).
|
|
2
|
+
|
|
3
|
+
Raw snapshot (one every ~20 s, all lines)::
|
|
4
|
+
|
|
5
|
+
[{"lineId": "55", "directionId": "9943", "pointId": "5766", "distanceFromPoint": 95}, ...]
|
|
6
|
+
|
|
7
|
+
(or the same list under a ``data`` / ``results`` key). A row is a vehicle: line, terminus code
|
|
8
|
+
(``directionId``, a stop code, sometimes not a GTFS stop such as 9943), last stop passed and metres
|
|
9
|
+
travelled since it. No vehicle id.
|
|
10
|
+
|
|
11
|
+
Compact per-day parquet (prototype ``data2025/stib/vd55`` and the platform's ``raw/vd``)::
|
|
12
|
+
|
|
13
|
+
<day>.parquet ts:int64 epoch s, [line], dir, point, dist
|
|
14
|
+
<day>.snaps.parquet ts:int64 epoch s, same|frozen:bool, [n_rows]
|
|
15
|
+
|
|
16
|
+
Rows of frozen snapshots (content identical to the previous one) are not stored: the feed did not
|
|
17
|
+
move, so they carry no new observation. Snapshots keep them, flagged ``frozen``.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import glob
|
|
22
|
+
import gzip
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Iterable
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import polars as pl
|
|
30
|
+
|
|
31
|
+
from .. import schema
|
|
32
|
+
from ..schema import OBSERVATION, SNAPSHOT, UTC
|
|
33
|
+
from .ids import normalise_expr
|
|
34
|
+
|
|
35
|
+
_FNAME_TS = re.compile(r"(\d{4})-(\d{2})-(\d{2})T(\d{2})-?:?(\d{2})-?:?(\d{2})")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _records(data) -> list[dict]:
|
|
39
|
+
if isinstance(data, (bytes, str)):
|
|
40
|
+
data = json.loads(data)
|
|
41
|
+
if isinstance(data, dict):
|
|
42
|
+
data = data.get("data") or data.get("results") or []
|
|
43
|
+
return list(data or [])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _epoch_us(ts) -> int:
|
|
47
|
+
"""int/float epoch seconds, datetime or ISO string -> epoch microseconds."""
|
|
48
|
+
import datetime as dt
|
|
49
|
+
if isinstance(ts, (int, float)):
|
|
50
|
+
return int(round(float(ts) * 1_000_000))
|
|
51
|
+
if isinstance(ts, str):
|
|
52
|
+
ts = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
53
|
+
if isinstance(ts, dt.datetime):
|
|
54
|
+
if ts.tzinfo is None:
|
|
55
|
+
ts = ts.replace(tzinfo=dt.timezone.utc)
|
|
56
|
+
return int(round(ts.timestamp() * 1_000_000))
|
|
57
|
+
raise TypeError(f"unsupported timestamp {ts!r}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _rows_frame(recs: list[dict], ts_us: int) -> pl.DataFrame:
|
|
61
|
+
return pl.DataFrame({
|
|
62
|
+
"ts": [ts_us] * len(recs),
|
|
63
|
+
"line": [None if r.get("lineId") is None else str(r.get("lineId")) for r in recs],
|
|
64
|
+
"dir": [None if r.get("directionId") is None else str(r.get("directionId")) for r in recs],
|
|
65
|
+
"point": [None if r.get("pointId") is None else str(r.get("pointId")) for r in recs],
|
|
66
|
+
"dist": [r.get("distanceFromPoint") for r in recs],
|
|
67
|
+
}, schema={"ts": pl.Int64, "line": pl.Utf8, "dir": pl.Utf8, "point": pl.Utf8, "dist": pl.Float64})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_snapshots(snapshots: Iterable[tuple[object, object]], route: str | list[str] | None = None,
|
|
71
|
+
id_normaliser: str = "leading_digits") -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
72
|
+
"""Parse raw snapshots ``(ts, data)`` in time order -> (OBSERVATION, SNAPSHOT) frames.
|
|
73
|
+
|
|
74
|
+
``ts``: epoch seconds, datetime (naive = UTC) or ISO string; ``data``: decoded JSON, bytes or str.
|
|
75
|
+
``route``: keep these lineIds only (None: all lines). ``n_rows`` counts every row of the snapshot.
|
|
76
|
+
A snapshot whose content equals the previous one is ``frozen`` and contributes no rows."""
|
|
77
|
+
routes = None if route is None else {str(route)} if isinstance(route, (str, int)) else {str(r) for r in route}
|
|
78
|
+
frames, snaps = [], []
|
|
79
|
+
prev = None
|
|
80
|
+
for ts, data in snapshots:
|
|
81
|
+
raw = data if isinstance(data, (bytes, str)) else json.dumps(data, sort_keys=True)
|
|
82
|
+
digest = hashlib.sha1(raw.encode() if isinstance(raw, str) else raw).digest()
|
|
83
|
+
recs = _records(data)
|
|
84
|
+
ts_us = _epoch_us(ts)
|
|
85
|
+
frozen = digest == prev
|
|
86
|
+
prev = digest
|
|
87
|
+
snaps.append((ts_us, len(recs), frozen))
|
|
88
|
+
if frozen:
|
|
89
|
+
continue
|
|
90
|
+
if routes is not None:
|
|
91
|
+
recs = [r for r in recs if str(r.get("lineId")) in routes]
|
|
92
|
+
if recs:
|
|
93
|
+
frames.append(_rows_frame(recs, ts_us))
|
|
94
|
+
rows = pl.concat(frames) if frames else _rows_frame([], 0)
|
|
95
|
+
obs = compact_to_observations(rows.lazy(), id_normaliser=id_normaliser, ts_unit="us").collect()
|
|
96
|
+
sn = pl.DataFrame(snaps, schema={"ts": pl.Int64, "n_rows": pl.UInt32, "frozen": pl.Boolean}, orient="row")
|
|
97
|
+
sn = sn.with_columns(pl.col("ts").cast(pl.Datetime("us")).dt.replace_time_zone("UTC")).cast(SNAPSHOT)
|
|
98
|
+
return obs, sn
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ts_from_filename(path: str | Path) -> float | None:
|
|
102
|
+
"""MobilityTwin download name ``2025-02-17T04-00-00Z.json`` (UTC) -> epoch seconds."""
|
|
103
|
+
import datetime as dt
|
|
104
|
+
m = _FNAME_TS.search(Path(path).name)
|
|
105
|
+
if not m:
|
|
106
|
+
return None
|
|
107
|
+
y, mo, d, h, mi, s = map(int, m.groups())
|
|
108
|
+
return dt.datetime(y, mo, d, h, mi, s, tzinfo=dt.timezone.utc).timestamp()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_json_snapshots(paths: str | Path | list, route: str | list[str] | None = None,
|
|
112
|
+
id_normaliser: str = "leading_digits") -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
113
|
+
"""Raw JSON snapshot files (``.json`` / ``.json.gz``, globs allowed), time from the file name
|
|
114
|
+
(MobilityTwin layout) -> (OBSERVATION, SNAPSHOT)."""
|
|
115
|
+
files = _expand(paths)
|
|
116
|
+
stamped = []
|
|
117
|
+
for f in files:
|
|
118
|
+
t = ts_from_filename(f)
|
|
119
|
+
if t is None:
|
|
120
|
+
raise ValueError(f"no UTC timestamp in file name {f} (expected e.g. 2025-02-17T04-00-00Z.json)")
|
|
121
|
+
stamped.append((t, f))
|
|
122
|
+
stamped.sort()
|
|
123
|
+
|
|
124
|
+
def gen():
|
|
125
|
+
for t, f in stamped:
|
|
126
|
+
b = Path(f).read_bytes()
|
|
127
|
+
if str(f).endswith(".gz"):
|
|
128
|
+
b = gzip.decompress(b)
|
|
129
|
+
yield t, b
|
|
130
|
+
return parse_snapshots(gen(), route=route, id_normaliser=id_normaliser)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def compact_to_observations(lf: pl.LazyFrame, route_id: str | None = None,
|
|
134
|
+
id_normaliser: str = "leading_digits", ts_unit: str = "s") -> pl.LazyFrame:
|
|
135
|
+
"""Compact rows (ts, [line], dir, point, dist) -> OBSERVATION. ``route_id`` fills a missing
|
|
136
|
+
``line`` column (prototype files hold one line) or filters an existing one."""
|
|
137
|
+
cols = lf.collect_schema().names()
|
|
138
|
+
factor = {"s": 1_000_000, "ms": 1_000, "us": 1}[ts_unit]
|
|
139
|
+
if "line" in cols:
|
|
140
|
+
route = pl.col("line").cast(pl.Utf8)
|
|
141
|
+
if route_id is not None:
|
|
142
|
+
lf = lf.filter(pl.col("line").cast(pl.Utf8) == str(route_id))
|
|
143
|
+
elif route_id is not None:
|
|
144
|
+
route = pl.lit(str(route_id), dtype=pl.Utf8)
|
|
145
|
+
else:
|
|
146
|
+
raise ValueError("compact STIB rows have no 'line' column: pass route_id")
|
|
147
|
+
out = lf.select(
|
|
148
|
+
(pl.col("ts").cast(pl.Int64) * factor).cast(pl.Datetime("us")).dt.replace_time_zone("UTC").alias("ts"),
|
|
149
|
+
route.alias("route_id"),
|
|
150
|
+
normalise_expr(pl.col("dir"), id_normaliser).alias("terminus_id"),
|
|
151
|
+
normalise_expr(pl.col("point"), id_normaliser).alias("stop_id"),
|
|
152
|
+
pl.col("dist").cast(pl.Float32).alias("dist_m"),
|
|
153
|
+
)
|
|
154
|
+
return schema.conform(out, OBSERVATION, schema.OBSERVATION_REQUIRED)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def observations_to_compact(obs: pl.DataFrame | pl.LazyFrame) -> pl.DataFrame | pl.LazyFrame:
|
|
158
|
+
"""OBSERVATION (linear) -> compact columns ts:int64 s, line, dir, point, dist:int64."""
|
|
159
|
+
return obs.select(
|
|
160
|
+
pl.col("ts").dt.epoch("s").alias("ts"),
|
|
161
|
+
pl.col("route_id").alias("line"),
|
|
162
|
+
pl.col("terminus_id").alias("dir"),
|
|
163
|
+
pl.col("stop_id").alias("point"),
|
|
164
|
+
pl.col("dist_m").round(0).cast(pl.Int64).alias("dist"),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def snapshots_to_compact(snaps: pl.DataFrame | pl.LazyFrame) -> pl.DataFrame | pl.LazyFrame:
|
|
169
|
+
return snaps.select(pl.col("ts").dt.epoch("s").alias("ts"), pl.col("frozen"), pl.col("n_rows"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _expand(paths) -> list[str]:
|
|
173
|
+
if isinstance(paths, (str, Path)):
|
|
174
|
+
paths = [paths]
|
|
175
|
+
out: list[str] = []
|
|
176
|
+
for p in paths:
|
|
177
|
+
p = str(p)
|
|
178
|
+
if Path(p).is_dir():
|
|
179
|
+
out += sorted(str(x) for x in Path(p).rglob("*") if x.is_file())
|
|
180
|
+
elif any(ch in p for ch in "*?["):
|
|
181
|
+
out += sorted(glob.glob(p, recursive=True))
|
|
182
|
+
else:
|
|
183
|
+
out.append(p)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def split_compact_paths(paths) -> tuple[list[str], list[str]]:
|
|
188
|
+
"""Separate row files from snapshot files (``*.snaps.parquet`` or ``snaps.parquet``)."""
|
|
189
|
+
files = [f for f in _expand(paths) if f.endswith(".parquet")]
|
|
190
|
+
snaps = [f for f in files if Path(f).name.endswith("snaps.parquet")]
|
|
191
|
+
rows = [f for f in files if f not in set(snaps)]
|
|
192
|
+
return rows, snaps
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_compact(paths, route_id: str | None = None, id_normaliser: str = "leading_digits",
|
|
196
|
+
) -> tuple[pl.LazyFrame, pl.LazyFrame | None]:
|
|
197
|
+
"""Compact per-day parquet (files, directories or globs; row and ``snaps`` files may be mixed)
|
|
198
|
+
-> (OBSERVATION LazyFrame, SNAPSHOT LazyFrame or None if no snaps file)."""
|
|
199
|
+
rows, snaps = split_compact_paths(paths)
|
|
200
|
+
if not rows:
|
|
201
|
+
raise FileNotFoundError(f"no STIB compact parquet in {paths}")
|
|
202
|
+
lf = pl.concat([pl.scan_parquet(f) for f in rows], how="diagonal_relaxed")
|
|
203
|
+
obs = compact_to_observations(lf, route_id=route_id, id_normaliser=id_normaliser)
|
|
204
|
+
return obs, (read_compact_snapshots(snaps) if snaps else None)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def read_compact_snapshots(paths) -> pl.LazyFrame:
|
|
208
|
+
files = _expand(paths)
|
|
209
|
+
lf = pl.concat([pl.scan_parquet(f) for f in files], how="diagonal_relaxed")
|
|
210
|
+
cols = lf.collect_schema().names()
|
|
211
|
+
frozen = pl.col("frozen") if "frozen" in cols else pl.col("same") if "same" in cols else pl.lit(False)
|
|
212
|
+
n_rows = pl.col("n_rows") if "n_rows" in cols else pl.lit(None)
|
|
213
|
+
return lf.select(
|
|
214
|
+
(pl.col("ts").cast(pl.Int64) * 1_000_000).cast(pl.Datetime("us")).dt.replace_time_zone("UTC").cast(UTC).alias("ts"),
|
|
215
|
+
n_rows.cast(pl.UInt32).alias("n_rows"),
|
|
216
|
+
frozen.cast(pl.Boolean).fill_null(False).alias("frozen"),
|
|
217
|
+
).unique("ts", keep="first").sort("ts")
|