Skip to content

fix(segments): recharge detection for devices that sleep through charging - #307

Merged
zer0stars merged 4 commits into
mainfrom
feat/recharge-sleep-aware
Sep 10, 2026
Merged

fix(segments): recharge detection for devices that sleep through charging#307
zer0stars merged 4 commits into
mainfrom
feat/recharge-sleep-aware

Conversation

@zer0stars

@zer0stars zer0stars commented Sep 10, 2026

Copy link
Copy Markdown
Member

Problem

segments(mechanism: recharge) smoothed SoC over 11 samples, not 11 minutes. Aftermarket devices (Ruptela, AutoPi, Macaron, most OEM connections) report every 1–3 s while awake and nothing while asleep, so the window straddled sleep gaps, pulled the trough/peak into the drives on either side, and the odometer rule rejected the session. The mobile app's Charging habits card depends on these segments for vehicles that have SoC but no power/energy signals.

Evidence from vehicle 192097 (2025 VW ID. Buzz, Ruptela R1), Aug 12 – Sep 11 2026:

Session Raw records Before After
Sep 8 19:19 → Sep 9 12:49, 44→79 ignition-off reading, 1,048 min silence, first SoC after wake-up is the departure missed 19:19 → 12:49, maxSampleGapSeconds = 63,000
Aug 23 00:27 → Aug 24 12:10, 40→78 36 h with three wake-ups carrying odometer but no SoC missed 00:29 → 12:10, gap = whole span
Sep 1 13:18 → 15:47, 14→23 slow charge at one spot, then a 3 km hop with no SoC drop missed reported
Sep 1 16:17 → Sep 2 17:02, 22→76 last SoC while still rolling to the second spot, silent 4 h, awake and charging to 56 at 23:28, silent again, 76 next afternoon without moving missed (trough landed in the pre-parking drive) one session 22→76, gap 1,054 of 1,485 min
Aug 25 22:02 → Aug 26 11:12, 35→95 silent 790 min detected still detected
Aug 20 18:41–18:43, 17→80 19,18,17,17 then 80,79 at 6 km/h — sensor glitch reported as a 90 s session rejected (2,500 %/h)

Note on Sep 1: the 1-minute export shows SoC 22 at 16:15–16:17 while the odometer still ticked, so 22 (not the 23 read at 15:47) is the last reading before parking at the second spot. The charge then continues monotonically to 76 on Sep 2 with the odometer unchanged, so it is one plug-in at one location, reported as one stint with the unobserved portion in maxSampleGapSeconds.

Design (one regime-aware recharge, no new mechanism)

  • Monotone runs on raw samples, no smoothing. A run survives dips up to 1.0 below its peak (integer staircase, Tesla float jitter). The peak is the first sample reaching the maximum, so a run never bleeds into the departure drive.
  • Anchor on the stationary core. Walk odometer back from the peak (0.5 km epsilon) to find when the car last moved. The start reading is the lowest SoC between the last moving and the first stationary odometer sample (so a sparse 5-minute Tesla odometer does not lag the start), bounded by the trough, then advanced through equal readings (flat lead-in; also covers sub-km moves an integer odometer cannot show). If the car moved after the trough, the part of the run before the move is evaluated on its own, so a charge followed by a short hop to a second charger is not folded into the second. No odometer data keeps today's behaviour.
  • Rise and rate. Rise ≥ minIncreasePercent (default 1, honoured from SegmentConfig) and > the 1.0 run tolerance (a one-point change on integer OBD is quantization flicker: 46,46,47,46 showed up three times on 192097). Rate cap 600 %/h drops sensor glitches.
  • Merging (gap ≤ 2 h, same odometer within 0.5 km at both peaks); maxSampleGapSeconds is computed over the merged range.
  • Structure. detectRechargeSessions is a pure function over SoC/odometer slices; DetectSegments only queries and calls it. smoothSamples, findTroughToPeakRanges, filterRangesBySocAndOdo and their tests are gone.

Schema

type Segment gains maxSampleGapSeconds: Int (nullable): the longest interval inside the segment with no SoC sample. Set for recharge, null elsewhere. The recharge doc string now says duration is an upper bound for devices that sleep through a charge, that a session is only reported when both bounding readings fall inside [from, to], and that consecutive charges at the same odometer with no drop are one session. make generate was run; the gqlgen and MCP diffs are limited to the new field and doc string.

Also in this PR

Exclusive after cursor fix (internal/repositories/segments.go, all mechanisms, pre-existing): after + 1ns is truncated to microseconds in the ClickHouse bound, so the segment starting exactly at the cursor was returned again on the next page. Found by the end-to-end pagination check below; segments with start <= after are now dropped before the limit is applied.

