How driving trips are created, extended, closed, and filtered. Companion to
CHARGING_SESSIONS.md. Server logic lives in the
bydmate_ingest_telemetry SQL function; the client-side display filter lives in
src/lib/voltflowmate/trip-filter.ts.
Each telemetry sample drives a server state machine over the user's single open trip
(ended_at is null). This is the complete lifecycle for legacy APKs and daemon-only
samples, which are not tagged client_trip:
- Open — a sample with drive evidence (
v_is_drive_sample:speed_kmh > 5orgear ∈ {D, R, N}) with no open trip creates a newbydmate_tripsrow — unless the same user+vehicle has aclient_tripstarted within the last 7 days (migration20261012120000). On such a vehicle the untagged daemon / fast-mode history sample (~3 s delivery) used to beat the app's client-tagged batch (~15–25 s) for the same drive, open a server-owned trip, and leave a 1-sample, 0 km stub beside the real trip that Rules A/B kept (max speed 10–53 km/h). Every legacy trip on a client-trip vehicle in prod was such a stub, so the guard loses nothing; cars without client trips keep this path.dedupeTripsBySourcealso hides any remaining stub that overlaps a client trip. - Extend — a drive sample within the 5-minute trip gap updates
last_device_time,sample_count,soc_end,max_speed_kmh,avg_speed_kmh, anddistance_km. - Close — the trip closes when a sample shows charging, gear P (with the
speed ≤ 5guard, migration20260612120000), or the 5-minute gap elapses. On close the row is run throughbydmate_discard_trip_if_junk; survivors getbydmate_finalize_trip_energy. - Route-insight projection — at transaction commit, every surviving closed trip with at
least two GPS points refreshes one compact
bydmate_trip_insight_inputsrow. It contains the bounded representative track and the per-trip temperature averages already used by Route Insights. The deferred trigger runs after junk-trip deletion, so discarded rows have no projection; it moves this expensive derivation out of the interactive Analytics request without changing trip facts or GPS collection.
Modern Mate APKs send a cumulative trips[] block beside telemetry. A block has ended_at
only after the local trip is closed. The server applies that aggregate through
bydmate_apply_client_trip; a final block is idempotent, never reopens a trip, and is the
canonical recovery path for drive → P → power off when no final sample reached ingest.
The APK writes the final block to its durable local queue before shutdown, tries an immediate
flush on confirmed P → power off, and retries it on the next app or daemon opportunity. Its
next-boot finalizer closes a stale local trip after 20 minutes. The 5-minute server gap close
is deliberately skipped for a client_trip row, because it could close an active client-owned
trip while later cumulative blocks are still arriving. It remains the fallback for legacy APKs
and untagged daemon traffic.
Some client_trip blocks carry the car's odometer as distance_km (e.g. 27,608 km while the
trip meter reads 11.6 km). bydmate_apply_client_trip no longer copies such a value:
| Block distance | Trip | Stored distance_km |
|---|---|---|
| plausible | any | the block value (as before) |
| implausible | still open | the previous value if plausible, else NULL — never the phantom |
| implausible | closing block (ended_at) |
bydmate_trip_distance_from_samples (odometer delta over the trip's own samples), else NULL |
Implausible = bydmate_trip_distance_plausible is false: > 1,500 km, and nothing else
(migration 20261007150000; the first version also rejected an implied speed > 250 km/h and was
removed). Do not judge a client trip's distance by distance ÷ recorded duration: some trips carry a
truncated duration (15–122 s) with a correct distance (net energy ÷ consumption agrees within ~10 %;
e.g. 8.6 km stored, 8.6 km from energy). On 2,650 client trips (2026-08-01…09-17) 6 were flagged by
speed alone and all 6 were false positives, while all 7 genuine odometer-scale phantoms (> 15,000 km)
are above the cap (every vehicle's odometer is ≥ 5,155 km). It is the SQL twin of
src/lib/voltflowmate/trip-distance-plausibility.ts; change both together.
bydmate_trip_distance_from_samples needs ≥ 2 positive odometer samples within 2 minutes of both
the trip's start and end, so late-delivered samples fail to NULL rather than to a wrong-small
number (on healthy trips it agrees with the stored distance to within max(0.5 km, 5%) for 194 of
198 repairable trips, worst 2.5 km). A real drive with a bad client distance is therefore kept
(repaired or NULL) instead of being deleted by Rule C; parking blips are still dropped by
Rules A/B. Readers treat a NULL distance as unknown (cleanTrips / sanitizeTripDistances).
Rollback: re-apply the bydmate_apply_client_trip body from 20260917120000.
For an already-closed client_trip, the server extends ended_at only when the incoming cumulative block
has a strictly higher sample_count and its last_device_time is later than both the stored
last_device_time and ended_at. The new end is
greatest(stored ended_at, block ended_at, block last_device_time); last_device_time itself never moves
backwards. Equal-count retries remain accepted for aggregate refreshes, but cannot move either time. Open
trips retain their existing close behaviour. This migration intentionally does not repair historical rows:
raw telemetry is not tied to a trip ID, so any history correction needs its own reviewed evidence and plan.
A final client-owned trip also reaches the same deferred route-insight projection trigger when
its ended_at is accepted. It therefore has the same compact route data when it contains GPS,
while summary-only inputs with no track remain absent from route analytics.
bydmate_trip_finalization_audits records the first server acceptance of each final client
block (not an HTTP attempt): client end time, server acceptance time, and delivery delay. It
contains no GPS or raw payload, is visible only to its owner through RLS, and is retained for
30 days for every account tier. Normal P → power off delivery targets under two minutes;
recovery up to 20 minutes is expected.
Ingest stores trip_meter_baseline_km = current_trip_distance_km at trip open and writes
distance_km as meter_now − baseline (with mid-trip reset handling via
bydmate_trip_distance_from_meter). This fixes inherited-meter inflation when the car does
not reset its trip counter between drives (e.g. Cl 94.1 km stored vs ~50.5 km real on
2026-06-14). Migration 20260615120100 backfills closed trips from telemetry open/close samples.
Short phantom trips (parking D→R→P maneuvers inheriting a stale meter for a few seconds)
are still caught by bydmate_discard_trip_if_junk Rules A/B/C below.
Runs automatically at every trip close and deletes the trip + its track points if any rule
matches. Current deployed logic is migration 20261007160000_bydmate_discard_junk_rule_c_skip_client_trips.sql (evolving 20260613150000_fix_junk_trip_discard_v2.sql):
| Rule | Condition | Catches |
|---|---|---|
| A | distance_km ≤ 0.1 AND max_speed_kmh ≤ 3 |
pure parking jitter |
| B | duration < 60 s AND max_speed_kmh < 10 |
slow short maneuvers |
| C | distance_km > 0.3 AND implied speed distance·3600/duration > max(max_speed·1.5, 80) |
inherited trip-meter phantoms — a genuine trip's average can never exceed its max instantaneous speed. Legacy (non-client_trip) trips only since 20261007160000 |
Rule C is the decisive one for inherited-distance phantoms: it caught a 4.5 km / 16 s
(992 km/h implied, max 38) trip that Rule B missed.
Rule C no longer applies to
client_triprows (since20261007160000). Some client trips carry a truncated duration (15–122 s) with a correct distance, so Rule C read "8.6 km / 122 s" as impossible and deleted a real trip at close. From20260917120000(when the junk filter was wired into the client path) until the fix, an estimated ~0.23 % of client trips (≈ 2–3 of 1,184) were lost this way and cannot be recovered (their audit/track rows cascade away). Rule C is for legacy telemetry trips whose distance came from the car's trip meter; for client trips distance comes from odometer baselines and the 1,500 km cap inbydmate_apply_client_tripis the guard. Rules A and B still apply to both paths. Rollback: re-apply the function body without theclient_tripcondition on Rule C.
Migration gotcha: an earlier fix (
20260613130000) was edited after it had already been applied, so its Rule B never reached the DB —supabase db pushskips applied migrations. Never edit an applied migration; always create a new file. Verify the live definition withselect pg_get_functiondef('public.bydmate_discard_trip_if_junk(uuid)'::regprocedure);.
The filter only fires on new closes, so rows created before a filter change persist.
Preview candidates first. In psql, explicitly set user_id, vehicle_id, from_time,
and to_time for the intended owner, vehicle, and timestamp window before running this
query. The window includes from_time and excludes to_time; timestamps should include
a time zone. The preview returns the first matching rule using the server's null handling.
begin read only;
with cand as (
select id, vehicle_id,
extract(epoch from (ended_at - started_at)) as dur_s,
distance_km, max_speed_kmh
from public.bydmate_trips
where user_id = :'user_id'::uuid
and vehicle_id = :'vehicle_id'
and ended_at is not null
and started_at >= :'from_time'::timestamptz
and started_at < :'to_time'::timestamptz
), classified as (
select *, case
when coalesce(distance_km, 0) <= 0.1 and coalesce(max_speed_kmh, 0) <= 3 then 'A'
when coalesce(dur_s, 999) < 60 and coalesce(max_speed_kmh, 0) < 10 then 'B'
when coalesce(dur_s, 0) > 0 and coalesce(distance_km, 0) > 0.3
and distance_km * 3600.0 / nullif(dur_s, 0)
> greatest(coalesce(max_speed_kmh, 0) * 1.5, 80) then 'C'
end as discard_rule
from cand
)
select id, vehicle_id, dur_s, distance_km, max_speed_kmh, discard_rule
from classified
where discard_rule is not null
order by vehicle_id, id;
rollback;This preview never calls bydmate_discard_trip_if_junk: that function deletes trips and
track points. Cleanup is a separate, explicitly authorized write operation against reviewed
trip IDs, with owner and vehicle scope rechecked and a recovery copy prepared beforehand.
Do not turn the preview SELECT into a function call and continue treating it as a dry run.
isJunkTrip() hides junk in the trip browser / analytics UI. < 3-sample trips
without movement evidence, so it would not hide an inherited-distance phantom if one were
served. The server discard is the authoritative gate; sync Rules B/C into this file if phantoms
ever surface in the UI.
Before migration 20260612120000, CommandDaemon heartbeats with reduced-payload gear=1 (P)
at driving speed closed the live trip every ~60 s → 1-minute fragments. Fixed two ways:
a speed ≤ 5 guard on the gear-P close, and (v0.3.9.5) the daemon staying silent on telemetry
while the app is alive. See BYDMate-own/docs/REMOTE_COMMAND_DAEMON.md.