Atrax ecodriving per-route control — operations

Per-route ecodriving deviation control on Atrax data (#6848, Etap 2 / Branch A of design spike #6841, docs/designs/6841-ecodriving-per-route-control.md). It is the ecodriving sibling of the fuel-consumption norm-control feature (docs/atrax-route-norm-control-operations.md, #6194) and reuses its comparative machinery verbatim.

Etap 1 confirmed Branch A. The live discovery (#6843, findings on #6841 2026-09-03) confirmed the per-event data needed for per-route ecodriving already exists in /api/rides/summary ridesRowsList — no new Atrax report is required. It also confirmed the raw entries carry telemetry, not pre-aggregated harsh-event counters: there is no harsh_accel_count / speeding_* / maxSpeed / maxRpm field. So the harsh-event signals are derived here as analytics over consecutive events; only the raw telemetry is stored.


Data flow

Atrax /api/rides/summary ─┬─(nightly atrax-ride-events-feeder, whole fleet, #6889)─┐
                          └─(atrax-report-generator, on the PDF calls it makes)─────┴─► p24_ride_events
p24_ride_events  ──(nightly atrax-ecodriving-anomaly-daily)──►  p24_ecodriving_anomalies

p24_ride_events is fed by two paths, both writing one row per DRIVING ridesRowsList entry through the same idempotent persist_ride_events upsert:

  1. Primary — atrax-ride-events-feeder (#6889): a nightly job that systematically pulls the previous Berlin day’s /api/rides/summary for every fleet vehicle and upserts its DRIVING events. This is the source the analytic actually depends on.
  2. Opportunistic — atrax-report-generator.py: as a non-fatal side effect when a rides/parking PDF is generated, for the single plate + window that report covers.

History note (#6889): originally path 2 was the only feed. A live check on 2026-09-06 found p24_ride_events had grown to just 12 rows / 1 vehicle — because reports are only ever generated on demand — so the ecodriving ETL below could never clear its sample_trips >= 5 cold-start gate. The feeder (path 1) was added to close that gap. The design of #6841 is unchanged: same endpoint, same parser, same table — only who and when pulls the data changed.

The atrax-ecodriving-anomaly-daily ETL itself remains pure Supabase-in / Supabase-out — it makes no Atrax call. The feeder is the only step in the chain that calls the Atrax API.

Confirmed per-event keys (Etap 1)

DRIVING entries carry: activeInputs, begin, distance, driverNameAndSurname, duration, end, engineTemperature, fuel, fuelCanConsumption, groupID, initialSpeed, lastFuelLevel, odometer, rpm, voltage, x, y. STANDING/STOPPING entries carry a subset and are not persisted (no driving telemetry). Every key not mapped to an explicit column is preserved in p24_ride_events.raw_extra (jsonb) so no field is ever silently dropped again (design fact 4).


Tables

p24_ride_events

One row per DRIVING ridesRowsList entry. Telemetry only — the harsh-event signals are derived downstream, never stored raw. Unique key (atrax_car_id, event_start) (idempotent re-persist via ON CONFLICT … DO NOTHING). start_zone_id is the (x, y) zone bucket (lat/lon rounded to 2 decimals, ~1.1 km — same as the p24_gps_trips route MVP). end_zone_id is reserved/NULL at parse time (a single ride event exposes one GPS point) — the ETL derives a route by pairing consecutive events. RLS: service_role FOR ALL + grafana_readonly SELECT.

p24_ecodriving_anomalies

Per (vehicle, derived-route, driver, trip_date) ecodriving deviation, modelled 1:1 on p24_route_consumption_anomalies. The scored metric is harsh-events-per-km (a composite of the four derived signals). anomaly_type ∈ {ecodriving_deviation, baseline_drift}. Same sample-size-gated severity tiers, same modified-Z / MAD norm, same attribution ∈ {vehicle_route, driver_pattern} with attribution_confidence ∈ {high, low}. RLS as above.

Both tables + the ETL are registered in dev_r_services; the analytic is registered in dev_r_ai_systems (see §EU AI Act). Migrations: supabase/migrations/20260903161329_ecodriving_per_route_schema_6848.sql (schema) and …161330_register_ecodriving_analytic_6848.sql (compliance), applied by CI via the per-repo _p24_applied_migrations ledger.


The nightly job (scripts/nightly-etl/atrax_ecodriving_anomaly_daily.py)

# default = yesterday (Europe/Berlin); --date to backfill a specific day; --dry-run writes nothing
python3 scripts/nightly-etl/atrax_ecodriving_anomaly_daily.py --env-file /opt/p24-infra/bms-4/.env
python3 scripts/nightly-etl/atrax_ecodriving_anomaly_daily.py --date 2026-09-02 --dry-run --env-file .../.env

Shares atrax_norm_common.py (env loading, PostgREST read/write, Europe/Berlin day boundaries, the Discord + GH-issue Error Notification Standard) and imports the fuel job’s pure statistics + classification (median, mad, robust_sigma, modified_z, percentile, classify_severity, classify_attribution, driver_cross_route_ratio, compute_anchor, baseline_drifted) so the two analytics cannot drift. Active-set (vehicles with a ride event that day) + per-vehicle error isolation, exactly like the sibling jobs.

How the harsh-event signals are derived

For each vehicle, DRIVING events are ordered by event_start; each adjacent pair on the same Berlin day is a leg whose route is (e_i.start_zone -> e_{i+1}.start_zone):

A leg’s speed is its average speed over the segment, avg_speed_kmh = distance / (duration / 3600) — where distance (km) and duration (s) are the confirmed per-event p24_ride_events fields (#7020).

SignalRuleThreshold (default)Per-vehicle source (#7048)
harsh accelΔ(avg leg speed) between consecutive legs ≥ thresholdACCEL_THRESHOLD_KMH = 60.0— (no Atrax column)
harsh brakeΔ(avg leg speed) ≤ −thresholdBRAKE_THRESHOLD_KMH = 60.0— (no Atrax column)
high RPMrpm above thresholdRPM_HIGH_THRESHOLD = 2000.0p24_l_cars_atrax.high_rpm_treshold
speedingavg leg speed above limitSPEED_LIMIT_KMH = 90.0p24_l_cars_atrax.high_speed_treshold

Why not initialSpeed? (#7020) The speed-derived signals originally read Atrax’s initialSpeed and rpm directly. Live verification (2026-09-15) showed both are technically NON-NULL but practically always zero: initialSpeed = 0 in 94.7 % of rows (median 0, p95 1 km/h) and rpm = 0 in 96.9 % — initialSpeed is a start-of-segment snapshot (speed just after pulling away from a stop), not representative of the leg. The snapshot reading was confirmed directionally on live data (#7020 Etap-1, 2026-09-18): segments starting < 120 s after the vehicle’s previous segment ended carry initialSpeed > 0 2.6× as often as long-gap segments (11.7 % vs 4.5 %; median inter-segment gap 477 s vs 786 s) — i.e. a non-zero start speed shows up mainly when the vehicle was still rolling, but even then 88 % read 0, so the field is unusable as a per-leg speed. With those inputs three of the four signals could never fire, so p24_ecodriving_anomalies produced 0 rows for 9+ nights despite a growing set of (vehicle, route) pairs past the cold-start gate. The fix keeps the parser correct and switches the speed basis to the leg’s average speed (distance / duration), which is a real non-zero signal. high_rpm is deliberately kept sourced from per-event rpm (issue option a): it still rarely fires but is harmless, and keeps this job self-contained on p24_ride_events (design §5). Feeding it from the already-working daily p24_driver_ecodriving_daily.max_rpm (option b) is a possible future enhancement, not done here.

Legs are grouped by (route, driver, day) and aggregated into harsh_per_km = Σ harsh events / Σ distance. A zero-distance leg is dropped before aggregation (#7020 — no real contribution to the ratio; ~15 % of legs had distance = 0). A day-route observation below MIN_DAY_DISTANCE_KM = 1.0 km, or outside the plausibility band [0, 50] (is_plausible_harsh_per_km, the ecodriving analogue of the fuel job’s is_plausible_l100km guardrail), is dropped as a telemetry artefact.

The values above are the fleet-wide defaults. Since #7048 the two signals that have a real supplier column (speeding, high RPM) prefer the vehicle’s own calibrated limit configured in Atrax, over the round default — see Per-vehicle calibrated thresholds below. ACCEL_THRESHOLD_KMH / BRAKE_THRESHOLD_KMH have no Atrax column and stay round, human-tunable values (this codebase’s convention of shipping tunable rather than derived thresholds — the #6194 mad_floor / severity-band precedent).

Accel/brake recalibration 15 → 60 km/h (#7020 Etap-1, 2026-09-18). The original 15.0 was carried over from the old instantaneous Δspeed basis; applied to the Δ of average leg speeds it was far too low. Measured over 8,690 legs of live p24_ride_events, |Δ(avg_speed)| has median 16 km/h, p90 58, p95 67 — so at 15 km/h ~52 % of all legs fired accel/brake (the threshold sat on the median: a coin flip per leg), saturating harsh_per_km into noise with no discriminating power for the per-route MAD/modified-Z norm. 60 km/h is ≈ p90 of that distribution: accel fires 4.6 %, brake 4.4 % (≈9 % combined) — a genuine minority, so harsh_per_km again varies across a route’s days and the norm can flag real outliers. Caveat: Δ(avg leg speed) is a coarse proxy — it also picks up ordinary highway→city leg pairs — but the norm scores relative deviation per route, not absolute harshness, so the threshold’s only job is to keep the per-leg signal a non-saturated minority. Re-tune from the same distribution if the fleet’s speed mix shifts.

Signal is alive, anomalies still gated by cold-start. Post-#7020 verification (2026-09-18) over all history confirmed 53.7 % of day-route observations now carry a non-zero harsh_per_km (it was structurally 0 before). p24_ecodriving_anomalies nonetheless stays empty because the per-route norm needs SAMPLE_MIN = 5 route-days: only 118 of 4,276 (vehicle, route) pairs have reached that (a (start_zone → end_zone) route is fine-grained, so most pairs are seen once). This resolves with more days of feeder history — it is not a code defect. If a coarser route key is ever wanted to speed norm formation, that is a change to the 6848 route-derivation design, out of scope for #7020.

Per-vehicle calibrated thresholds (#7048)

p24_l_cars_atrax carries the real per-vehicle limits configured in the Atrax device itself — a city van and a highway tractor have very different physical envelopes, so a single fleet-wide constant is a coarse guess. thresholds_for_vehicle(car_row) resolves each vehicle’s thresholds once per run (one extra p24_l_cars_atrax read in process_vehicle, keyed by atrax_id == atrax_car_id) and feeds them into leg_signals:

Default constant← supplier columnApplied when
SPEED_LIMIT_KMH (90)high_speed_tresholdvalue ≥ MIN_PLAUSIBLE_SPEED_LIMIT_KMH (30 km/h)
RPM_HIGH_THRESHOLD (2000)high_rpm_tresholdvalue ≥ MIN_PLAUSIBLE_RPM_HIGH (500 rpm)

Fallback is deliberately guarded on plausibility, not just NULL. Live verification (2026-09-15, #7048 Etap 1, 264 rows) found the columns populated and genuinely varied — high_speed_treshold is 90 for 238 vehicles, 120 for 16, 92 for 5 — but high_rpm_treshold = 0 for 163/264 vehicles. A 0 there is an Atrax “not configured” sentinel, not a literal 0-rpm limit (which would flag every leg as high-RPM). So any supplier value below the plausibility floor — or NULL, or a missing car row — falls back to the module default rather than being trusted. accel/brake always use the default (no supplier column exists). This layers on top of the #7020 avg_speed fix; it does not change how the speed basis is derived.

Norm + scoring

The per-route norm is computed on the fly from the vehicle’s own p24_ride_events history (median/MAD of the daily harsh_per_km) — there is no separate norm table, this job is self-contained on p24_ride_events. A route norm is trusted only at sample_trips ≥ 5 route-days (SAMPLE_MIN, cold-start gate). The target day’s observations are scored with modified_z, tiered by classify_severity (only watch/escalate are persisted), and attributed via classify_attribution — driver_pattern when the same-route driver slice is itself elevated, else a leave-one-driver-out cross-route ratio gives driver_pattern/low, else vehicle_route. A population-level baseline_drift row is emitted per route whose rolling norm has crept past its anchor. Writes are idempotent: the day’s rows are deleted and re-inserted.

Time-of-day route norm (#7287)

A single per-route norm mixes every departure time into one median/MAD, so a route driven mostly in rush-hour traffic (slower, more start/stop) can read as a style deviation when it is just congestion. When a route has real coverage in ≥2 time-of-day windows — each with ≥SAMPLE_MIN (5) observations — each observation is scored against a separate norm for its own window instead of the combined one:

WindowBerlin-local hours
AM_PEAK06:00–10:00
MIDDAY10:00–16:00
PM_PEAK16:00–20:00
OFFPEAK_NIGHT20:00–06:00
  • The window is taken from the departure time of the observation’s first (earliest) leg (time_bucket_of), in Berlin local time.
  • The split is applied only where it is statistically meaningful. A route covered in a single window — the feasibility check on #7287 found ~92% of (vehicle, route) pairs are — falls back to the combined per-route norm and produces bit-identical output to before. An observation whose own window is too thin (< SAMPLE_MIN) on an otherwise multi-window route also falls back.
  • No schema change: p24_ecodriving_anomalies is unchanged. Which norm was used is visible in the row’s norm_median_harsh_per_km / sample_trips and in an INFO log line naming the window.
  • Deliberately kept on the combined per-route norm (out of scope for this methodology patch): the population-level baseline_drift signal, the driver slice (route, driver) norm (per-driver data is already thin), and the informational vehicle risk-ranking KPI (vehicle_risk_score, #7274 — kept on the combined norm so its historical scale stays stable).
  • The sibling fuel/fraud detector (atrax_route_anomaly_daily.py) shares this methodology but reads a precomputed norm table (p24_route_consumption_norm); applying the same split there needs a norm schema + calibrate-job change and is tracked as a separate follow-up (see #7287).

The feeder job (scripts/nightly-etl/atrax_ride_events_feeder.py, #6889)

# default = yesterday (Europe/Berlin); --date to backfill; --dry-run makes NO Atrax call & no write
python3 scripts/nightly-etl/atrax_ride_events_feeder.py --env-file /opt/p24-infra/bms-4/.env
python3 scripts/nightly-etl/atrax_ride_events_feeder.py --date 2026-09-05 --limit 5 --env-file .../.env
python3 scripts/nightly-etl/atrax_ride_events_feeder.py --dry-run --env-file .../.env

For every fleet vehicle in p24_l_cars_atrax (all rows with a non-null atrax_id whose in_fleet is not explicitly false — currently the whole ~264-vehicle table, since in_fleet is not yet populated; an in_fleet=false backfill would auto-exclude those rows) it fetches the previous Berlin day’s /api/rides/summary and upserts the DRIVING events. It reuses atrax-report-generator.py’s get_atrax_token / fetch_rides / _extract_entry / persist_ride_events verbatim (lazy importlib load — no parser duplication) and atrax_norm_common for env/PostgREST/day-boundaries/error-notification.

  • Idempotent & no double-call: the upsert is ON CONFLICT (atrax_car_id, event_start) DO NOTHING, and vehicles that already have events for the target day (PDF webhook or a prior feeder run) are skipped before any Atrax call.
  • Rate-limiting: one token acquired per run, a --throttle (default 0.3s) sleep between vehicles, and per-vehicle error isolation (a single vehicle’s failure is logged and skipped, never aborts the run). At ~264 vehicles that is well under the nightly window.
  • Credentials: needs the ATRAX_* API creds (present in bms-4/.env) plus Supabase; no object-store creds (it never renders a PDF or uploads to S3). --dry-run reports the plan (fleet / already-fed / would-feed counts) without calling Atrax or writing.

Nightly runtime order

Both ETL steps are wired into the bms-4 nightly chain in scripts/run-atrax-norm-nightly.sh, in this order (the feeder must precede the ecodriving step so the day’s events are fresh in the same pass):

atrax_trip_builder → atrax_route_norm_compute → atrax_route_rolling_avg → atrax_route_anomaly_daily
  → atrax_ride_events_feeder (#6889) → atrax_ecodriving_anomaly_daily (#6848)

The ecodriving step stays a safe no-op until a route has accumulated ≥5 route-days of history (SAMPLE_MIN cold-start gate); the feeder is what makes that history accumulate. A manual --dry-run of either job is safe to run any time. Actual cron scheduling on bms-4 is a sys-admin/infra-task operation — the code ships ready in this PR.


EU AI Act

Driver-level ecodriving output is worker-performance data. The analytic is registered in dev_r_ai_systems as ecodriving-per-route-anomaly and classified limited, affects_people=true, decision_type='human-in-loop'.

It is the first p24-infra analytic that evaluates a named driver’s performance, so it sits adjacent to Annex III.4(a) (“monitoring and evaluating the performance of workers”). It is nonetheless not high-risk because the Annex III trigger is using AI to make or automate an employment decision (discipline, ranking, task allocation) — this analytic does none of that. It is advisory / investigatory only: a human interprets every finding, and the output is never wired into any automated disciplinary/performance/allocation system. That advisory-only guardrail — identical to the one p24_route_consumption_anomalies’ driver_pattern attribution carries (docs/atrax-route-norm-control-operations.md §EU AI Act) — is exactly what keeps it out of the high-risk tier.

⚠️ Wiring this output into any automated employment decision requires a fresh conformity assessment first. Do not ship a driver-facing ranking or feed these rows into any automated disciplinary/performance system without that review.

It is rule-based statistics (median/MAD + fixed thresholds), not ML. Human oversight (Art. 14): advisory GitHub/dashboard signals only. Logging (Art. 12): p24_ecodriving_anomalies rows + the ETL logs + the Discord/GH error notifications. Transparency (Art. 13): this section + dev_r_ai_systems + docs/eu-ai-act-compliance.md §4.

Fleet-anomaly escalation (#7153, plan-7107 PR-1)

Watch/escalate findings from this detector are escalated into the shared p24_fleet_anomaly_escalations table (per-row upsert via atrax_norm_common.escalate_and_digest), with a shared Discord digest for the NEW rows. Fleet managers triage in the et-operational-platform escalation panel. Severity is normalized to warning/critical (plan §2). See docs/atrax-fleet-anomaly-escalation-operations.md.

Known limitation (shared): a rerun that recomputes a row as no longer anomalous does not delete an existing open escalation row — it must be dismissed by a human in the panel. The detection table above keeps its own delete+reinsert-per-day behaviour and is unchanged.