Verification

  • go test ./... (incl. the e2e RechargeDetector case against ClickHouse), go vet ./..., make lint: clean.
  • Table tests for the shapes above plus float jitter, integer staircase, one-point flicker, regen while driving, hop between two charges, sparse odometer, min-rise override, min-duration, no-odometer, duplicate timestamps, two-session merge and drive-separated sessions.
  • Pure-core harness (internal/service/ch/recharge_realdata_test.go, RECHARGE_REALDATA_JSON, skips when unset; nothing committed) against 1-minute LAST exports.
  • End-to-end harness (e2e/recharge_realdata_test.go, same env var): loads the export into the ClickHouse container and runs the real HTTP stack: JWT auth, segments resolver, repository summary FIRST/LAST signals, minIncreasePercent override, limit/after pagination, maxSampleGapSeconds null on frequencyAnalysis and refuel empty, and the MCP telemetry_get_trip_segments tool returning the same session count as GraphQL.
Vehicle Before (prod) After Notes
192097 Ruptela R1 11 (10 real + Aug 20 glitch) 26 all 10 real sessions kept (Aug 16 start shifts 8 min later within the flat 26% lead-in), 3 target sessions recovered, 2 slow charges before a hop recovered, glitch gone (plus a second one-minute 14→95 glitch on Aug 28), 11 more overnight/fast-charge sessions found; September-only window yields 5; API summary for Sep 8 reads 44→79, duration 63,000 s, gap 63,000 s
193616 Tesla Model 3 2026 7 14 all 7 kept with the same start readings as prod; 7 additional dense fast-charge sessions (gap 60–180 s) the old odometer check dropped because the Tesla odometer is sparse (619 samples vs 6,114 SoC)

An independent review pass (code + harness) was run on the first revision; its findings (hop between charges, sparse odometer lag, stale MCP schema, range-boundary docs) are addressed in the second commit.

Follow-ups (not in this PR)

  • Summary LAST SoC can read one or two points below the peak. The repositories layer extends the summary window 2 minutes past end for refuel/recharge (sized for the old lagging peak). With end now at the first sample reaching the max, a car that departs within 2 minutes contributes a lower post-peak reading: 1 of 26 Ruptela sessions (Sep 3 22:45, 18→76 vs peak 78) and 3 of 14 Tesla sessions (one point low). Dropping the buffer for recharge is a one-line change in internal/repositories/segments.go but touches the summary computation, which was out of scope here.
  • Sessions that start before from or end after to are invisible because SoC/odometer are only loaded for [from, to); a sleeping device needs both bounding readings inside the window. A ±48 h lookback/lookahead with clipping and startedBeforeRange would close that.

…rough charging

The recharge detector smoothed SoC over 11 samples, not 11 minutes. Aftermarket
devices (Ruptela, AutoPi, Macaron, most OEM connections) report every 1-3 s while
awake and nothing while asleep, so the window straddled sleep gaps, pulled the
trough and peak into the drives on either side, and the odometer rule rejected
the session. Vehicle 192097 (Ruptela R1) lost 3 overnight charges in 31 days and
reported a 90-second sensor glitch as a session.

Replace smoothing with a pure detector over raw samples:
- monotone runs tolerate dips of 1.0 (integer staircase, float jitter); the peak
  is the first sample reaching the maximum so a run never bleeds into departure
- the start reading is the last SoC at or before the car last moved (odometer
  walk-back within 0.5 km), bounded by the trough, skipping the flat lead-in
- a rise must exceed the run tolerance and stay under 600 %/h
- sessions within 2 h with unchanged odometer still merge

Add Segment.maxSampleGapSeconds (recharge only): the longest interval with no
SoC sample, so clients can tell how much of the duration was unobserved.

Add a local real-data harness (RECHARGE_REALDATA_JSON) that skips when unset.
…al window

Review findings on the first revision:
- a charge followed by a short hop to a second charger with no SoC drop was
  folded into the second session (twice on 192097 in one month); the part of a
  run before the last movement is now evaluated on its own
- a sparse odometer (Tesla, 5 min) lagged the start by 2-8 SoC points; the
  start reading is now the lowest SoC between the last moving and the first
  stationary odometer sample
- merge compares the odometer at each peak within epsilon instead of exact
  equality at boundary samples that two sessions may share
- odometer walk-back uses the absolute step so a backwards glitch does not
  count as stationary; cheap raw-rise check before the walk-back
- run make generate so the MCP condensed schema carries the new field
- document the [from, to] boundary and the monotone-continuation behaviour
Loads a local SoC/odometer export (RECHARGE_REALDATA_JSON, skipped when unset,
never committed) into the ClickHouse container and exercises JWT auth, the
segments resolver, repository summary signals, config overrides, pagination and
the MCP telemetry_get_trip_segments tool.
after+1ns is truncated to microseconds in the ClickHouse bound, so the segment
starting exactly at the cursor was returned again on the next page for every
mechanism. Drop segments with start <= after before applying the limit.
@zer0stars
zer0stars merged commit 2f0519b into main Sep 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant