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/summaryridesRowsList— no new Atrax report is required. It also confirmed the raw entries carry telemetry, not pre-aggregated harsh-event counters: there is noharsh_accel_count/speeding_*/maxSpeed/maxRpmfield. 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:
- Primary —
atrax-ride-events-feeder(#6889): a nightly job that systematically pulls the previous Berlin day’s/api/rides/summaryfor every fleet vehicle and upserts its DRIVING events. This is the source the analytic actually depends on. - 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_eventshad grown to just 12 rows / 1 vehicle — because reports are only ever generated on demand — so the ecodriving ETL below could never clear itssample_trips >= 5cold-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 .../.envShares 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).
| Signal | Rule | Threshold (default) | Per-vehicle source (#7048) |
|---|---|---|---|
| harsh accel | Δ(avg leg speed) between consecutive legs ≥ threshold | ACCEL_THRESHOLD_KMH = 60.0 | — (no Atrax column) |
| harsh brake | Δ(avg leg speed) ≤ −threshold | BRAKE_THRESHOLD_KMH = 60.0 | — (no Atrax column) |
| high RPM | rpm above threshold | RPM_HIGH_THRESHOLD = 2000.0 | p24_l_cars_atrax.high_rpm_treshold |
| speeding | avg leg speed above limit | SPEED_LIMIT_KMH = 90.0 | p24_l_cars_atrax.high_speed_treshold |
Why not
initialSpeed? (#7020) The speed-derived signals originally read Atrax’sinitialSpeedandrpmdirectly. Live verification (2026-09-15) showed both are technically NON-NULL but practically always zero:initialSpeed = 0in 94.7 % of rows (median 0, p95 1 km/h) andrpm = 0in 96.9 % —initialSpeedis 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 carryinitialSpeed > 02.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, sop24_ecodriving_anomaliesproduced 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_rpmis deliberately kept sourced from per-eventrpm(issue option a): it still rarely fires but is harmless, and keeps this job self-contained onp24_ride_events(design §5). Feeding it from the already-working dailyp24_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.0was 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 livep24_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), saturatingharsh_per_kminto noise with no discriminating power for the per-route MAD/modified-Z norm.60 km/his ≈ p90 of that distribution: accel fires 4.6 %, brake 4.4 % (≈9 % combined) — a genuine minority, soharsh_per_kmagain 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 column | Applied when |
|---|---|---|
SPEED_LIMIT_KMH (90) | high_speed_treshold | value ≥ MIN_PLAUSIBLE_SPEED_LIMIT_KMH (30 km/h) |
RPM_HIGH_THRESHOLD (2000) | high_rpm_treshold | value ≥ 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:
| Window | Berlin-local hours |
|---|---|
AM_PEAK | 06:00–10:00 |
MIDDAY | 10:00–16:00 |
PM_PEAK | 16:00–20:00 |
OFFPEAK_NIGHT | 20: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_anomaliesis unchanged. Which norm was used is visible in the row’snorm_median_harsh_per_km/sample_tripsand in anINFOlog line naming the window. - Deliberately kept on the combined per-route norm (out of scope for this methodology patch): the
population-level
baseline_driftsignal, 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 .../.envFor 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 inbms-4/.env) plus Supabase; no object-store creds (it never renders a PDF or uploads to S3).--dry-runreports 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.