microsegments 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. microsegments/__init__.py +42 -0
  2. microsegments/_version.py +24 -0
  3. microsegments/aggregate.py +101 -0
  4. microsegments/cli.py +201 -0
  5. microsegments/config.py +142 -0
  6. microsegments/contract.py +42 -0
  7. microsegments/hotspots.py +337 -0
  8. microsegments/html/__init__.py +6 -0
  9. microsegments/html/export.py +96 -0
  10. microsegments/html/leaflet.min.css +2 -0
  11. microsegments/html/template.html +863 -0
  12. microsegments/io/__init__.py +9 -0
  13. microsegments/io/coverage.py +114 -0
  14. microsegments/io/events.py +98 -0
  15. microsegments/io/gtfsrt.py +168 -0
  16. microsegments/io/ids.py +34 -0
  17. microsegments/io/stib.py +217 -0
  18. microsegments/io/tabular.py +188 -0
  19. microsegments/io/timeutil.py +84 -0
  20. microsegments/locate/__init__.py +47 -0
  21. microsegments/locate/common.py +149 -0
  22. microsegments/locate/linear.py +317 -0
  23. microsegments/locate/mapmatch.py +390 -0
  24. microsegments/locate/resample.py +71 -0
  25. microsegments/metrics.py +520 -0
  26. microsegments/network/__init__.py +10 -0
  27. microsegments/network/calendar.py +218 -0
  28. microsegments/network/geometry.py +224 -0
  29. microsegments/network/keys.py +97 -0
  30. microsegments/network/patterns.py +328 -0
  31. microsegments/pipeline.py +308 -0
  32. microsegments/plot.py +476 -0
  33. microsegments/py.typed +0 -0
  34. microsegments/report.py +258 -0
  35. microsegments/schema.py +257 -0
  36. microsegments/segments.py +253 -0
  37. microsegments/simulate.py +612 -0
  38. microsegments/tracks.py +391 -0
  39. microsegments/tune.py +518 -0
  40. microsegments-0.1.0.dist-info/METADATA +184 -0
  41. microsegments-0.1.0.dist-info/RECORD +44 -0
  42. microsegments-0.1.0.dist-info/WHEEL +4 -0
  43. microsegments-0.1.0.dist-info/entry_points.txt +2 -0
  44. microsegments-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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,6 @@
1
+ """Standalone HTML report (map, hotspots, hour x micro-segment matrix, coverage calendar; fr / en)."""
2
+ from __future__ import annotations
3
+
4
+ from .export import export, render
5
+
6
+ __all__ = ["export", "render"]
@@ -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}}