diff --git a/README.md b/README.md index f0d2a94..2060324 100644 --- a/README.md +++ b/README.md @@ -240,3 +240,8 @@ GitHub Actions (`.github/workflows/ci.yml`) runs on every push/PR to `main`: a `python` job that `uv sync`s and runs `./dev check` (plus a non-blocking `ruff check`), and a `frontend` job that type-checks with `tsc` and runs the Playwright suite, uploading the HTML report as an artifact. + +## Desired quality checks for the hackathon + +https://docs.google.com/spreadsheets/d/1uFikn1oFehRSQzr4VxS09NjVna7_gvvluq2YKs1pl5Y/edit?gid=0#gid=0 + diff --git a/orion/days_with_no_cancellations.md b/orion/days_with_no_cancellations.md new file mode 100644 index 0000000..b4be4da --- /dev/null +++ b/orion/days_with_no_cancellations.md @@ -0,0 +1,132 @@ +# Data availability check — Method 1 (`days_wo_cancellation_score()`) + +Goal of method 1: for a given bus line, score = (# days in the last 15 with **zero** cancellations) / (# days scored). A day with ≥1 cancellation counts 0, a fully-operated day counts 1. + +## Which data is needed + +There is no direct "cancellation" flag in Stride. A cancellation is **inferred**: a ride that was *planned* in the GTFS timetable but for which *no actual* activity exists. There are three ways to obtain that signal from the API — listed below from simplest to heaviest. + +--- + +## Option A — `/rides_execution/list` (recommended for Method 1) ✅ VERIFIED + +Purpose-built for planned-vs-actual at ride level. One endpoint gives everything. + +- **Params (per the OpenAPI schema):** `line_ref` **(required, int)**, `operator_ref` **(required, int)**, `date_from` **(required)**, `date_to` **(required)**, `limit`, `offset`, `get_count`. There is **no `order_by`** and no other filter — all four filters must be supplied on every call. +- **Fields returned:** `planned_start_time`, `actual_start_time`, `gtfs_ride_id`. That is the whole model. +- **Cancellation signal:** a row where `actual_start_time` is **null** = that planned ride did not run. +- **Granularity:** one row per planned ride. +- **Verified live (2026-07-30):** line 2259 / operator 5 / 2026-07-15..29 → 1,790 rows, **6 nulls across 4 distinct days** → score 10/15. The signal is real and non-degenerate (contrast Option B). Date filter is by **Israel service date** (a `date_from=2026-07-29` row shows `planned_start_time` 2026-07-28 21:00 UTC = local midnight). +- **Full end-to-end run:** line 480, all 8 variants, 15 days → 1,355 rows over 8 requests, 7 cancellations, score 0.53. Historical windows work too (2025-11-01..15 → 0.81). + +```python +# 1. resolve the line -> (line_ref, operator_ref) pairs FOR THIS WINDOW (see caveat below) +routes = stride.get('/gtfs_routes/list', { + 'route_short_name': '480', 'date_from': '2026-07-29', 'date_to': '2026-07-29', +}) +pairs = {(r['line_ref'], r['operator_ref']) for r in routes} # -> 8 pairs for line 480 + +# 2. one paged call per pair; union the rows +for line_ref, operator_ref in pairs: + rows = stride.iterate('/rides_execution/list', { + 'line_ref': line_ref, 'operator_ref': operator_ref, + 'date_from': '2026-07-15', 'date_to': '2026-07-29', + }) + # drop rows with no planned_start_time (see caveat), dedup on planned_start_time, + # bucket by Asia/Jerusalem date +# per day: cancelled = [r for r in day_rows if not r['actual_start_time']] +# day_is_good = len(cancelled) == 0 +``` + +Why it matches Method 1: the null check is an unambiguous per-ride cancellation, and query weight is trivial (one request per line variant per window, nowhere near the 15,000-row cap). + +### Caveats specific to A (all confirmed live, all must be handled) + +1. **`line_ref` is per direction+alternative, not per line.** "Line 480" is **8** distinct `line_ref`s today (7020, 7022, 7023, 7024, 7028, 7033, 7034, 10958 — all operator 3), each needing its own call, unioned per day. The set is **time-varying**: the same line had only **2** line_refs in Nov 2025. Resolve it via `/gtfs_routes/list` *for the window being scored* — do not cache one mapping and reuse it. +2. **`planned_start_time` is null on 3–6% of rows** (4–8/day on line 2259). These are actuals with no matching plan — the mirror image of a cancellation. They cannot be bucketed to a service date; filter them out before the day loop or it miscounts (or crashes on the date parse). +3. **Duplicate planned starts exist.** 2026-07-26 returned 259 rows for 127 distinct start times (some ×4) under 255 distinct `gtfs_ride_id`s — one physical departure emitted under several ride ids. Dedup on `planned_start_time` before counting, or a duplicated row with a null actual becomes a phantom cancellation. (In every sample checked the duplicates all had actuals filled, so it hasn't bitten yet — but the shape is live.) +4. **`actual_start_time` is a binary flag, not an observed time.** For all 1,726 non-null rows sampled, `actual_start_time == planned_start_time` to the second. Fine for the null check; but this endpoint can never be reused for delay/punctuality work, and "actual" here is not a ground-truth departure time. +5. **Use real `Asia/Jerusalem`, not a fixed UTC+3.** A hardcoded +3 spills a 16th day into a 15-day November window — Israel is UTC+2 in winter. The "Israel service date" note above only holds under summer time. + +--- + +## Option B — `/gtfs_rides_agg/list` — ❌ NOT VIABLE on this deployment + +Pre-aggregated planned-vs-actual counts. On paper the lightest option (one row per route×hour). **In practice it cannot detect cancellations on this API instance: `num_actual_rides` is always 0.** + +- **Params:** `date_from` (required), `date_to` (required), `exclude_hours_from`, `exclude_hours_to`, `limit`, `offset`. No `line_ref`, `operator_ref`, or `gtfs_route_id` filter on `/list`. +- **Fields returned:** `gtfs_route_id`, `gtfs_route_hour`, `num_planned_rides`, `num_actual_rides`, `operator_ref`. +- **Cancellation signal (broken):** `num_planned_rides − num_actual_rides`. + +**Live check (2026-07-30) — blocker:** across both `/list` and `/group_by`, on every date sampled from Nov 2025 → Jul 2026, `num_actual_rides` = **0 for every line, network-wide** while `num_planned_rides` is populated. The actual-rides column is never filled in this deployment, so the signal reports 100% cancellation everywhere. + +| Date | Σ planned | Σ actual | +|---|---|---| +| 2026-07-01 | 3,498 | **0** | +| 2026-06-15 | 3,510 | **0** | +| 2026-04-01 | 3,227 | **0** | +| 2025-11-01 | 3,927 | **0** | + +Not lag: line 2259 / 2026-07-29 shows `total_actual_rides=0` in the aggregate, yet Option A shows real `actual_start_time` values and 0 cancellations for the same line/date — actuals exist at ride level, just not rolled into the aggregate. + +*(Correction, 2026-07-30: an earlier version of this doc claimed A returns 219 planned rows for that line/date vs the aggregate's 123, and used the mismatch as a second strike against B. A actually returns **126**, which matches the aggregate closely. The planned counts agree; B is ruled out solely because `num_actual_rides` is never populated.)* + +**Silver lining (didn't rescue B):** `/gtfs_rides_agg/group_by` accepts `group_by=` any of `gtfs_route_date, gtfs_route_hour, operator_ref, day_of_week, line_ref`, returning `route_short_name`/`route_long_name` + `total_planned_rides`/`total_actual_rides`. So a clean per-line, per-day planned rollup *is* available (the earlier "no `line_ref` filter" caveat was wrong) — but with actuals stuck at 0 it still can't score cancellations. + +```python +# What was tried: +rows = stride.get('/gtfs_rides_agg/group_by', { + 'date_from': '2026-07-29', 'date_to': '2026-07-29', + 'group_by': 'line_ref,gtfs_route_date', +}) +# -> line 2259: total_planned_rides=123, total_actual_rides=0 (actual always 0) +``` + +--- + +## Option C — GTFS timetable vs SIRI diff (heaviest; only for degraded-ride detection) + +Build the planned start-set and the actual start-set separately and diff them. This is the manual method demonstrated in the `compare gtfs planned vs siri actual` notebook. + +- **Line identity (once):** `/gtfs_routes/list` with `route_short_name`, `operator_refs`, `agency_name`, `date_from`/`date_to` → `line_ref`, `operator_ref`. +- **Planned rides:** `/route_timetable/list` with `line_refs`, `planned_start_time_date_from`/`..._date_to` → distinct set of `gtfs_line_start_time`. +- **Actual rides:** `/siri_rides/list` (one row per ride; lighter) or `/siri_vehicle_locations/list`, filtered by `siri_route__line_refs`, `siri_route__operator_refs`, `scheduled_start_time_from`/`..._to` → distinct set of `scheduled_start_time`. +- **Cancellation signal:** `cancelled = planned_starts − actual_starts` (match on scheduled start-time). +- **Granularity:** one row per timetable stop / per SIRI location ping — the heaviest pull. +- **Only reason to use it:** it carries the full vehicle traces, so it can additionally flag *degraded* rides (started but died mid-route), which A and B cannot see. + +--- + +## Comparison + +| | A · `/rides_execution/list` | B · `/gtfs_rides_agg` | C · GTFS vs SIRI diff | +|---|---|---|---| +| Granularity | 1 row per ride | 1 row per route×hour (or per-line/day via group_by) | 1 row per stop / location ping | +| Rows for 1 line, 15 d | ~1,400–1,800 (measured) | ~hundreds | thousands (heaviest) | +| Cancellation signal | `actual_start_time` is null | `num_planned − num_actual` | planned start-set − actual start-set | +| Filter by line? | ✅ `line_ref` + `operator_ref` (both **required**; 1 call per line variant) | ⚠️ client-side (group_by `line_ref`) | ✅ direct | +| Matches Method 1 signature | ✅ after a `/gtfs_routes` line_ref lookup | ➖ needs client-side filter | ➖ two queries + manual diff | +| Detects fully-cancelled | ✅ | ❌ **actuals always 0** | ✅ | +| Detects degraded/partial | ❌ | ❌ | ⚠️ possible | +| Usable for delay/punctuality | ❌ `actual == planned` always | ❌ | ✅ | +| Query weight | light (8 requests for line 480 / 15 d) | lightest (if it worked) | heaviest | +| Verified working | ✅ live 2026-07-30, non-degenerate | ❌ **num_actual=0 network-wide** | notebook-confirmed | + +**Recommendation:** Method 1 → **A**, confirmed by a live end-to-end run. Option B is ruled out on this deployment (actuals never populated), so Methods 2 & 3 (per-operator / cross-operator) also fall back to **A**. A is far lighter than the doc originally assumed, but it fans out per `line_ref`: Method 2 needs *every* line variant of an operator resolved from `/gtfs_routes/list` first, which is where the real request count lives. Use `/gtfs_rides_agg/group_by` only for planned-ride denominators. Reserve **C** for when degraded-ride detection or actual timing is wanted. + +--- + +## The 15-day loop (applies to whichever option) + +- Iterate the 15 service dates; derive per-day planned + cancelled counts. +- `score = (# good days) / (# scored days)`, where a good day has zero cancellations. +- Exclude days with **no planned rides at all** (line not in service / no timetable) from "# scored days" so they count as neither good nor bad. + +## Edge cases / caveats to decide on + +- **Matching key / timezone:** compare planned vs actual on scheduled *start*-time in the same timezone; the `date_from` filter is by Israel service date (see Option A note). Bucket days with `zoneinfo.ZoneInfo("Asia/Jerusalem")` — a fixed UTC+3 offset breaks on winter dates (A-caveat 5). +- **Partial rides:** a ride that started but died mid-route still has an `actual_start_time`, so A and B count it as operated. Only Option C can flag it. Note A cannot even tell you *when* it started — `actual_start_time` always equals `planned_start_time` (A-caveat 4). +- **Unplanned rides:** A returns rows with a null `planned_start_time` (~3–6%/day). Not cancellations — the opposite. Drop them (A-caveat 2). +- **Missing SIRI ingestion vs a real cancellation:** a day where actual data is globally absent (feed outage) looks like 100% cancellations. Consider a sanity floor (e.g. skip days where the operator has ~zero actuals across all lines). +- **`line_ref` / route variants:** one line number maps to several variants (line 480 → 8 today, 2 in Nov 2025) — resolve them per window and aggregate carefully so a variant that simply wasn't scheduled that day isn't read as a cancellation. Also dedup on `planned_start_time`: the same departure can appear under several `gtfs_ride_id`s (A-caveats 1 and 3). +- **Row limits:** a single request caps at 15,000 (server abuse guard); use `stride.iterate` (pages within that limit) rather than a large single `limit`. diff --git a/orion/days_with_no_cancellations.py b/orion/days_with_no_cancellations.py new file mode 100644 index 0000000..1b9d054 --- /dev/null +++ b/orion/days_with_no_cancellations.py @@ -0,0 +1,388 @@ +# motivation - for a given bus line we want to provide a quality score based on how many days all buses operated as expected. +# i.e. a day with at least 1 cancellation is counter as 0, a day with full operations counted as 1 +# we measure ratio of good days relative to to total days +# we average over past 15 days. + +# method 1 : days_wo_cancellation_score() +# input: line args +# output: score + +# method 2: script that runs on all buses for one bus company and provide score per bus line, and provide a visualization +# method 3: script that compares average score per bus company, and provide a visualization + +""""Days without cancellations" quality score — methods 1 and 2. + +Data source is ``/rides_execution/list`` (Option A in days_with_no_cancellations.md): +one row per planned ride, where ``actual_start_time is None`` means the ride did +not run. Everything below exists to work around the five live caveats documented +there — see the ``# caveat N`` comments. + + days_wo_cancellation_score("480") # method 1: one line -> 0.53 + daily_report("480") # its per-day breakdown -> DataFrame + operator_line_scores("סופרבוס") # method 2: every line of one company + plot_operator_scores(scores, 35) # ...as a bar chart + + $ python days_with_no_cancellations.py --line 480 + $ python days_with_no_cancellations.py --operator "בית שמש אקספרס" + +Method 2 costs one paged request per line_ref: ~80 for a small company (minutes), +~1,240 for אגד. Responses are disk-cached by stride, so a rerun is instant. +""" + +from __future__ import annotations + +import re +import sys +from collections import defaultdict +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, Iterable, Iterator, Sequence +from zoneinfo import ZoneInfo + +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from openbus_hack import stride # noqa: E402 + +ISRAEL = ZoneInfo("Asia/Jerusalem") # caveat 5: never a fixed UTC+3 — winter is +2 +DEFAULT_DAYS = 15 + + +# ── window ─────────────────────────────────────────────────────────────────── + + +def score_window(days: int = DEFAULT_DAYS, end: date | str | None = None) -> tuple[date, date]: + """The ``days``-long service-date window ending at ``end`` (default: yesterday). + + Today is excluded by default — its actuals are still landing, so a live day + looks like a wall of cancellations. + """ + if end is None: + end = datetime.now(ISRAEL).date() - timedelta(days=1) + elif isinstance(end, str): + end = date.fromisoformat(end) + return end - timedelta(days=days - 1), end + + +def _service_date(iso: str) -> date: + """UTC timestamp string -> the Israel service date it belongs to.""" + return datetime.fromisoformat(iso).astimezone(ISRAEL).date() + + +# ── fetching ───────────────────────────────────────────────────────────────── + + +def line_variants(line: str, date_from: date, date_to: date, + operators: Sequence[str] | None = None) -> set[tuple[int, int]]: + """Resolve a line short-name to its ``(line_ref, operator_ref)`` pairs. + + caveat 1: a "line" is many refs — one per direction × route alternative, and + the set changes over time (480 was 2 refs in Nov 2025, 8 in Jul 2026). Always + resolve for the window being scored; never cache one mapping and reuse it. + """ + routes = stride.routes(lines=[line], operators=operators, + date_from=date_from, date_to=date_to) + if routes.empty: + return set() + return { + (int(r.line_ref), int(r.operator_ref)) + for r in routes.itertuples() + if pd.notna(r.line_ref) and pd.notna(r.operator_ref) + } + + +def rides_execution(line_ref: int, operator_ref: int, + date_from: date, date_to: date) -> Iterator[dict[str, Any]]: + """Page through ``/rides_execution/list``. All four filters are required.""" + offset = 0 + while True: + batch = stride.get("/rides_execution/list", { + "line_ref": line_ref, + "operator_ref": operator_ref, + "date_from": date_from, + "date_to": date_to, + "limit": stride.PAGE_SIZE, + "offset": offset, + }) + if not isinstance(batch, list) or not batch: + return + yield from batch + if len(batch) < stride.PAGE_SIZE: + return + offset += len(batch) + + +# ── the score ──────────────────────────────────────────────────────────────── + + +def daily_report(line: str, days: int = DEFAULT_DAYS, end: date | str | None = None, + operators: Sequence[str] | None = None, + variants: Iterable[tuple[int, int]] | None = None) -> pd.DataFrame: + """Per-day planned/cancelled counts for one line. + + Columns: ``date``, ``planned``, ``cancelled``, ``good`` (bool). + Days on which the line had no planned rides at all are simply absent — they + are neither good nor bad, and must not land in the denominator. + """ + date_from, date_to = score_window(days, end) + if variants is None: + variants = line_variants(line, date_from, date_to, operators) + + # caveat 3: one departure can surface under several gtfs_ride_ids. Key on the + # planned departure itself, and let *any* observed actual mark it as operated, + # so a duplicated row with a null actual can't invent a cancellation. + operated: dict[tuple[int, int, str], bool] = {} + for line_ref, operator_ref in variants: + for row in rides_execution(line_ref, operator_ref, date_from, date_to): + planned = row.get("planned_start_time") + if not planned: + continue # caveat 2: unplanned ride (~3-6%) — the opposite of a cancellation + key = (line_ref, operator_ref, planned) + operated[key] = operated.get(key, False) or bool(row.get("actual_start_time")) + + per_day: dict[date, list[int]] = defaultdict(lambda: [0, 0]) # planned, cancelled + for (_, _, planned), ran in operated.items(): + day = _service_date(planned) + if not (date_from <= day <= date_to): + continue # the API's service-date filter is fuzzy at the window edges + per_day[day][0] += 1 + per_day[day][1] += 0 if ran else 1 + + rows = [ + {"date": day, "planned": planned, "cancelled": cancelled, "good": cancelled == 0} + for day, (planned, cancelled) in sorted(per_day.items()) + ] + return pd.DataFrame(rows, columns=["date", "planned", "cancelled", "good"]) + + +def days_wo_cancellation_score(line: str, days: int = DEFAULT_DAYS, + end: date | str | None = None, + operators: Sequence[str] | None = None) -> float | None: + """Fraction of the last ``days`` service days on which *no* ride was cancelled. + + Returns ``None`` when the line had no planned rides in the window at all — + that is "unknown", not "perfect", and callers must not average it in as 1.0. + """ + report = daily_report(line, days, end, operators) + if report.empty: + return None + return float(report["good"].mean()) + + +# ── method 2: score every line of one operator ─────────────────────────────── + + +def operator_ref_of(operator: str | int) -> int: + """Accept an operator_ref (``5``, ``"5"``) or an agency name (``"אגד"``).""" + if isinstance(operator, int) or str(operator).isdigit(): + return int(operator) + refs = stride.operator_refs_for([str(operator)]) + if not refs: + raise ValueError(f"no operator matched {operator!r} — see stride.agencies()") + return refs[0] + + +def operator_name(operator: str | int, date_from: date | None = None, + date_to: date | None = None) -> str: + """Agency name for display. Passes non-numeric input straight through.""" + if not (isinstance(operator, int) or str(operator).isdigit()): + return str(operator) + ag = stride.agencies(date_from, date_to) + hit = ag[ag["operator_ref"] == int(operator)] if not ag.empty else ag + return str(hit.iloc[0]["agency_name"]) if len(hit) else f"operator {operator}" + + +def operator_variants(operator: str | int, date_from: date, + date_to: date) -> dict[str, set[tuple[int, int]]]: + """``route_short_name -> {(line_ref, operator_ref)}`` for one operator. + + One request set for the whole company, instead of a /gtfs_routes lookup per + line. This is where method 2's real cost lives: אגד is ~1,240 line_refs over + 15 days, i.e. ~1,240 ride pulls downstream. + """ + ref = operator_ref_of(operator) + routes = stride.routes(date_from=date_from, date_to=date_to, + operator_refs=str(ref), limit=None) + by_line: dict[str, set[tuple[int, int]]] = defaultdict(set) + if routes.empty: + return by_line + for r in routes.itertuples(): + if pd.notna(r.line_ref) and pd.notna(r.route_short_name): + by_line[str(r.route_short_name)].add((int(r.line_ref), int(r.operator_ref))) + return by_line + + +def operator_line_scores(operator: str | int, days: int = DEFAULT_DAYS, + end: date | str | None = None, max_lines: int | None = None, + progress: bool = True) -> pd.DataFrame: + """Method 2 — one row per line of one bus company, worst score first. + + Columns: ``line``, ``score``, ``good_days``, ``days_scored``, ``planned``, + ``cancelled``, ``variants``. Lines with no planned rides in the window are + dropped (score would be undefined, not zero). + """ + date_from, date_to = score_window(days, end) + by_line = operator_variants(operator, date_from, date_to) + lines = sorted(by_line, key=lambda s: (len(s), s)) + if max_lines: + lines = lines[:max_lines] + + rows = [] + for i, line in enumerate(lines, 1): + if progress: + print(f" [{i}/{len(lines)}] line {line} " + f"({len(by_line[line])} variants)", file=sys.stderr, flush=True) + report = daily_report(line, days, end, variants=by_line[line]) + if report.empty: + continue + planned, cancelled = int(report["planned"].sum()), int(report["cancelled"].sum()) + rows.append({ + "line": line, + "score": float(report["good"].mean()), + "good_days": int(report["good"].sum()), + "days_scored": len(report), + "planned": planned, + "cancelled": cancelled, + # Not one actual in 15 days is an ingestion gap, not a company that + # cancelled every bus it ever scheduled. Score is meaningless here. + "no_actuals": cancelled == planned, + "variants": len(by_line[line]), + }) + + df = pd.DataFrame(rows, columns=["line", "score", "good_days", "days_scored", + "planned", "cancelled", "no_actuals", "variants"]) + return df.sort_values(["no_actuals", "score", "cancelled"], + ascending=[True, True, False], ignore_index=True) + + +# ── visualization ──────────────────────────────────────────────────────────── + + +NO_DATA_GREY = "#898781" # theme.py _CHROME["light"]["muted"] — never a series color + + +_RUN = re.compile(r"[0-9A-Za-z]+|[^0-9A-Za-z]+") + + +def _rtl(text: str) -> str: + """matplotlib has no bidi engine — lay Hebrew out right-to-left by hand. + + Runs are emitted in reverse order, but digit/latin runs keep their own + direction: line "28א" must display as "א28", not "א82". + """ + if not any("֐" <= c <= "׿" for c in text): + return text + runs = _RUN.findall(text) + return "".join(r if r[0].isascii() and r[0].isalnum() else r[::-1] + for r in reversed(runs)) + + +def _score_color(score: float) -> str: + from openbus_hack.theme import STATUS + if score >= 0.9: + return STATUS["good"] + if score >= 0.7: + return STATUS["warning"] + if score >= 0.4: + return STATUS["serious"] + return STATUS["critical"] + + +def plot_operator_scores(scores: pd.DataFrame, operator: str | int, + days: int = DEFAULT_DAYS, out: str | Path | None = None) -> Path: + """Horizontal bar chart, worst line at the top. Returns the written path.""" + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + from openbus_hack.theme import use_openbus_style + use_openbus_style() + + df = scores.iloc[::-1] # barh draws bottom-up; we want worst on top + blind = df["no_actuals"] if "no_actuals" in df else pd.Series(False, index=df.index) + height = max(3.0, 0.28 * len(df) + 1.6) + fig, ax = plt.subplots(figsize=(9, height)) + + labels = [_rtl(s) for s in df["line"].astype(str)] # line names like "4א" are RTL too + bars = ax.barh(labels, df["score"].where(~blind, 1.0), height=0.72, + color=[NO_DATA_GREY if b else _score_color(s) + for s, b in zip(df["score"], blind)]) + for bar, b in zip(bars, blind): + if b: # a bar you must not read as a score + bar.set_hatch("///") + bar.set_alpha(0.35) + for line, score, cancelled, b in zip(labels, df["score"], df["cancelled"], blind): + label = "no actuals reported" if b else ( + f"{score:.2f}" + (f" ({cancelled} cx)" if cancelled else "")) + ax.text((1.0 if b else score) + 0.015, line, label, va="center", fontsize=8.5, + color=NO_DATA_GREY if b else None) + + ax.set_xlim(0, 1.18) + ax.set_xticks([0, 0.25, 0.5, 0.75, 1.0]) + ax.set_xlabel("days with zero cancellations / days scored") + ax.set_ylabel("line") + ax.set_title(f"{_rtl(operator_name(operator))} — " + f"days-without-cancellations score, last {days} days") + ax.margins(y=0.01) + fig.tight_layout() + + # ref first so the name sorts and stays unambiguous, then the agency name so + # the file is identifiable without looking the ref up. + slug = re.sub(r"[^\w()'-]+", "_", operator_name(operator)).strip("_") + out = (Path(out) if out else Path(__file__).parent / "out" + / f"operator_{operator_ref_of(operator)}_{slug}_scores.png") + out.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out, dpi=140) + plt.close(fig) + return out + + +# ── CLI ────────────────────────────────────────────────────────────────────── + + +def _main(argv: Sequence[str] | None = None) -> int: + import argparse + + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--line", help="method 1: score one line (e.g. 480)") + p.add_argument("--operator", help="method 2: score every line of one company " + "(agency name or operator_ref)") + p.add_argument("--days", type=int, default=DEFAULT_DAYS) + p.add_argument("--end", help="last service date to score (default: yesterday)") + p.add_argument("--max-lines", type=int, help="method 2: stop after N lines") + p.add_argument("--out", help="method 2: chart path") + args = p.parse_args(argv) + + if args.operator: + scores = operator_line_scores(args.operator, args.days, args.end, args.max_lines) + if scores.empty: + print(f"operator {args.operator}: no lines with planned rides in the window") + return 0 + print(scores.to_string(index=False)) + scored = scores[~scores["no_actuals"]] + blind = int(scores["no_actuals"].sum()) + if scored.empty: + print(f"\n⚠ all {len(scores)} lines report zero actuals for the whole window — " + "this is an ingestion gap, not a 0.00 score. Nothing scoreable here.") + else: + print(f"\n{len(scored)} lines scored, mean score {scored['score'].mean():.2f}, " + f"{int((scored['score'] == 1.0).sum())} perfect, " + f"{int((scored['score'] == 0.0).sum())} never clean" + + (f"; {blind} excluded (zero actuals all window)" if blind else "")) + print(f"chart: {plot_operator_scores(scores, args.operator, args.days, args.out)}") + return 0 + + line = args.line or "480" + report = daily_report(line, args.days, args.end) + if report.empty: + print(f"line {line}: no planned rides in the window") + return 0 + print(report.to_string(index=False)) + good, scored = int(report["good"].sum()), len(report) + print(f"\nline {line}: score = {good / scored:.2f} ({good}/{scored} days)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git "a/orion/out/operator_20_\327\233\327\250\327\236\327\234\327\231\327\252_scores.png" "b/orion/out/operator_20_\327\233\327\250\327\236\327\234\327\231\327\252_scores.png" new file mode 100644 index 0000000..c98e592 Binary files /dev/null and "b/orion/out/operator_20_\327\233\327\250\327\236\327\234\327\231\327\252_scores.png" differ diff --git "a/orion/out/operator_23_\327\222\327\234\327\231\327\235_scores.png" "b/orion/out/operator_23_\327\222\327\234\327\231\327\235_scores.png" new file mode 100644 index 0000000..ff4c7e2 Binary files /dev/null and "b/orion/out/operator_23_\327\222\327\234\327\231\327\235_scores.png" differ diff --git "a/orion/out/operator_32_\327\223\327\237_\327\221\327\220\327\250_\327\251\327\221\327\242_scores.png" "b/orion/out/operator_32_\327\223\327\237_\327\221\327\220\327\250_\327\251\327\221\327\242_scores.png" new file mode 100644 index 0000000..f951e25 Binary files /dev/null and "b/orion/out/operator_32_\327\223\327\237_\327\221\327\220\327\250_\327\251\327\221\327\242_scores.png" differ diff --git a/orion/out/operator_35_scores.png b/orion/out/operator_35_scores.png new file mode 100644 index 0000000..64a30d5 Binary files /dev/null and b/orion/out/operator_35_scores.png differ diff --git "a/orion/out/operator_35_\327\221\327\231\327\252_\327\251\327\236\327\251_\327\220\327\247\327\241\327\244\327\250\327\241_scores.png" "b/orion/out/operator_35_\327\221\327\231\327\252_\327\251\327\236\327\251_\327\220\327\247\327\241\327\244\327\250\327\241_scores.png" new file mode 100644 index 0000000..64a30d5 Binary files /dev/null and "b/orion/out/operator_35_\327\221\327\231\327\252_\327\251\327\236\327\251_\327\220\327\247\327\241\327\244\327\250\327\241_scores.png" differ