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,337 @@
|
|
|
1
|
+
"""Hotspots: stretches of segments where vehicles linger more than at the reference hours.
|
|
2
|
+
|
|
3
|
+
Per direction, on the display pattern, for every bin (segment x hour, plus the am / pm bands):
|
|
4
|
+
|
|
5
|
+
1. Day bootstrap (B draws, Rao-Wu rescaled, weekday-stratified, whole days resampled): numerator, passages and
|
|
6
|
+
reference are recomputed from the same resampled days (``Analysis.estimate`` with day weights).
|
|
7
|
+
2. Candidate bin when
|
|
8
|
+
* the lower bound of the percentile CI (``level``) of ``excess_per_passage * tick_s`` > ``theta_s``;
|
|
9
|
+
* daily excess (day's obs/passages minus the full-sample reference) is positive on
|
|
10
|
+
≥ ``min_persistence`` of the days with passages;
|
|
11
|
+
* it passes Benjamini-Hochberg at ``q`` over all tested bins. p-values are one-sided, from the
|
|
12
|
+
bootstrap standard error (z = (x - θ) / se): percentile p-values cannot go below 1/(B+1), which
|
|
13
|
+
BH over thousands of bins never accepts.
|
|
14
|
+
3. Temporal persistence: an hourly candidate counts only within a run of ≥ 2 adjacent candidate hours;
|
|
15
|
+
a band candidate (am / pm) counts for all its hours.
|
|
16
|
+
4. Stretches: candidate segments merge along the pattern with a ``gap``-bin tolerance, and never
|
|
17
|
+
across a stop / running zone border.
|
|
18
|
+
5. Classes (``fixed_score`` F and ``peak_ratio`` are returned as extra columns):
|
|
19
|
+
* F = q20 over day hours 6-20 of the stretch occupancy per metre (obs / passage / m), divided by the
|
|
20
|
+
median of the same quantity over non-candidate segments of the same zone within ±500 m (the
|
|
21
|
+
"free-flow" level of the day; high when the delay never goes away during the day);
|
|
22
|
+
* peak_ratio = mean summed excess over peak hours (am / pm bands) / mean over the other day hours;
|
|
23
|
+
* infrastructure: F ≥ ``f_min`` and peak_ratio < ``peak_ratio``; congestion: peak_ratio ≥
|
|
24
|
+
``peak_ratio`` and F < ``f_min``; mixed otherwise.
|
|
25
|
+
Note: a delay that is also present at the reference hours cancels in the excess and is not detected.
|
|
26
|
+
|
|
27
|
+
Stretches are ranked by excess observations per day (Σ over stretch hours and segments of
|
|
28
|
+
obs - ref * passages, divided by the number of days).
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
import polars as pl
|
|
37
|
+
|
|
38
|
+
from .metrics import BANDS, BAND_HOUR, Analysis, DirData
|
|
39
|
+
from .schema import HOTSPOT, conform
|
|
40
|
+
|
|
41
|
+
PEAK_BANDS = ("am", "pm")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class BinStats:
|
|
46
|
+
"""Per-bin statistics of one direction, on the display pattern (columns = output hours + am + pm)."""
|
|
47
|
+
dd: DirData
|
|
48
|
+
ks: np.ndarray # (Kd,) K indices, display order
|
|
49
|
+
seg_idx: np.ndarray # (Kd,)
|
|
50
|
+
x0: np.ndarray
|
|
51
|
+
length: np.ndarray
|
|
52
|
+
zone: np.ndarray
|
|
53
|
+
cols: np.ndarray # (Hc,) labels: hours, then -1 (am), -2 (pm)
|
|
54
|
+
x: np.ndarray # (Hc,Kd) excess per passage (obs / vehicle)
|
|
55
|
+
est: dict # full-sample estimate restricted to (Hc|all hours, Kd), see _cut
|
|
56
|
+
ci_lo: np.ndarray
|
|
57
|
+
ci_hi: np.ndarray
|
|
58
|
+
se: np.ndarray
|
|
59
|
+
p: np.ndarray
|
|
60
|
+
reject: np.ndarray
|
|
61
|
+
persistence: np.ndarray
|
|
62
|
+
candidate: np.ndarray # per-bin rule (CI, persistence, BH)
|
|
63
|
+
draws: np.ndarray # (B,Hc,Kd) bootstrap excess per passage
|
|
64
|
+
daily: np.ndarray # (D,Hc,Kd) daily excess (nan where no passage)
|
|
65
|
+
theta: float # threshold in obs per passage
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def bootstrap_weights(dow: np.ndarray, B: int, rng: np.random.Generator, stratified: bool = True) -> np.ndarray:
|
|
69
|
+
"""(B, D) day weights of a Rao-Wu rescaled day bootstrap (within each weekday when ``stratified``):
|
|
70
|
+
n_h - 1 days drawn with replacement in a stratum of n_h days, multiplicities scaled by
|
|
71
|
+
n_h / (n_h - 1). This removes the (n_h - 1) / n_h variance shrinkage of the naive bootstrap,
|
|
72
|
+
which matters with few days per weekday. A stratum with one day keeps weight 1."""
|
|
73
|
+
D = len(dow)
|
|
74
|
+
W = np.zeros((B, D), np.float32)
|
|
75
|
+
groups = [np.flatnonzero(dow == w) for w in np.unique(dow)] if stratified else [np.arange(D)]
|
|
76
|
+
rows = np.arange(B)[:, None]
|
|
77
|
+
for g in groups:
|
|
78
|
+
n = len(g)
|
|
79
|
+
if n == 0:
|
|
80
|
+
continue
|
|
81
|
+
if n == 1:
|
|
82
|
+
W[:, g] = 1.0
|
|
83
|
+
continue
|
|
84
|
+
pick = g[rng.integers(0, n, (B, n - 1))]
|
|
85
|
+
np.add.at(W, (np.broadcast_to(rows, pick.shape), pick), n / (n - 1))
|
|
86
|
+
return W
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _bh(p: np.ndarray, q: float) -> np.ndarray:
|
|
90
|
+
flat = p.reshape(-1)
|
|
91
|
+
ok = np.isfinite(flat)
|
|
92
|
+
out = np.zeros(flat.shape, bool)
|
|
93
|
+
pv = flat[ok]
|
|
94
|
+
m = len(pv)
|
|
95
|
+
if m == 0:
|
|
96
|
+
return out.reshape(p.shape)
|
|
97
|
+
order = np.argsort(pv)
|
|
98
|
+
passed = pv[order] <= q * np.arange(1, m + 1) / m
|
|
99
|
+
if passed.any():
|
|
100
|
+
kmax = np.flatnonzero(passed).max()
|
|
101
|
+
sel = np.zeros(m, bool)
|
|
102
|
+
sel[order[: kmax + 1]] = True
|
|
103
|
+
out[np.flatnonzero(ok)[sel]] = True
|
|
104
|
+
return out.reshape(p.shape)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_erfc = np.frompyfunc(math.erfc, 1, 1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def bin_stats(an: Analysis, direction_id: int, *, B: int | None = None, theta_s: float = 2.0, q: float = 0.1,
|
|
111
|
+
level: float = 0.95, min_persistence: float = 0.6, seed: int = 0, stratified: bool = True,
|
|
112
|
+
chunk: int = 50) -> BinStats:
|
|
113
|
+
dd = an.dirs[direction_id]
|
|
114
|
+
B = an.params.bootstrap if B is None else B
|
|
115
|
+
tick = an.params.tick_s
|
|
116
|
+
theta = theta_s / tick
|
|
117
|
+
ks = dd.pattern_segs[dd.display_uid]
|
|
118
|
+
out_hours = set(an.hours)
|
|
119
|
+
ci = np.array([i for i, c in enumerate(dd.cols) if (c >= 0 and int(c) in out_hours)]
|
|
120
|
+
+ [int(np.flatnonzero(dd.cols == BAND_HOUR[b])[0]) for b in PEAK_BANDS])
|
|
121
|
+
cols = dd.cols[ci]
|
|
122
|
+
|
|
123
|
+
est = an.estimate(direction_id)
|
|
124
|
+
x = est["excess_per_passage"][0][ci][:, ks]
|
|
125
|
+
|
|
126
|
+
rng = np.random.default_rng(seed)
|
|
127
|
+
W = bootstrap_weights(dd.dow, B, rng, stratified)
|
|
128
|
+
draws = np.empty((B, len(ci), len(ks)), np.float32)
|
|
129
|
+
for a in range(0, B, chunk):
|
|
130
|
+
e = an.estimate(direction_id, W[a:a + chunk])
|
|
131
|
+
draws[a:a + chunk] = e["excess_per_passage"][:, ci][:, :, ks]
|
|
132
|
+
alpha = 1 - level
|
|
133
|
+
with np.errstate(all="ignore"):
|
|
134
|
+
import warnings
|
|
135
|
+
with warnings.catch_warnings():
|
|
136
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
137
|
+
lo, hi = np.nanquantile(draws, [alpha / 2, 1 - alpha / 2], axis=0)
|
|
138
|
+
se = np.nanstd(draws, axis=0, ddof=1)
|
|
139
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
140
|
+
z = (x - theta) / se
|
|
141
|
+
z = np.where(se > 0, z, np.where(x > theta, np.inf, -np.inf))
|
|
142
|
+
p = 0.5 * _erfc(z / math.sqrt(2)).astype(float)
|
|
143
|
+
p[~np.isfinite(x)] = np.nan
|
|
144
|
+
reject = _bh(p, q)
|
|
145
|
+
|
|
146
|
+
X = dd.stacked()
|
|
147
|
+
NM, PM = X["NM"][:, ci][:, :, ks], X["PM"][:, ci][:, :, ks]
|
|
148
|
+
ref = est["ref"][0][ks]
|
|
149
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
150
|
+
daily = np.where(PM > 0, NM / PM - ref[None, None, :], np.nan)
|
|
151
|
+
npos = (daily > 0).sum(0)
|
|
152
|
+
nval = np.isfinite(daily).sum(0)
|
|
153
|
+
pers = np.where(nval > 0, npos / nval, np.nan)
|
|
154
|
+
cand = (lo > theta) & (pers >= min_persistence) & reject
|
|
155
|
+
cut = {k: v[0][ci][:, ks] for k, v in est.items() if k != "ref"}
|
|
156
|
+
cut["ref"] = ref
|
|
157
|
+
cut["all"] = {k: v[0][:, ks] for k, v in est.items() if k != "ref"}
|
|
158
|
+
u = dd.display_uid
|
|
159
|
+
return BinStats(dd=dd, ks=ks, seg_idx=dd.pattern_seg_idx[u], x0=dd.pattern_x0[u], length=dd.seg_len[ks],
|
|
160
|
+
zone=dd.seg_zone[ks], cols=cols, x=x, est=cut, ci_lo=lo, ci_hi=hi, se=se, p=p, reject=reject,
|
|
161
|
+
persistence=pers, candidate=cand, draws=draws, daily=daily, theta=theta)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def bins_frame(st: BinStats) -> pl.DataFrame:
|
|
165
|
+
Hc, Kd = st.x.shape
|
|
166
|
+
return pl.DataFrame({
|
|
167
|
+
"pattern_uid": [st.dd.display_uid] * (Hc * Kd),
|
|
168
|
+
"direction_id": np.full(Hc * Kd, st.dd.direction_id, np.int8),
|
|
169
|
+
"seg_idx": np.tile(st.seg_idx, Hc),
|
|
170
|
+
"hour": np.repeat(st.cols, Kd).astype(np.int8),
|
|
171
|
+
"excess_per_passage": st.x.reshape(-1),
|
|
172
|
+
"ci_lo": st.ci_lo.reshape(-1), "ci_hi": st.ci_hi.reshape(-1), "se": st.se.reshape(-1),
|
|
173
|
+
"p": st.p.reshape(-1), "bh": st.reject.reshape(-1), "persistence": st.persistence.reshape(-1),
|
|
174
|
+
"candidate": st.candidate.reshape(-1),
|
|
175
|
+
}, nan_to_null=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def seg_hours(st: BinStats) -> list[set[int]]:
|
|
179
|
+
"""Hours per segment that pass the temporal rule (≥ 2 adjacent candidate hours, or a whole band)."""
|
|
180
|
+
hcols = np.flatnonzero(st.cols >= 0)
|
|
181
|
+
hrs = st.cols[hcols]
|
|
182
|
+
out = []
|
|
183
|
+
for j in range(st.x.shape[1]):
|
|
184
|
+
c = st.candidate[hcols, j]
|
|
185
|
+
keep: set[int] = set()
|
|
186
|
+
for i in range(len(hrs)):
|
|
187
|
+
if not c[i]:
|
|
188
|
+
continue
|
|
189
|
+
prev = i > 0 and c[i - 1] and hrs[i - 1] == hrs[i] - 1
|
|
190
|
+
nxt = i + 1 < len(hrs) and c[i + 1] and hrs[i + 1] == hrs[i] + 1
|
|
191
|
+
if prev or nxt:
|
|
192
|
+
keep.add(int(hrs[i]))
|
|
193
|
+
for b in PEAK_BANDS:
|
|
194
|
+
bi = np.flatnonzero(st.cols == BAND_HOUR[b])
|
|
195
|
+
if len(bi) and st.candidate[bi[0], j]:
|
|
196
|
+
a, z = BANDS[b]
|
|
197
|
+
keep |= {h for h in range(a, z) if h in set(hrs.tolist())}
|
|
198
|
+
out.append(keep)
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def stretches(st: BinStats, gap: int = 1) -> list[tuple[int, int, set[int]]]:
|
|
203
|
+
"""[(j0, j1, hours)] in display order (inclusive), merged with a ``gap``-bin tolerance, split at zones."""
|
|
204
|
+
hs = seg_hours(st)
|
|
205
|
+
out: list[list] = []
|
|
206
|
+
for j, h in enumerate(hs):
|
|
207
|
+
if not h:
|
|
208
|
+
continue
|
|
209
|
+
if out:
|
|
210
|
+
j0, j1, hh = out[-1]
|
|
211
|
+
if j - j1 <= gap + 1 and all(st.zone[i] == st.zone[j] for i in range(j1, j + 1)):
|
|
212
|
+
out[-1] = [j0, j, hh | h]
|
|
213
|
+
continue
|
|
214
|
+
out.append([j, j, set(h)])
|
|
215
|
+
return [tuple(s) for s in out]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def hotspots(an: Analysis, *, B: int | None = None, theta_s: float = 2.0, q: float = 0.1, level: float = 0.95,
|
|
219
|
+
min_persistence: float = 0.6, gap: int = 1, f_min: float = 1.5, peak_ratio: float = 2.0,
|
|
220
|
+
seed: int = 0, stratified: bool = True, links: pl.DataFrame | None = None,
|
|
221
|
+
stats: dict[int, BinStats] | None = None) -> pl.DataFrame:
|
|
222
|
+
"""Hotspot stretches for every direction of ``an`` (schema.HOTSPOT + extra columns
|
|
223
|
+
excess_obs_per_day, fixed_score, peak_ratio, seg_keys). ``links`` (schema.LINK) names the stops."""
|
|
224
|
+
rows = []
|
|
225
|
+
names = {}
|
|
226
|
+
if links is not None:
|
|
227
|
+
for r in links.select("link_key", "from_name", "to_name").iter_rows():
|
|
228
|
+
names.setdefault(r[0], (r[1], r[2]))
|
|
229
|
+
for dirid in an.dirs:
|
|
230
|
+
if len(an.dirs[dirid].days) == 0:
|
|
231
|
+
continue
|
|
232
|
+
st = stats[dirid] if stats is not None and dirid in stats else bin_stats(
|
|
233
|
+
an, dirid, B=B, theta_s=theta_s, q=q, level=level, min_persistence=min_persistence, seed=seed,
|
|
234
|
+
stratified=stratified)
|
|
235
|
+
rows += _describe(an, st, stretches(st, gap), names, level, f_min, peak_ratio)
|
|
236
|
+
schema = {**HOTSPOT, "excess_obs_per_day": pl.Float32, "fixed_score": pl.Float32, "peak_ratio": pl.Float32,
|
|
237
|
+
"seg_keys": pl.List(pl.Utf8)}
|
|
238
|
+
if not rows:
|
|
239
|
+
return pl.DataFrame(schema=schema)
|
|
240
|
+
df = pl.DataFrame(rows, schema=schema, orient="row")
|
|
241
|
+
df = df.sort(["direction_id", "excess_obs_per_day"], descending=[False, True])
|
|
242
|
+
df = df.with_columns(pl.int_range(1, pl.len() + 1).over("direction_id").cast(pl.Int16).alias("rank"))
|
|
243
|
+
return conform(df, schema)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _describe(an, st: BinStats, strs, names, level, f_min, peak_ratio_min) -> list[tuple]:
|
|
247
|
+
dd = st.dd
|
|
248
|
+
hcols = np.flatnonzero(st.cols >= 0)
|
|
249
|
+
hrs = st.cols[hcols]
|
|
250
|
+
alpha = 1 - level
|
|
251
|
+
allc = st.est["all"]
|
|
252
|
+
hour_cols_all = dd.hour_cols
|
|
253
|
+
hours_all = dd.cols[hour_cols_all]
|
|
254
|
+
day_sel = hour_cols_all[(hours_all >= 6) & (hours_all <= 20)]
|
|
255
|
+
opp_all = allc["obs_per_passage"] # (Hx, Kd)
|
|
256
|
+
cand = np.zeros(len(st.ks), bool)
|
|
257
|
+
for j0, j1, _ in strs:
|
|
258
|
+
cand[j0:j1 + 1] = True
|
|
259
|
+
with np.errstate(all="ignore"):
|
|
260
|
+
import warnings
|
|
261
|
+
with warnings.catch_warnings():
|
|
262
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
263
|
+
seg_ff = np.nanquantile(opp_all[day_sel] / st.length[None, :], 0.2, axis=0)
|
|
264
|
+
mid = st.x0 + st.length / 2
|
|
265
|
+
D = max(len(dd.days), 1)
|
|
266
|
+
peak_hours = {h for b in PEAK_BANDS for h in range(*BANDS[b])}
|
|
267
|
+
obs_h, pas_h, ref = st.est["obs"], st.est["passages"], st.est["ref"]
|
|
268
|
+
out = []
|
|
269
|
+
for j0, j1, hours in strs:
|
|
270
|
+
sl = slice(j0, j1 + 1)
|
|
271
|
+
hidx = np.array([hcols[np.flatnonzero(hrs == h)[0]] for h in sorted(hours)])
|
|
272
|
+
sx = np.nansum(st.x[hidx][:, sl], axis=1)
|
|
273
|
+
pk = int(np.nanargmax(sx))
|
|
274
|
+
pcol = hidx[pk]
|
|
275
|
+
peak_hour = int(st.cols[pcol])
|
|
276
|
+
val = float(sx[pk])
|
|
277
|
+
d = np.nansum(st.draws[:, pcol, sl], axis=1)
|
|
278
|
+
lo, hi = np.quantile(d, [alpha / 2, 1 - alpha / 2])
|
|
279
|
+
dly = st.daily[:, pcol, sl]
|
|
280
|
+
okd = np.isfinite(dly).all(1)
|
|
281
|
+
pers = float((dly[okd].sum(1) > 0).mean()) if okd.any() else float("nan")
|
|
282
|
+
exc_day = float(np.nansum(obs_h[hidx][:, sl] - ref[None, sl] * pas_h[hidx][:, sl]) / D)
|
|
283
|
+
# fixed score
|
|
284
|
+
with np.errstate(all="ignore"):
|
|
285
|
+
occ = np.nansum(opp_all[day_sel][:, sl], axis=1) / st.length[sl].sum()
|
|
286
|
+
f_num = float(np.nanquantile(occ, 0.2)) if np.isfinite(occ).any() else float("nan")
|
|
287
|
+
zone = st.zone[j0]
|
|
288
|
+
c = 0.5 * (mid[j0] + mid[j1])
|
|
289
|
+
base_m = (~cand) & (st.zone == zone) & np.isfinite(seg_ff)
|
|
290
|
+
near = base_m & (np.abs(mid - c) <= 500)
|
|
291
|
+
pool = seg_ff[near] if near.sum() >= 5 else seg_ff[base_m]
|
|
292
|
+
base = float(np.median(pool)) if len(pool) else float("nan")
|
|
293
|
+
F = f_num / base if base and base > 0 else float("nan")
|
|
294
|
+
# peak ratio over day hours, from the full-sample per-hour summed excess
|
|
295
|
+
dh = [h for h in range(6, 20) if h in set(hrs.tolist())]
|
|
296
|
+
summed = {h: float(np.nansum(st.x[hcols[np.flatnonzero(hrs == h)[0]], sl])) for h in dh}
|
|
297
|
+
pv = [v for h, v in summed.items() if h in peak_hours]
|
|
298
|
+
ov = [v for h, v in summed.items() if h not in peak_hours]
|
|
299
|
+
pmean = np.mean(pv) if pv else float("nan")
|
|
300
|
+
omean = np.mean(ov) if ov else float("nan")
|
|
301
|
+
ratio = float(pmean / max(omean, st.theta / 2)) if np.isfinite(pmean) else float("nan")
|
|
302
|
+
if np.isfinite(F) and F >= f_min and not ratio >= peak_ratio_min:
|
|
303
|
+
kind = "infrastructure"
|
|
304
|
+
elif ratio >= peak_ratio_min and not (np.isfinite(F) and F >= f_min):
|
|
305
|
+
kind = "congestion"
|
|
306
|
+
else:
|
|
307
|
+
kind = "mixed"
|
|
308
|
+
lk0 = dd.link_keys[dd.seg_link[st.ks[j0]]]
|
|
309
|
+
lk1 = dd.link_keys[dd.seg_link[st.ks[j1]]]
|
|
310
|
+
n0 = names.get(lk0, (lk0, None))[0]
|
|
311
|
+
n1 = names.get(lk1, (None, lk1))[1]
|
|
312
|
+
out.append((dd.display_uid, dd.direction_id, 0, int(st.seg_idx[j0]), int(st.seg_idx[j1]), float(st.x0[j0]),
|
|
313
|
+
float(st.x0[j1] + st.length[j1]), n0, n1, zone, kind, sorted(int(h) for h in hours), peak_hour,
|
|
314
|
+
val, float(lo), float(hi), pers, int(dd.V[:, st.ks[sl]].sum(0).min()), exc_day, F, ratio,
|
|
315
|
+
dd.seg_keys[st.ks[sl]].tolist()))
|
|
316
|
+
return out
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def to_geojson(hotspots: pl.DataFrame, segments: pl.DataFrame) -> dict:
|
|
320
|
+
"""GeoJSON FeatureCollection, one LineString per stretch (concatenated segment geometries)."""
|
|
321
|
+
geo = {}
|
|
322
|
+
for (u,), g in segments.select("pattern_uid", "seg_idx", "geometry").group_by(["pattern_uid"]):
|
|
323
|
+
g = g.sort("seg_idx")
|
|
324
|
+
geo[u] = (g["seg_idx"].to_numpy(), g["geometry"].to_list())
|
|
325
|
+
feats = []
|
|
326
|
+
for r in hotspots.iter_rows(named=True):
|
|
327
|
+
coords: list = []
|
|
328
|
+
if r["pattern_uid"] in geo:
|
|
329
|
+
idx, gs = geo[r["pattern_uid"]]
|
|
330
|
+
for i in np.flatnonzero((idx >= r["from_seg"]) & (idx <= r["to_seg"])):
|
|
331
|
+
for pt in gs[i] or []:
|
|
332
|
+
if not coords or coords[-1] != list(pt):
|
|
333
|
+
coords.append(list(pt))
|
|
334
|
+
props = {k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in r.items()}
|
|
335
|
+
feats.append({"type": "Feature", "properties": props,
|
|
336
|
+
"geometry": {"type": "LineString", "coordinates": coords} if len(coords) >= 2 else None})
|
|
337
|
+
return {"type": "FeatureCollection", "features": feats}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Standalone HTML report: ``template.html`` filled with the JSON contract (``report.to_contract``).
|
|
2
|
+
|
|
3
|
+
The page needs Leaflet JS from cdnjs (and, if ``tiles``, CARTO base-map tiles) for the map; the
|
|
4
|
+
matrix, profile, hotspots and coverage calendar work offline. Leaflet's CSS is inlined.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
PLACEHOLDERS = ("__LANG__", "__TITLE__", "__DESC__", "__LEAFLETCSS__", "__DATA__")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resource(name: str) -> str:
|
|
19
|
+
return resources.files("microsegments.html").joinpath(name).read_text(encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _contract(obj, **kw) -> dict[str, Any]:
|
|
23
|
+
if isinstance(obj, dict):
|
|
24
|
+
return obj
|
|
25
|
+
from ..metrics import Analysis
|
|
26
|
+
from ..report import to_contract
|
|
27
|
+
if isinstance(obj, Analysis):
|
|
28
|
+
return to_contract(obj, **kw)
|
|
29
|
+
if hasattr(obj, "contract"): # pipeline.RunResult
|
|
30
|
+
return obj.contract(**kw)
|
|
31
|
+
raise TypeError(f"expected an Analysis, a RunResult or a contract dict, got {type(obj).__name__}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _json_for_script(data: dict) -> str:
|
|
35
|
+
s = json.dumps(data, separators=(",", ":"), ensure_ascii=False, allow_nan=False, default=str)
|
|
36
|
+
# safe inside <script type="application/json">
|
|
37
|
+
return s.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") \
|
|
38
|
+
.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _default_title(c: dict, lang: str) -> str:
|
|
42
|
+
line = c.get("line") or ""
|
|
43
|
+
d = (c.get("dirs") or [{}])[0]
|
|
44
|
+
ends = f"{d.get('from')} ↔ {d.get('to')}" if d.get("from") else ""
|
|
45
|
+
if lang == "en":
|
|
46
|
+
return f"Line {line}, observations per micro-segment" + (f" ({ends})" if ends else "")
|
|
47
|
+
return f"Ligne {line}, observations par microsegment" + (f" ({ends})" if ends else "")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render(analysis_or_contract, *, title: str | None = None, lang: str = "fr", tiles: bool = True,
|
|
51
|
+
scales: dict | None = None, description: str | None = None, **contract_kw) -> str:
|
|
52
|
+
"""The full HTML document as a string. ``scales``: fixed colour-scale tops, e.g.
|
|
53
|
+
``{"oph": 20, "opp": 3, "ex": 1.5, "oph10": 8}`` (or ``{"oph": {"true": 20, "false": 6}}`` per
|
|
54
|
+
stop-zone toggle); default: p98 of the 6-21 h values. ``contract_kw`` go to ``to_contract`` when an
|
|
55
|
+
Analysis / RunResult is given."""
|
|
56
|
+
c = dict(_contract(analysis_or_contract, **contract_kw))
|
|
57
|
+
c["tiles"] = bool(tiles)
|
|
58
|
+
if scales:
|
|
59
|
+
c["scales"] = scales
|
|
60
|
+
if title:
|
|
61
|
+
c["title"] = title
|
|
62
|
+
page_title = c.get("title") or _default_title(c, lang)
|
|
63
|
+
p = c.get("period") or {}
|
|
64
|
+
desc = description or (
|
|
65
|
+
f"{page_title}. {p.get('first', '')} – {p.get('last', '')}, {p.get('days', 0)} "
|
|
66
|
+
+ ("days." if lang == "en" else "jours."))
|
|
67
|
+
out = _resource("template.html")
|
|
68
|
+
out = out.replace("__LANG__", "en" if lang == "en" else "fr")
|
|
69
|
+
out = out.replace("__TITLE__", html.escape(page_title))
|
|
70
|
+
out = out.replace("__DESC__", html.escape(desc, quote=True))
|
|
71
|
+
out = out.replace("__LEAFLETCSS__", _resource("leaflet.min.css"))
|
|
72
|
+
out = out.replace("__DATA__", _json_for_script(c))
|
|
73
|
+
left = [m for m in re.findall(r"__[A-Z]+__", out) if m in PLACEHOLDERS]
|
|
74
|
+
if left: # pragma: no cover - template bug
|
|
75
|
+
raise RuntimeError(f"unfilled placeholders {left}")
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def export(analysis_or_contract, path: str | Path, *, title: str | None = None, lang: str = "fr",
|
|
80
|
+
standalone: bool = True, tiles: bool = True, scales: dict | None = None, **contract_kw) -> Path:
|
|
81
|
+
"""Write the report to ``path``. ``analysis_or_contract``: an ``Analysis``, a ``RunResult`` or a
|
|
82
|
+
contract dict (``report.to_contract``). ``standalone`` (default): a complete HTML document; False:
|
|
83
|
+
only what goes inside ``<body>`` (head elements included inline), to embed in another page."""
|
|
84
|
+
page = render(analysis_or_contract, title=title, lang=lang, tiles=tiles, scales=scales, **contract_kw)
|
|
85
|
+
if not standalone:
|
|
86
|
+
head = re.search(r"<head>(.*)</head>", page, re.DOTALL).group(1)
|
|
87
|
+
body = re.search(r"<body>(.*)</body>", page, re.DOTALL).group(1)
|
|
88
|
+
head = re.sub(r"<meta[^>]*>\n?", "", head)
|
|
89
|
+
page = head + body
|
|
90
|
+
path = Path(path)
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
path.write_text(page, encoding="utf-8")
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["export", "render"]
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/* Leaflet 1.9.4 CSS - BSD-2-Clause - (c) 2010-2023 Volodymyr Agafonkin, (c) 2010-2011 CloudMade */
|
|
2
|
+
.leaflet-image-layer,.leaflet-layer,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-pane,.leaflet-pane>canvas,.leaflet-pane>svg,.leaflet-tile,.leaflet-tile-container,.leaflet-zoom-box{position:absolute;left:0;top:0}.leaflet-container{overflow:hidden}.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-tile{-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-user-drag:none}.leaflet-tile::selection{background:0 0}.leaflet-safari .leaflet-tile{image-rendering:-webkit-optimize-contrast}.leaflet-safari .leaflet-tile-container{width:1600px;height:1600px;-webkit-transform-origin:0 0}.leaflet-marker-icon,.leaflet-marker-shadow{display:block}.leaflet-container .leaflet-overlay-pane svg{max-width:none!important;max-height:none!important}.leaflet-container .leaflet-marker-pane img,.leaflet-container .leaflet-shadow-pane img,.leaflet-container .leaflet-tile,.leaflet-container .leaflet-tile-pane img,.leaflet-container img.leaflet-image-layer{max-width:none!important;max-height:none!important;width:auto;padding:0}.leaflet-container img.leaflet-tile{mix-blend-mode:plus-lighter}.leaflet-container.leaflet-touch-zoom{-ms-touch-action:pan-x pan-y;touch-action:pan-x pan-y}.leaflet-container.leaflet-touch-drag{-ms-touch-action:pinch-zoom;touch-action:none;touch-action:pinch-zoom}.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom{-ms-touch-action:none;touch-action:none}.leaflet-container{-webkit-tap-highlight-color:transparent}.leaflet-container a{-webkit-tap-highlight-color:rgba(51,181,229,.4)}.leaflet-tile{filter:inherit;visibility:hidden}.leaflet-tile-loaded{visibility:inherit}.leaflet-zoom-box{width:0;height:0;-moz-box-sizing:border-box;box-sizing:border-box;z-index:800}.leaflet-overlay-pane svg{-moz-user-select:none}.leaflet-pane{z-index:400}.leaflet-tile-pane{z-index:200}.leaflet-overlay-pane{z-index:400}.leaflet-shadow-pane{z-index:500}.leaflet-marker-pane{z-index:600}.leaflet-tooltip-pane{z-index:650}.leaflet-popup-pane{z-index:700}.leaflet-map-pane canvas{z-index:100}.leaflet-map-pane svg{z-index:200}.leaflet-vml-shape{width:1px;height:1px}.lvml{behavior:url(#default#VML);display:inline-block;position:absolute}.leaflet-control{position:relative;z-index:800;pointer-events:visiblePainted;pointer-events:auto}.leaflet-bottom,.leaflet-top{position:absolute;z-index:1000;pointer-events:none}.leaflet-top{top:0}.leaflet-right{right:0}.leaflet-bottom{bottom:0}.leaflet-left{left:0}.leaflet-control{float:left;clear:both}.leaflet-right .leaflet-control{float:right}.leaflet-top .leaflet-control{margin-top:10px}.leaflet-bottom .leaflet-control{margin-bottom:10px}.leaflet-left .leaflet-control{margin-left:10px}.leaflet-right .leaflet-control{margin-right:10px}.leaflet-fade-anim .leaflet-popup{opacity:0;-webkit-transition:opacity .2s linear;-moz-transition:opacity .2s linear;transition:opacity .2s linear}.leaflet-fade-anim .leaflet-map-pane .leaflet-popup{opacity:1}.leaflet-zoom-animated{-webkit-transform-origin:0 0;-ms-transform-origin:0 0;transform-origin:0 0}svg.leaflet-zoom-animated{will-change:transform}.leaflet-zoom-anim .leaflet-zoom-animated{-webkit-transition:-webkit-transform .25s cubic-bezier(0,0,.25,1);-moz-transition:-moz-transform .25s cubic-bezier(0,0,.25,1);transition:transform .25s cubic-bezier(0,0,.25,1)}.leaflet-pan-anim .leaflet-tile,.leaflet-zoom-anim .leaflet-tile{-webkit-transition:none;-moz-transition:none;transition:none}.leaflet-zoom-anim .leaflet-zoom-hide{visibility:hidden}.leaflet-interactive{cursor:pointer}.leaflet-grab{cursor:-webkit-grab;cursor:-moz-grab;cursor:grab}.leaflet-crosshair,.leaflet-crosshair .leaflet-interactive{cursor:crosshair}.leaflet-control,.leaflet-popup-pane{cursor:auto}.leaflet-dragging .leaflet-grab,.leaflet-dragging .leaflet-grab .leaflet-interactive,.leaflet-dragging .leaflet-marker-draggable{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.leaflet-image-layer,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-pane>svg path,.leaflet-tile-container{pointer-events:none}.leaflet-image-layer.leaflet-interactive,.leaflet-marker-icon.leaflet-interactive,.leaflet-pane>svg path.leaflet-interactive,svg.leaflet-image-layer.leaflet-interactive path{pointer-events:visiblePainted;pointer-events:auto}.leaflet-container{background:#ddd;outline-offset:1px}.leaflet-container a{color:#0078a8}.leaflet-zoom-box{border:2px dotted #38f;background:rgba(255,255,255,.5)}.leaflet-container{font-family:"Helvetica Neue",Arial,Helvetica,sans-serif;font-size:12px;font-size:.75rem;line-height:1.5}.leaflet-bar{box-shadow:0 1px 5px rgba(0,0,0,.65);border-radius:4px}.leaflet-bar a{background-color:#fff;border-bottom:1px solid #ccc;width:26px;height:26px;line-height:26px;display:block;text-align:center;text-decoration:none;color:#000}.leaflet-bar a,.leaflet-control-layers-toggle{background-position:50% 50%;background-repeat:no-repeat;display:block}.leaflet-bar a:focus,.leaflet-bar a:hover{background-color:#f4f4f4}.leaflet-bar a:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.leaflet-bar a:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:4px;border-bottom:none}.leaflet-bar a.leaflet-disabled{cursor:default;background-color:#f4f4f4;color:#bbb}.leaflet-touch .leaflet-bar a{width:30px;height:30px;line-height:30px}.leaflet-touch .leaflet-bar a:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.leaflet-touch .leaflet-bar a:last-child{border-bottom-left-radius:2px;border-bottom-right-radius:2px}.leaflet-control-zoom-in,.leaflet-control-zoom-out{font:bold 18px 'Lucida Console',Monaco,monospace;text-indent:1px}.leaflet-touch .leaflet-control-zoom-in,.leaflet-touch .leaflet-control-zoom-out{font-size:22px}.leaflet-control-layers{box-shadow:0 1px 5px rgba(0,0,0,.4);background:#fff;border-radius:5px}.leaflet-control-layers-toggle{background-image:url(images/layers.png);width:36px;height:36px}.leaflet-retina .leaflet-control-layers-toggle{background-image:url(images/layers-2x.png);background-size:26px 26px}.leaflet-touch .leaflet-control-layers-toggle{width:44px;height:44px}.leaflet-control-layers .leaflet-control-layers-list,.leaflet-control-layers-expanded .leaflet-control-layers-toggle{display:none}.leaflet-control-layers-expanded .leaflet-control-layers-list{display:block;position:relative}.leaflet-control-layers-expanded{padding:6px 10px 6px 6px;color:#333;background:#fff}.leaflet-control-layers-scrollbar{overflow-y:scroll;overflow-x:hidden;padding-right:5px}.leaflet-control-layers-selector{margin-top:2px;position:relative;top:1px}.leaflet-control-layers label{display:block;font-size:13px;font-size:1.08333em}.leaflet-control-layers-separator{height:0;border-top:1px solid #ddd;margin:5px -10px 5px -6px}.leaflet-default-icon-path{background-image:url(images/marker-icon.png)}.leaflet-container .leaflet-control-attribution{background:#fff;background:rgba(255,255,255,.8);margin:0}.leaflet-control-attribution,.leaflet-control-scale-line{padding:0 5px;color:#333;line-height:1.4}.leaflet-control-attribution a{text-decoration:none}.leaflet-control-attribution a:focus,.leaflet-control-attribution a:hover{text-decoration:underline}.leaflet-attribution-flag{display:inline!important;vertical-align:baseline!important;width:1em;height:.6669em}.leaflet-left .leaflet-control-scale{margin-left:5px}.leaflet-bottom .leaflet-control-scale{margin-bottom:5px}.leaflet-control-scale-line{border:2px solid #777;border-top:none;line-height:1.1;padding:2px 5px 1px;white-space:nowrap;-moz-box-sizing:border-box;box-sizing:border-box;background:rgba(255,255,255,.8);text-shadow:1px 1px #fff}.leaflet-control-scale-line:not(:first-child){border-top:2px solid #777;border-bottom:none;margin-top:-2px}.leaflet-control-scale-line:not(:first-child):not(:last-child){border-bottom:2px solid #777}.leaflet-touch .leaflet-bar,.leaflet-touch .leaflet-control-attribution,.leaflet-touch .leaflet-control-layers{box-shadow:none}.leaflet-touch .leaflet-bar,.leaflet-touch .leaflet-control-layers{border:2px solid rgba(0,0,0,.2);background-clip:padding-box}.leaflet-popup{position:absolute;text-align:center;margin-bottom:20px}.leaflet-popup-content-wrapper{padding:1px;text-align:left;border-radius:12px}.leaflet-popup-content{margin:13px 24px 13px 20px;line-height:1.3;font-size:13px;font-size:1.08333em;min-height:1px}.leaflet-popup-content p{margin:17px 0;margin:1.3em 0}.leaflet-popup-tip-container{width:40px;height:20px;position:absolute;left:50%;margin-top:-1px;margin-left:-20px;overflow:hidden;pointer-events:none}.leaflet-popup-tip{width:17px;height:17px;padding:1px;margin:-10px auto 0;pointer-events:auto;-webkit-transform:rotate(45deg);-moz-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.leaflet-popup-content-wrapper,.leaflet-popup-tip{background:#fff;color:#333;box-shadow:0 3px 14px rgba(0,0,0,.4)}.leaflet-container a.leaflet-popup-close-button{position:absolute;top:0;right:0;border:none;text-align:center;width:24px;height:24px;font:16px/24px Tahoma,Verdana,sans-serif;color:#757575;text-decoration:none;background:0 0}.leaflet-container a.leaflet-popup-close-button:focus,.leaflet-container a.leaflet-popup-close-button:hover{color:#585858}.leaflet-popup-scrolled{overflow:auto}.leaflet-oldie .leaflet-popup-content-wrapper{-ms-zoom:1}.leaflet-oldie .leaflet-popup-tip{width:24px;margin:0 auto}.leaflet-oldie .leaflet-control-layers,.leaflet-oldie .leaflet-control-zoom,.leaflet-oldie .leaflet-popup-content-wrapper,.leaflet-oldie .leaflet-popup-tip{border:1px solid #999}.leaflet-div-icon{background:#fff;border:1px solid #666}.leaflet-tooltip{position:absolute;padding:6px;background-color:#fff;border:1px solid #fff;border-radius:3px;color:#222;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;pointer-events:none;box-shadow:0 1px 3px rgba(0,0,0,.4)}.leaflet-tooltip.leaflet-interactive{cursor:pointer;pointer-events:auto}.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before,.leaflet-tooltip-top:before{position:absolute;pointer-events:none;border:6px solid transparent;background:0 0;content:""}.leaflet-tooltip-bottom{margin-top:6px}.leaflet-tooltip-top{margin-top:-6px}.leaflet-tooltip-bottom:before,.leaflet-tooltip-top:before{left:50%;margin-left:-6px}.leaflet-tooltip-top:before{bottom:0;margin-bottom:-12px;border-top-color:#fff}.leaflet-tooltip-bottom:before{top:0;margin-top:-12px;margin-left:-6px;border-bottom-color:#fff}.leaflet-tooltip-left{margin-left:-6px}.leaflet-tooltip-right{margin-left:6px}.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{top:50%;margin-top:-6px}.leaflet-tooltip-left:before{right:0;margin-right:-12px;border-left-color:#fff}.leaflet-tooltip-right:before{left:0;margin-left:-12px;border-right-color:#fff}@media print{.leaflet-control{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
|