From 547cf00ff1796bb65cc1ef631f5af82bddab930c Mon Sep 17 00:00:00 2001 From: zer0stars <74260741+zer0stars@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:29:49 -0400 Subject: [PATCH 1/4] fix(segments): make recharge detection work for devices that sleep through 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. --- internal/graph/generated.go | 64 +++- internal/graph/model/models_gen.go | 8 +- internal/service/ch/recharge_detector.go | 250 +++++++++------ internal/service/ch/recharge_detector_test.go | 292 +++++++++++------- internal/service/ch/recharge_realdata_test.go | 120 +++++++ schema/segments.graphqls | 10 +- 6 files changed, 523 insertions(+), 221 deletions(-) create mode 100644 internal/service/ch/recharge_realdata_test.go diff --git a/internal/graph/generated.go b/internal/graph/generated.go index da1979a3..76d8d05c 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -123,13 +123,14 @@ type ComplexityRoot struct { } Segment struct { - Duration func(childComplexity int) int - End func(childComplexity int) int - EventCounts func(childComplexity int) int - IsOngoing func(childComplexity int) int - Signals func(childComplexity int) int - Start func(childComplexity int) int - StartedBeforeRange func(childComplexity int) int + Duration func(childComplexity int) int + End func(childComplexity int) int + EventCounts func(childComplexity int) int + IsOngoing func(childComplexity int) int + MaxSampleGapSeconds func(childComplexity int) int + Signals func(childComplexity int) int + Start func(childComplexity int) int + StartedBeforeRange func(childComplexity int) int } SignalAggregationValue struct { @@ -956,6 +957,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Segment.IsOngoing(childComplexity), true + case "Segment.maxSampleGapSeconds": + if e.ComplexityRoot.Segment.MaxSampleGapSeconds == nil { + break + } + + return e.ComplexityRoot.Segment.MaxSampleGapSeconds(childComplexity), true case "Segment.signals": if e.ComplexityRoot.Segment.Signals == nil { break @@ -3615,7 +3622,10 @@ directive @mcpHide on FIELD_DEFINITION refuel """ - Recharge: Hybrid detection. Uses charging signals and state of charge for detection. + Recharge: Detects where battery state of charge rises while the vehicle is stationary. + Aftermarket devices often sleep through a charge, so the segment spans from the last + reading before the car stopped to the first reading after it woke: duration is an + upper bound, and maxSampleGapSeconds reports the unobserved portion. """ recharge } @@ -3749,6 +3759,11 @@ type Segment { startedBeforeRange: Boolean! signals: [SignalAggregationValue!] eventCounts: [EventCount!] + """ + Longest interval in seconds inside the segment with no state-of-charge sample. + Set for recharge only (devices that sleep while charging report nothing until they wake), null otherwise. + """ + maxSampleGapSeconds: Int } `, BuiltIn: false}, {Name: "../../schema/signals-events_gen.graphqls", Input: `# Code generated with ` + "`" + `make gql-model` + "`" + ` DO NOT EDIT. @@ -10066,6 +10081,8 @@ func (ec *executionContext) fieldContext_Query_segments(ctx context.Context, fie return ec.fieldContext_Segment_signals(ctx, field) case "eventCounts": return ec.fieldContext_Segment_eventCounts(ctx, field) + case "maxSampleGapSeconds": + return ec.fieldContext_Segment_maxSampleGapSeconds(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Segment", field.Name) }, @@ -10587,6 +10604,35 @@ func (ec *executionContext) fieldContext_Segment_eventCounts(_ context.Context, return fc, nil } +func (ec *executionContext) _Segment_maxSampleGapSeconds(ctx context.Context, field graphql.CollectedField, obj *model.Segment) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Segment_maxSampleGapSeconds, + func(ctx context.Context) (any, error) { + return obj.MaxSampleGapSeconds, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Segment_maxSampleGapSeconds(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Segment", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _SignalAggregationValue_name(ctx context.Context, field graphql.CollectedField, obj *model.SignalAggregationValue) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -29749,6 +29795,8 @@ func (ec *executionContext) _Segment(ctx context.Context, sel ast.SelectionSet, out.Values[i] = ec._Segment_signals(ctx, field, obj) case "eventCounts": out.Values[i] = ec._Segment_eventCounts(ctx, field, obj) + case "maxSampleGapSeconds": + out.Values[i] = ec._Segment_maxSampleGapSeconds(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 338685b4..959d26ba 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -142,6 +142,9 @@ type Segment struct { StartedBeforeRange bool `json:"startedBeforeRange"` Signals []*SignalAggregationValue `json:"signals,omitempty"` EventCounts []*EventCount `json:"eventCounts,omitempty"` + // Longest interval in seconds inside the segment with no state-of-charge sample. + // Set for recharge only (devices that sleep while charging report nothing until they wake), null otherwise. + MaxSampleGapSeconds *int `json:"maxSampleGapSeconds,omitempty"` } type SegmentConfig struct { @@ -721,7 +724,10 @@ const ( DetectionMechanismIdling DetectionMechanism = "idling" // Refuel: Detects where fuel level rises significantly. DetectionMechanismRefuel DetectionMechanism = "refuel" - // Recharge: Hybrid detection. Uses charging signals and state of charge for detection. + // Recharge: Detects where battery state of charge rises while the vehicle is stationary. + // Aftermarket devices often sleep through a charge, so the segment spans from the last + // reading before the car stopped to the first reading after it woke: duration is an + // upper bound, and maxSampleGapSeconds reports the unobserved portion. DetectionMechanismRecharge DetectionMechanism = "recharge" ) diff --git a/internal/service/ch/recharge_detector.go b/internal/service/ch/recharge_detector.go index 7fa56af7..40d8b003 100644 --- a/internal/service/ch/recharge_detector.go +++ b/internal/service/ch/recharge_detector.go @@ -3,6 +3,7 @@ package ch import ( "context" "fmt" + "sort" "time" "github.com/ClickHouse/clickhouse-go/v2" @@ -14,11 +15,12 @@ const ( rechargeDefaultMinDurationSeconds = 60 // shorter default than other detectors — charge sessions can be brief rechargeSessionGapMax = 2 * time.Hour // merge consecutive segments if gap ≤ this and odometer unchanged rechargeOdometerEpsilonKm = 0.5 // allow odometer increase ≤ this (noise) to still count as stationary - rechargeSmoothWindow = 11 // rolling average window for SoC smoothing (~11 samples ≈ 11 min) - rechargeMinRisePct = 1.0 // trough-to-peak must rise at least this much to be a candidate + rechargeMinRisePct = 1.0 // start-to-peak must rise at least this much to be a session (and always more than rechargeRunTolerancePct) + rechargeRunTolerancePct = 1.0 // a monotone run survives dips up to this far below its peak (OBD integer staircase, Tesla float jitter) + rechargeMaxRatePctPerHour = 600.0 // physical cap: a real session on a small pack at 350 kW stays under ~400 %/h; sensor glitches are far above ) -// RechargeDetector detects recharge segments by finding trough-to-peak rises in the SoC curve. +// RechargeDetector detects recharge segments by finding rises in the raw SoC curve while the vehicle is stationary. type RechargeDetector struct { conn clickhouse.Conn } @@ -28,7 +30,7 @@ func NewRechargeDetector(conn clickhouse.Conn) *RechargeDetector { return &RechargeDetector{conn: conn} } -// DetectSegments finds periods where state of charge rises (trough to peak), filters by odometer, and merges nearby sessions. +// DetectSegments loads SoC and odometer samples and runs the pure recharge detection over them. func (d *RechargeDetector) DetectSegments( ctx context.Context, subject string, @@ -44,136 +46,182 @@ func (d *RechargeDetector) DetectSegments( if config != nil && config.MinIncreasePercent != nil && *config.MinIncreasePercent > 0 { minRisePct = float64(*config.MinIncreasePercent) } - return detectRechargeSegments(ctx, d.conn, subject, from, to, rc.minDuration, minRisePct) -} - -// GetMechanismName returns the name of this detection mechanism. -func (d *RechargeDetector) GetMechanismName() string { - return "recharge" -} -// detectRechargeSegments: 2 CH queries (SoC + odometer), then all processing in-memory. -func detectRechargeSegments(ctx context.Context, conn clickhouse.Conn, subject string, from, to time.Time, minDuration int, minRisePct float64) ([]*model.Segment, error) { - // Query 1: SoC samples (returned sorted by CH) - socSamples, err := getLevelSamples(ctx, conn, subject, vss.FieldPowertrainTractionBatteryStateOfChargeCurrent, from, to) + socSamples, err := getLevelSamples(ctx, d.conn, subject, vss.FieldPowertrainTractionBatteryStateOfChargeCurrent, from, to) if err != nil { return nil, fmt.Errorf("failed to query SoC samples: %w", err) } - if len(socSamples) < rechargeSmoothWindow+2 { + if len(socSamples) < 2 { return []*model.Segment{}, nil } - - // Query 2: Odometer samples (returned sorted by CH) - odoSamples, err := getLevelSamples(ctx, conn, subject, vss.FieldPowertrainTransmissionTravelledDistance, from, to) + odoSamples, err := getLevelSamples(ctx, d.conn, subject, vss.FieldPowertrainTransmissionTravelledDistance, from, to) if err != nil { return nil, fmt.Errorf("failed to query odometer samples: %w", err) } - // Step 1: Smooth SoC to eliminate per-sample noise - smoothed := smoothSamples(socSamples, rechargeSmoothWindow) + sessions := detectRechargeSessions(socSamples, odoSamples, rc.minDuration, minRisePct) + return rechargeSessionsToSegments(sessions, from), nil +} - // Step 2: Find trough-to-peak ranges from smoothed curve - candidates := findTroughToPeakRanges(smoothed, minRisePct, minDuration) +// GetMechanismName returns the name of this detection mechanism. +func (d *RechargeDetector) GetMechanismName() string { + return "recharge" +} - // Step 3: Filter by SoC increase and odometer non-increase - filtered := filterRangesBySocAndOdo(candidates, socSamples, odoSamples) +// rechargeSession is a detected charging session with its longest unobserved interval. +type rechargeSession struct { + start, end time.Time + maxSampleGapSeconds int +} - // Step 4: Merge consecutive sessions (with odometer check) +// detectRechargeSessions is the pure detection core: it walks raw SoC samples for monotone runs, validates each +// run against the odometer (the rise must happen while the car is stationary) and a physical rate cap, then merges +// sessions that are close in time with an unchanged odometer. +// +// Devices that sleep while charging report nothing between the last parked reading and the wake-up, so a session +// may consist of just two samples; no smoothing is applied and sample count is never used as a proxy for time. +// soc and odo must be sorted by ts. +func detectRechargeSessions(soc, odo []levelSample, minDuration int, minRisePct float64) []rechargeSession { + if len(soc) < 2 { + return nil + } + var candidates []timeRange + for _, run := range findMonotoneRuns(soc) { + if tr, ok := validateRechargeRun(soc, odo, run, minDuration, minRisePct); ok { + candidates = append(candidates, tr) + } + } + if len(candidates) == 0 { + return nil + } shouldMerge := func(a, b timeRange) bool { - _, odoCurEnd, ok1 := levelFirstLastInRange(odoSamples, a.start, a.end) - odoNextStart, _, ok2 := levelFirstLastInRange(odoSamples, b.start, b.end) + _, odoCurEnd, ok1 := levelFirstLastInRange(odo, a.start, a.end) + odoNextStart, _, ok2 := levelFirstLastInRange(odo, b.start, b.end) return ok1 && ok2 && odoCurEnd == odoNextStart } - // Merge with zero from/to to skip clipping (already filtered/clipped upstream) - merged := mergeTimeRanges(filtered, rechargeSessionGapMax, minDuration, time.Time{}, time.Time{}, shouldMerge) - - return timeRangesToSegments(merged, from), nil -} - -// smoothSamples applies a rolling average over the given window size. -// Timestamps are taken from the center sample of each window. -// Uses per-position summation for exact floating-point reproducibility. -func smoothSamples(samples []levelSample, window int) []levelSample { - if window <= 1 || len(samples) <= window { - return samples - } - half := window / 2 - wf := float64(window) - out := make([]levelSample, 0, len(samples)-window+1) - for i := half; i < len(samples)-half; i++ { - sum := 0.0 - for j := i - half; j <= i+half; j++ { - sum += samples[j].value - } - out = append(out, levelSample{ts: samples[i].ts, value: sum / wf}) + // Zero from/to: no clipping, the samples were already loaded for [from, to). + merged := mergeTimeRanges(candidates, rechargeSessionGapMax, minDuration, time.Time{}, time.Time{}, shouldMerge) + out := make([]rechargeSession, 0, len(merged)) + for _, tr := range merged { + out = append(out, rechargeSession{start: tr.start, end: tr.end, maxSampleGapSeconds: maxSampleGapSeconds(soc, tr)}) } return out } -// findTroughToPeakRanges walks smoothed SoC samples and finds every rise from a local trough to a local peak. -func findTroughToPeakRanges(samples []levelSample, minRisePct float64, minDuration int) []timeRange { - if len(samples) < 2 { - return nil - } - - const ( - dirRising = 1 - dirFalling = -1 - ) - - var ranges []timeRange - dir := 0 - troughIdx := 0 - peakIdx := 0 +// monotoneRun is a candidate rise over raw samples: indices into the SoC slice. +type monotoneRun struct { + troughIdx int // lowest sample before the rise + peakIdx int // first sample reaching the run's maximum +} - for i := 1; i < len(samples); i++ { - diff := samples[i].value - samples[i-1].value - if diff > 0 { - if dir == dirFalling { - troughIdx = i - 1 - } - dir = dirRising +// findMonotoneRuns splits the SoC series into rises. A run starts at a trough and continues while SoC does not +// fall more than rechargeRunTolerancePct below the run's peak. The peak is the first sample reaching the maximum, +// so the run never bleeds into the departure drive that follows a sleeping charge. +func findMonotoneRuns(soc []levelSample) []monotoneRun { + var runs []monotoneRun + troughIdx, peakIdx := 0, 0 + for i := 1; i < len(soc); i++ { + v := soc[i].value + peakVal := soc[peakIdx].value + switch { + case v > peakVal: peakIdx = i - } else if diff < 0 { - if dir == dirRising { - appendTroughToPeak(samples, troughIdx, peakIdx, minRisePct, minDuration, &ranges) + case v < peakVal-rechargeRunTolerancePct: + // The run is over; a new one starts at this lower sample. + if peakIdx > troughIdx { + runs = append(runs, monotoneRun{troughIdx: troughIdx, peakIdx: peakIdx}) } - dir = dirFalling + troughIdx, peakIdx = i, i + case v < soc[troughIdx].value: + // Still drifting down within tolerance and below the trough: the rise has not started yet. + troughIdx, peakIdx = i, i } } - if dir == dirRising { - appendTroughToPeak(samples, troughIdx, peakIdx, minRisePct, minDuration, &ranges) + if peakIdx > troughIdx { + runs = append(runs, monotoneRun{troughIdx: troughIdx, peakIdx: peakIdx}) } - return ranges + return runs } -// appendTroughToPeak appends a timeRange if the rise meets minimum criteria. -func appendTroughToPeak(samples []levelSample, troughIdx, peakIdx int, minRisePct float64, minDuration int, out *[]timeRange) { - rise := samples[peakIdx].value - samples[troughIdx].value - if rise < minRisePct { - return +// validateRechargeRun anchors a run on its stationary core and applies the rise, duration and rate checks. +// The session start reading is the last SoC sample at or before the car last moved (bounded below by the trough), +// so a reading taken while still driving to the charger counts and energy is not under-reported; equal readings +// after that point are skipped so the session starts when SoC last sat at its start value. +func validateRechargeRun(soc, odo []levelSample, run monotoneRun, minDuration int, minRisePct float64) (timeRange, bool) { + peak := soc[run.peakIdx] + startIdx := run.troughIdx + if stationaryStart, ok := stationaryStartBefore(odo, peak.ts); ok { + // Last SoC sample at or before the car stopped moving. + idx := sort.Search(len(soc), func(i int) bool { return soc[i].ts.After(stationaryStart) }) - 1 + if idx > startIdx { + startIdx = idx + } + } + // Skip the flat lead-in: while the next sample has not risen above the start reading the charge has not + // begun. This also absorbs sub-epsilon drives that an integer odometer cannot show. + for startIdx+1 < run.peakIdx && soc[startIdx+1].value <= soc[startIdx].value { + startIdx++ + } + start := soc[startIdx] + rise := peak.value - start.value + // A rise within the run tolerance is indistinguishable from quantization flicker (46,46,47,46 on integer OBD). + if rise < minRisePct || rise <= rechargeRunTolerancePct { + return timeRange{}, false } - start := samples[troughIdx].ts - end := samples[peakIdx].ts - if int(end.Sub(start).Seconds()) < minDuration { - return + dur := peak.ts.Sub(start.ts) + if int(dur.Seconds()) < minDuration { + return timeRange{}, false } - *out = append(*out, timeRange{start: start, end: end}) + if rise/dur.Hours() > rechargeMaxRatePctPerHour { + return timeRange{}, false + } + return timeRange{start: start.ts, end: peak.ts}, true } -// filterRangesBySocAndOdo keeps only ranges where SoC increased and odometer did not increase beyond epsilon. -func filterRangesBySocAndOdo(ranges []timeRange, socSamples, odoSamples []levelSample) []timeRange { - out := make([]timeRange, 0, len(ranges)) - for _, tr := range ranges { - socFirst, socLast, socOk := levelFirstLastInRange(socSamples, tr.start, tr.end) - if !socOk || socLast <= socFirst { - continue - } - odoFirst, odoLast, odoOk := levelFirstLastInRange(odoSamples, tr.start, tr.end) - if odoOk && (odoLast-odoFirst) > rechargeOdometerEpsilonKm { - continue +// stationaryStartBefore walks odometer samples backward from t and returns the timestamp of the earliest sample +// after which the odometer stayed within rechargeOdometerEpsilonKm of its value at t, i.e. when the car last moved. +// ok is false when there is no odometer sample at or before t (no odometer data: caller keeps the trough). +func stationaryStartBefore(odo []levelSample, t time.Time) (time.Time, bool) { + last := sort.Search(len(odo), func(i int) bool { return odo[i].ts.After(t) }) - 1 + if last < 0 { + return time.Time{}, false + } + odoAtPeak := odo[last].value + k := last + for k > 0 && odoAtPeak-odo[k-1].value <= rechargeOdometerEpsilonKm { + k-- + } + return odo[k].ts, true +} + +// maxSampleGapSeconds returns the longest interval within tr with no SoC sample, including the lead-in from +// tr.start to the first sample and the tail from the last sample to tr.end. +func maxSampleGapSeconds(soc []levelSample, tr timeRange) int { + first := sort.Search(len(soc), func(i int) bool { return !soc[i].ts.Before(tr.start) }) + prev := tr.start + maxGap := time.Duration(0) + for i := first; i < len(soc) && !soc[i].ts.After(tr.end); i++ { + if g := soc[i].ts.Sub(prev); g > maxGap { + maxGap = g } - out = append(out, tr) + prev = soc[i].ts + } + if g := tr.end.Sub(prev); g > maxGap { + maxGap = g + } + return int(maxGap.Seconds()) +} + +// rechargeSessionsToSegments converts sessions to model segments, setting MaxSampleGapSeconds. +// Returns an empty (non-nil) slice when there are no sessions. +func rechargeSessionsToSegments(sessions []rechargeSession, from time.Time) []*model.Segment { + out := make([]*model.Segment, 0, len(sessions)) + for _, s := range sessions { + end := s.end + seg := newSegment(s.start, &end, int32(end.Sub(s.start).Seconds()), false, !s.start.After(from)) + gap := s.maxSampleGapSeconds + seg.MaxSampleGapSeconds = &gap + out = append(out, seg) } return out } diff --git a/internal/service/ch/recharge_detector_test.go b/internal/service/ch/recharge_detector_test.go index 047bc91e..1389f871 100644 --- a/internal/service/ch/recharge_detector_test.go +++ b/internal/service/ch/recharge_detector_test.go @@ -7,137 +7,209 @@ import ( "github.com/stretchr/testify/require" ) -func TestSmoothSamples(t *testing.T) { - base := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) - min := func(m int) time.Time { return base.Add(time.Duration(m) * time.Minute) } - - t.Run("window=1 returns input unchanged", func(t *testing.T) { - samples := []levelSample{{ts: min(0), value: 10}, {ts: min(1), value: 20}} - result := smoothSamples(samples, 1) - require.Equal(t, samples, result) - }) - - t.Run("window larger than samples returns input unchanged", func(t *testing.T) { - samples := []levelSample{{ts: min(0), value: 10}, {ts: min(1), value: 20}} - result := smoothSamples(samples, 5) - require.Equal(t, samples, result) - }) - - t.Run("window=3 computes rolling average", func(t *testing.T) { - samples := []levelSample{ - {ts: min(0), value: 10}, - {ts: min(1), value: 20}, - {ts: min(2), value: 30}, - {ts: min(3), value: 40}, - {ts: min(4), value: 50}, - } - result := smoothSamples(samples, 3) - require.Len(t, result, 3) // 5 - 3 + 1 - // avg of [10,20,30] = 20, ts from center (min(1)) - require.InDelta(t, 20.0, result[0].value, 0.01) - require.Equal(t, min(1), result[0].ts) - // avg of [20,30,40] = 30 - require.InDelta(t, 30.0, result[1].value, 0.01) - // avg of [30,40,50] = 40 - require.InDelta(t, 40.0, result[2].value, 0.01) - }) +// pt is a (minute offset, value) pair used to build synthetic sample series. +type pt struct { + m float64 + v float64 } -func TestFindTroughToPeakRanges(t *testing.T) { - base := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) - min := func(m int) time.Time { return base.Add(time.Duration(m) * time.Minute) } - - t.Run("empty returns nil", func(t *testing.T) { - require.Nil(t, findTroughToPeakRanges(nil, 1.0, 61)) - require.Nil(t, findTroughToPeakRanges([]levelSample{{ts: min(0), value: 10}}, 1.0, 61)) - }) +func newSeries(base time.Time, pts ...pt) []levelSample { + out := make([]levelSample, 0, len(pts)) + for _, p := range pts { + out = append(out, levelSample{ts: base.Add(time.Duration(p.m * float64(time.Minute))), value: p.v}) + } + return out +} - t.Run("single rise detected", func(t *testing.T) { - samples := []levelSample{ - {ts: min(0), value: 20}, - {ts: min(2), value: 22}, - {ts: min(4), value: 25}, +func TestDetectRechargeSessions(t *testing.T) { + base := time.Date(2026, 9, 1, 15, 47, 0, 0, time.UTC) + at := func(m float64) time.Time { return base.Add(time.Duration(m * float64(time.Minute))) } + const ( + minDur = rechargeDefaultMinDurationSeconds + minRise = rechargeMinRisePct + ) + + t.Run("device sleeps through the whole charge, first reading after wake-up is the departure", func(t *testing.T) { + // Sep 8-9 shape: ignition-off reading 44, 1,050 min of silence, wake-up reads 79 then drives off. + // The integer odometer hides the last few hundred metres (-3..0), so the start must be the last + // flat 44 reading, not the first one. + soc := newSeries(base, pt{-10, 46}, pt{-5, 45}, pt{-3, 44}, pt{-2, 44}, pt{0, 44}, pt{1050, 79}, pt{1052, 79}, pt{1060, 78}) + odo := newSeries(base, pt{-10, 995}, pt{-5, 998}, pt{-3, 1000}, pt{-2, 1000}, pt{0, 1000}, pt{1048, 1000}, pt{1050, 1000}, pt{1060, 1004}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(1050), got[0].end) + require.Equal(t, 1050*60, got[0].maxSampleGapSeconds) + }) + + t.Run("brief wake-ups that carry odometer but no SoC do not split the session", func(t *testing.T) { + // Aug 23-24 shape: 36 h with three wake-ups reporting odometer only. + soc := newSeries(base, pt{-5, 42}, pt{0, 40}, pt{2160, 78}, pt{2165, 77}) + odo := newSeries(base, pt{-5, 997}, pt{0, 1000}, pt{600, 1000}, pt{1200, 1000}, pt{1800, 1000}, pt{2158, 1000}, pt{2165, 1002}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(2160), got[0].end) + require.Equal(t, 2160*60, got[0].maxSampleGapSeconds) + }) + + t.Run("start reading is the last SoC before the car stopped, even if it was read while driving", func(t *testing.T) { + // Sep 1 shape: 23% read at 15:47 while still driving 2 km, parked 16:14, silent until 20:00, + // then awake and charging 31->56 until 23:21. + pts := []pt{{-30, 30}, {-15, 26}, {0, 23}} + for m := 226; m <= 426; m += 10 { + pts = append(pts, pt{float64(m), 31 + float64(m-226)*0.125}) } - ranges := findTroughToPeakRanges(samples, 1.0, 60) - require.Len(t, ranges, 1) - require.Equal(t, min(0), ranges[0].start) - require.Equal(t, min(4), ranges[0].end) - }) - - t.Run("rise below minRisePct filtered", func(t *testing.T) { - samples := []levelSample{ - {ts: min(0), value: 50}, - {ts: min(2), value: 50.5}, // rise of 0.5 < 1.0 + pts = append(pts, pt{430, 56}, pt{440, 55}) + soc := newSeries(base, pts...) + odo := newSeries(base, pt{-30, 995}, pt{-15, 997}, pt{0, 998}, pt{10, 999}, pt{27, 1000}, + pt{226, 1000}, pt{300, 1000}, pt{426, 1000}, pt{430, 1000}, pt{440, 1002}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(0), got[0].start, "start must be the 23%% reading, not the 31%% wake-up reading") + require.Equal(t, at(426), got[0].end, "end is the first sample reaching the peak") + require.Equal(t, 226*60, got[0].maxSampleGapSeconds) + }) + + t.Run("parked trough followed by a long silence is detected", func(t *testing.T) { + // Aug 25-26 shape: the case the old detector already handled. + soc := newSeries(base, pt{-5, 36}, pt{0, 35}, pt{790, 95}, pt{795, 95}, pt{800, 94}) + odo := newSeries(base, pt{-5, 999}, pt{0, 1000}, pt{790, 1000}, pt{800, 1001}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(790), got[0].end) + }) + + t.Run("sensor glitch while driving is rejected by the rate cap", func(t *testing.T) { + // Aug 20 shape: 19,18,17,17 then 80,79 within 90 s at 6 km/h. + soc := newSeries(base, pt{0, 19}, pt{0.5, 18}, pt{1, 17}, pt{1.5, 17}, pt{2.5, 80}, pt{3, 79}, pt{10, 79}, pt{20, 78}, pt{40, 77}) + var odoPts []pt + for m := 0.0; m <= 40; m += 0.5 { + odoPts = append(odoPts, pt{m, 1000 + 0.1*m}) } - ranges := findTroughToPeakRanges(samples, 1.0, 0) - require.Empty(t, ranges) - }) - - t.Run("rise below minDuration filtered", func(t *testing.T) { - samples := []levelSample{ - {ts: min(0), value: 20}, - {ts: min(0).Add(30 * time.Second), value: 30}, // 30s < 61s + odo := newSeries(base, odoPts...) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Empty(t, got) + }) + + t.Run("float jitter does not split a dense session", func(t *testing.T) { + // Tesla shape: one sample per minute, ±0.3 jitter on a steady 20%/h climb. + var pts []pt + for m := 0; m <= 60; m++ { + jitter := 0.3 + if m%2 == 1 { + jitter = -0.3 + } + pts = append(pts, pt{float64(m), 40 + float64(m)/3 + jitter}) } - ranges := findTroughToPeakRanges(samples, 1.0, 61) - require.Empty(t, ranges) - }) - - t.Run("two rises with dip between", func(t *testing.T) { - samples := []levelSample{ - {ts: min(0), value: 20}, - {ts: min(5), value: 30}, // peak 1 - {ts: min(10), value: 25}, // dip - {ts: min(15), value: 40}, // peak 2 + pts = append(pts, pt{70, 59}, pt{80, 57}) + soc := newSeries(base, pts...) + odo := newSeries(base, pt{0, 1000}, pt{30, 1000}, pt{60, 1000}, pt{70, 1005}, pt{80, 1010}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 1) + require.WithinDuration(t, at(0), got[0].start, time.Minute) + require.Equal(t, at(60), got[0].end) + require.Equal(t, 60, got[0].maxSampleGapSeconds) + }) + + t.Run("integer staircase with one-point flicker stays one session", func(t *testing.T) { + soc := newSeries(base, pt{0, 40}, pt{10, 41}, pt{20, 42}, pt{30, 41}, pt{40, 43}, pt{50, 44}, pt{60, 43}, pt{70, 41}) + odo := newSeries(base, pt{0, 1000}, pt{50, 1000}, pt{60, 1003}, pt{70, 1006}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(50), got[0].end) + }) + + t.Run("one-point flicker on a parked car is not a session", func(t *testing.T) { + // Integer OBD SoC: a single 47 between 46s is quantization noise, not a 1% charge. + soc := newSeries(base, pt{0, 46}, pt{10, 46}, pt{130, 47}, pt{131, 46}, pt{140, 45}) + odo := newSeries(base, pt{0, 1000}, pt{130, 1000}, pt{140, 1002}) + require.Empty(t, detectRechargeSessions(soc, odo, minDur, minRise)) + }) + + t.Run("regen while driving has no stationary core and is dropped", func(t *testing.T) { + var socPts, odoPts []pt + for m := 0; m <= 15; m++ { + socPts = append(socPts, pt{float64(m), 50 + float64(m)*0.2}) + odoPts = append(odoPts, pt{float64(m), 1000 + float64(m)}) } - ranges := findTroughToPeakRanges(samples, 1.0, 60) - require.Len(t, ranges, 2) + socPts = append(socPts, pt{20, 52}) + odoPts = append(odoPts, pt{20, 1020}) + got := detectRechargeSessions(newSeries(base, socPts...), newSeries(base, odoPts...), minDur, minRise) + require.Empty(t, got) }) -} -func TestFilterRangesBySocAndOdo(t *testing.T) { - base := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) - min := func(m int) time.Time { return base.Add(time.Duration(m) * time.Minute) } + t.Run("minIncreasePercent override is honored", func(t *testing.T) { + soc := newSeries(base, pt{0, 50}, pt{30, 55}) + odo := newSeries(base, pt{0, 1000}, pt{30, 1000}) + require.Len(t, detectRechargeSessions(soc, odo, minDur, 1), 1) + require.Empty(t, detectRechargeSessions(soc, odo, minDur, 10)) + }) - t.Run("keeps range with SoC increase and no odometer change", func(t *testing.T) { - ranges := []timeRange{{start: min(0), end: min(10)}} - soc := []levelSample{{ts: min(0), value: 20}, {ts: min(10), value: 80}} - odo := []levelSample{{ts: min(0), value: 1000}, {ts: min(10), value: 1000}} - result := filterRangesBySocAndOdo(ranges, soc, odo) - require.Len(t, result, 1) + t.Run("min duration is honored", func(t *testing.T) { + soc := newSeries(base, pt{0, 20}, pt{0.5, 22}) + odo := newSeries(base, pt{0, 1000}, pt{0.5, 1000}) + require.Empty(t, detectRechargeSessions(soc, odo, 60, minRise)) + require.Len(t, detectRechargeSessions(soc, odo, 10, minRise), 1) }) - t.Run("filters range where SoC decreases", func(t *testing.T) { - ranges := []timeRange{{start: min(0), end: min(10)}} - soc := []levelSample{{ts: min(0), value: 80}, {ts: min(10), value: 60}} - odo := []levelSample{{ts: min(0), value: 1000}, {ts: min(10), value: 1000}} - result := filterRangesBySocAndOdo(ranges, soc, odo) - require.Empty(t, result) + t.Run("no odometer data keeps the trough-to-peak rise", func(t *testing.T) { + soc := newSeries(base, pt{0, 44}, pt{600, 79}) + got := detectRechargeSessions(soc, nil, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(600), got[0].end) }) - t.Run("filters range where odometer increases beyond epsilon", func(t *testing.T) { - ranges := []timeRange{{start: min(0), end: min(10)}} - soc := []levelSample{{ts: min(0), value: 20}, {ts: min(10), value: 80}} - odo := []levelSample{{ts: min(0), value: 1000}, {ts: min(10), value: 1002}} // 2km > 0.5 epsilon - result := filterRangesBySocAndOdo(ranges, soc, odo) - require.Empty(t, result) + t.Run("two sessions within 2h with equal odometer merge and the gap is recomputed", func(t *testing.T) { + soc := newSeries(base, pt{0, 30}, pt{30, 40}, pt{60, 50}, pt{61, 48}, pt{121, 48}, pt{150, 60}, pt{180, 70}, pt{190, 69}) + odo := newSeries(base, pt{0, 1000}, pt{60, 1000}, pt{61, 1000}, pt{121, 1000}, pt{180, 1000}, pt{190, 1005}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(180), got[0].end) + require.Equal(t, 60*60, got[0].maxSampleGapSeconds) }) - t.Run("odometer within epsilon is kept", func(t *testing.T) { - ranges := []timeRange{{start: min(0), end: min(10)}} - soc := []levelSample{{ts: min(0), value: 20}, {ts: min(10), value: 80}} - odo := []levelSample{{ts: min(0), value: 1000}, {ts: min(10), value: 1000.3}} // 0.3 < 0.5 - result := filterRangesBySocAndOdo(ranges, soc, odo) - require.Len(t, result, 1) + t.Run("two sessions separated by a drive stay separate", func(t *testing.T) { + soc := newSeries(base, pt{0, 30}, pt{30, 40}, pt{60, 50}, pt{61, 48}, pt{90, 46}, pt{121, 46}, pt{150, 60}, pt{180, 70}, pt{190, 69}) + odo := newSeries(base, pt{0, 1000}, pt{60, 1000}, pt{61, 1000}, pt{90, 1003}, pt{121, 1005}, pt{180, 1005}, pt{190, 1010}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 2) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(60), got[0].end) + require.Equal(t, at(121), got[1].start) + require.Equal(t, at(180), got[1].end) }) - t.Run("no odometer data keeps range", func(t *testing.T) { - ranges := []timeRange{{start: min(0), end: min(10)}} - soc := []levelSample{{ts: min(0), value: 20}, {ts: min(10), value: 80}} - result := filterRangesBySocAndOdo(ranges, soc, nil) - require.Len(t, result, 1) + t.Run("fewer than two SoC samples yields nothing", func(t *testing.T) { + require.Empty(t, detectRechargeSessions(nil, nil, minDur, minRise)) + require.Empty(t, detectRechargeSessions(newSeries(base, pt{0, 50}), nil, minDur, minRise)) }) } +func TestRechargeSessionsToSegments(t *testing.T) { + base := time.Date(2026, 9, 8, 19, 19, 47, 0, time.UTC) + from := base.Add(-24 * time.Hour) + sessions := []rechargeSession{{start: base, end: base.Add(62880 * time.Second), maxSampleGapSeconds: 62880}} + + segs := rechargeSessionsToSegments(sessions, from) + require.Len(t, segs, 1) + require.Equal(t, base, segs[0].Start.Timestamp) + require.NotNil(t, segs[0].End) + require.Equal(t, base.Add(62880*time.Second), segs[0].End.Timestamp) + require.Equal(t, 62880, segs[0].Duration) + require.False(t, segs[0].IsOngoing) + require.False(t, segs[0].StartedBeforeRange) + require.NotNil(t, segs[0].MaxSampleGapSeconds) + require.Equal(t, 62880, *segs[0].MaxSampleGapSeconds) + + require.Empty(t, rechargeSessionsToSegments(nil, from)) + require.NotNil(t, rechargeSessionsToSegments(nil, from)) +} + func TestLevelFirstLastInRange(t *testing.T) { base := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC) min := func(m int) time.Time { return base.Add(time.Duration(m) * time.Minute) } diff --git a/internal/service/ch/recharge_realdata_test.go b/internal/service/ch/recharge_realdata_test.go new file mode 100644 index 00000000..901437b5 --- /dev/null +++ b/internal/service/ch/recharge_realdata_test.go @@ -0,0 +1,120 @@ +package ch + +import ( + "encoding/json" + "os" + "sort" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// rechargeRealDataEnv points at a local JSON export of a vehicle's SoC and odometer series. +// The file is never committed; the test skips when the variable is unset. +// +// Format: +// +// { +// "from": "...", "to": "...", +// "soc": [{"ts": "RFC3339", "value": 44}, ...], +// "odo": [{"ts": "RFC3339", "value": 12345.6}, ...], +// "expectedStarts": ["RFC3339", ...], // a session must start within expectTolerance of each +// "unexpectedStarts": ["RFC3339", ...], // no session may start within expectTolerance of any +// "expectedSoc": [{"start": "RFC3339", "from": 23, "to": 56}, ...] +// } +const rechargeRealDataEnv = "RECHARGE_REALDATA_JSON" + +// Starts may shift by the flat lead-in (SoC sitting at its start value before the rise), so allow some slack. +const rechargeRealDataTolerance = 15 * time.Minute + +type realDataSample struct { + Ts time.Time `json:"ts"` + Value float64 `json:"value"` +} + +type realDataExport struct { + From time.Time `json:"from"` + To time.Time `json:"to"` + Soc []realDataSample `json:"soc"` + Odo []realDataSample `json:"odo"` + ExpectedStarts []time.Time `json:"expectedStarts"` + UnexpectedStarts []time.Time `json:"unexpectedStarts"` + ExpectedSoc []struct { + Start time.Time `json:"start"` + From float64 `json:"from"` + To float64 `json:"to"` + } `json:"expectedSoc"` +} + +func toLevelSamples(in []realDataSample) []levelSample { + out := make([]levelSample, 0, len(in)) + for _, s := range in { + out = append(out, levelSample{ts: s.Ts, value: s.Value}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ts.Before(out[j].ts) }) + return out +} + +func TestRechargeRealData(t *testing.T) { + path := os.Getenv(rechargeRealDataEnv) + if path == "" { + t.Skipf("%s not set", rechargeRealDataEnv) + } + raw, err := os.ReadFile(path) + require.NoError(t, err) + var export realDataExport + require.NoError(t, json.Unmarshal(raw, &export)) + + soc := toLevelSamples(export.Soc) + odo := toLevelSamples(export.Odo) + sessions := detectRechargeSessions(soc, odo, rechargeDefaultMinDurationSeconds, rechargeMinRisePct) + + t.Logf("%d sessions over %s -> %s", len(sessions), export.From.Format(time.RFC3339), export.To.Format(time.RFC3339)) + for _, s := range sessions { + startSoc, endSoc, _ := levelFirstLastInRange(soc, s.start, s.end) + t.Logf(" %s -> %s dur=%6.0f min gap=%6.0f min soc %.0f->%.0f", + s.start.Format("01-02 15:04"), s.end.Format("01-02 15:04"), + s.end.Sub(s.start).Minutes(), float64(s.maxSampleGapSeconds)/60, startSoc, endSoc) + } + + findStart := func(want time.Time) (rechargeSession, bool) { + for _, s := range sessions { + if d := s.start.Sub(want); d > -rechargeRealDataTolerance && d < rechargeRealDataTolerance { + return s, true + } + } + return rechargeSession{}, false + } + for _, want := range export.ExpectedStarts { + _, ok := findStart(want) + require.Truef(t, ok, "expected a session starting near %s", want.Format(time.RFC3339)) + } + for _, unwanted := range export.UnexpectedStarts { + _, ok := findStart(unwanted) + require.Falsef(t, ok, "expected no session starting near %s", unwanted.Format(time.RFC3339)) + } + for _, e := range export.ExpectedSoc { + s, ok := findStart(e.Start) + require.Truef(t, ok, "expected a session starting near %s", e.Start.Format(time.RFC3339)) + startSoc, endSoc, ok := levelFirstLastInRange(soc, s.start, s.end) + require.True(t, ok) + require.InDeltaf(t, e.From, startSoc, 0.5, "start SoC for session at %s", e.Start.Format(time.RFC3339)) + require.InDeltaf(t, e.To, endSoc, 0.5, "end SoC for session at %s", e.Start.Format(time.RFC3339)) + } + + // A window that starts after the first sessions must still see the later ones. + if len(export.ExpectedStarts) > 0 { + last := export.ExpectedStarts[0] + for _, s := range export.ExpectedStarts { + if s.After(last) { + last = s + } + } + cut := last.Add(-24 * time.Hour) + cutSoc := soc[sort.Search(len(soc), func(i int) bool { return !soc[i].ts.Before(cut) }):] + cutOdo := odo[sort.Search(len(odo), func(i int) bool { return !odo[i].ts.Before(cut) }):] + require.NotEmpty(t, detectRechargeSessions(cutSoc, cutOdo, rechargeDefaultMinDurationSeconds, rechargeMinRisePct), + "window from %s must not be empty", cut.Format(time.RFC3339)) + } +} diff --git a/schema/segments.graphqls b/schema/segments.graphqls index ce10f334..285a26b6 100644 --- a/schema/segments.graphqls +++ b/schema/segments.graphqls @@ -31,7 +31,10 @@ enum DetectionMechanism { refuel """ - Recharge: Hybrid detection. Uses charging signals and state of charge for detection. + Recharge: Detects where battery state of charge rises while the vehicle is stationary. + Aftermarket devices often sleep through a charge, so the segment spans from the last + reading before the car stopped to the first reading after it woke: duration is an + upper bound, and maxSampleGapSeconds reports the unobserved portion. """ recharge } @@ -165,4 +168,9 @@ type Segment { startedBeforeRange: Boolean! signals: [SignalAggregationValue!] eventCounts: [EventCount!] + """ + Longest interval in seconds inside the segment with no state-of-charge sample. + Set for recharge only (devices that sleep while charging report nothing until they wake), null otherwise. + """ + maxSampleGapSeconds: Int } From 67d73c4d6d341b98a368b3fa4182151f0c490392 Mon Sep 17 00:00:00 2001 From: zer0stars <74260741+zer0stars@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:49:12 -0400 Subject: [PATCH 2/4] fix(segments): split recharge runs at movement, anchor start on arrival 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 --- internal/graph/generated.go | 4 +- internal/graph/mcp_tools_gen.go | 2 +- internal/graph/model/models_gen.go | 4 +- internal/service/ch/recharge_detector.go | 109 ++++++++++++++---- internal/service/ch/recharge_detector_test.go | 39 +++++++ internal/service/ch/recharge_realdata_test.go | 5 + schema/segments.graphqls | 4 +- 7 files changed, 138 insertions(+), 29 deletions(-) diff --git a/internal/graph/generated.go b/internal/graph/generated.go index 76d8d05c..682ebd8c 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -3625,7 +3625,9 @@ directive @mcpHide on FIELD_DEFINITION Recharge: Detects where battery state of charge rises while the vehicle is stationary. Aftermarket devices often sleep through a charge, so the segment spans from the last reading before the car stopped to the first reading after it woke: duration is an - upper bound, and maxSampleGapSeconds reports the unobserved portion. + upper bound, and maxSampleGapSeconds reports the unobserved portion. A session is + only reported when both of those readings fall inside [from, to]. Consecutive charges + at the same odometer with no drop in between are reported as one session. """ recharge } diff --git a/internal/graph/mcp_tools_gen.go b/internal/graph/mcp_tools_gen.go index 83ba1c91..3defafdc 100644 --- a/internal/graph/mcp_tools_gen.go +++ b/internal/graph/mcp_tools_gen.go @@ -179,4 +179,4 @@ var MCPTools = []mcpserver.ToolDefinition{ }, } -var CondensedSchema = "scalar Address # A 20-byte Ethereum address, encoded as a checksummed hex string with 0x prefix.\nscalar Map\nscalar Time # A point in time, encoded per RFC-3339.\nscalar Uint64 # A 64-bit unsigned integer.\n\n# ═══ SIGNAL FIELDS (117 total) ═══\n# All signals below exist on every signal type. Calling convention per type:\n# SignalAggregations:\n# fieldName(agg: LocationAggregation!): Location\n# fieldName(agg: FloatAggregation!, filter: SignalFloatFilter): Float\n# fieldName(agg: LocationAggregation!, filter: SignalLocationFilter): Location\n# fieldName(agg: StringAggregation!): String\n# SignalCollection:\n# fieldName(): SignalLocation\n# fieldName(): SignalFloat\n# fieldName(): SignalString\n# Float is the default type. Location: currentLocationApproximateCoordinates, currentLocationCoordinates. String: obdDTCList, obdFuelTypeName, powertrainCombustionEngineEngineOilLevel, powertrainFuelSystemSupportedFuelTypes, powertrainTransmissionRetarderTorqueMode, powertrainType.\n# | Signal | Unit | Description |\n# |--------|------|-------------|\n# Shared descriptions (blank rows below use these):\n# - Is item open or closed? True = Fully or partially open\n# - Is the belt engaged\n# - Measured Load on axle row 3\n# ── CURRENT (privilege: VEHICLE_ALL_TIME_LOCATION) ──\n# | currentLocationApproximateCoordinates | | Approximate location of the vehicle in WGS 84 coordinates (privilege: VEHICLE_APPROXIMATE_LOCATION VEHICLE_ALL_TIME_LOCATION) |\n# | currentLocationAltitude | m | Current altitude relative to WGS 84 reference ellipsoid, as measured at the position of GNSS receiver antenna |\n# | currentLocationCoordinates | | Current location of the vehicle in WGS 84 coordinates |\n# | currentLocationHeading | degrees | Current heading relative to geographic north |\n# ── OTHER (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | angularVelocityYaw | degrees/s | Vehicle rotation rate along Z (vertical) |\n# | connectivityCellularIsJammingDetected | | Indicates whether cellular radio signal jamming or interference is detected that prevents normal communication |\n# | exteriorAirTemperature | celsius | Air temperature outside the vehicle |\n# | isIgnitionOn | | Vehicle ignition status |\n# | lowVoltageBatteryCurrentVoltage | V | |\n# | speed | km/h | |\n# ── BODY (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | bodyLightsIsAirbagWarningOn | | Indicates whether the airbag/SRS warning telltale is active |\n# | bodyLockIsLocked | | Indicates whether the vehicle is locked via the central locking system |\n# | bodyTrunkFrontIsOpen | | |\n# | bodyTrunkRearIsOpen | | |\n# ── CABIN (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | cabinDoorRow1DriverSideIsOpen | | |\n# | cabinDoorRow1DriverSideWindowIsOpen | | |\n# | cabinDoorRow1PassengerSideIsOpen | | |\n# | cabinDoorRow1PassengerSideWindowIsOpen | | |\n# | cabinDoorRow2DriverSideIsOpen | | |\n# | cabinDoorRow2DriverSideWindowIsOpen | | |\n# | cabinDoorRow2PassengerSideIsOpen | | |\n# | cabinDoorRow2PassengerSideWindowIsOpen | | |\n# | cabinSeatRow1DriverSideIsBelted | | |\n# | cabinSeatRow1PassengerSideIsBelted | | |\n# | cabinSeatRow2DriverSideIsBelted | | |\n# | cabinSeatRow2MiddleIsBelted | | |\n# | cabinSeatRow2PassengerSideIsBelted | | |\n# | cabinSeatRow3DriverSideIsBelted | | |\n# | cabinSeatRow3PassengerSideIsBelted | | |\n# ── CHASSIS (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# shared: Rotational speed of a vehicle's wheel\n# shared: Pneumatic pressure in the service brake circuit or reservoir\n# | chassisAxleRow1WheelLeftSpeed | km/h | |\n# | chassisAxleRow1WheelLeftTirePressure | kPa | |\n# | chassisAxleRow1WheelRightSpeed | km/h | |\n# | chassisAxleRow1WheelRightTirePressure | kPa | |\n# | chassisAxleRow2WheelLeftTirePressure | kPa | |\n# | chassisAxleRow2WheelRightTirePressure | kPa | |\n# | chassisAxleRow3Weight | kg | |\n# | chassisAxleRow4Weight | kg | |\n# | chassisAxleRow5Weight | kg | |\n# | chassisBrakeABSIsWarningOn | | Indicates whether the ABS warning telltale is active (any non-off state) |\n# | chassisBrakeCircuit1PressurePrimary | kPa | |\n# | chassisBrakeCircuit2PressurePrimary | kPa | |\n# | chassisBrakeIsPedalPressed | | Indicates whether the brake pedal is pressed |\n# | chassisBrakePedalPosition | percent | Brake pedal position as percent |\n# | chassisParkingBrakeIsEngaged | | |\n# | chassisTireSystemIsWarningOn | | Indicates whether the tire system warning telltale is active |\n# ── OBD (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# shared: PID 2x (byte CD) - Voltage for wide range/band oxygen sensor\n# | obdBarometricPressure | kPa | PID 33 - Barometric pressure |\n# | obdCommandedEGR | percent | PID 2C - Commanded exhaust gas recirculation (EGR) |\n# | obdCommandedEVAP | percent | PID 2E - Commanded evaporative purge (EVAP) valve |\n# | obdDTCList | | List of currently active DTCs formatted according OBD II (SAE-J2012DA_201812) standard ([P|C|B|U]XXXXX ) |\n# | obdDistanceSinceDTCClear | km | PID 31 - Distance traveled since codes cleared |\n# | obdDistanceWithMIL | km | PID 21 - Distance traveled with MIL on |\n# | obdEngineLoad | percent | PID 04 - Engine load in percent - 0 = no load, 100 = full load |\n# | obdEthanolPercent | percent | PID 52 - Percentage of ethanol in the fuel |\n# | obdFuelPressure | kPa | PID 0A - Fuel pressure |\n# | obdFuelRailPressure | kPa | |\n# | obdFuelRate | l/h | PID 5E - Engine fuel rate |\n# | obdFuelTypeName | | Fuel type names decoded from PID 51 |\n# | obdIntakeTemp | celsius | PID 0F - Intake temperature |\n# | obdIsEngineBlocked | | Engine block status, 0 = engine unblocked, 1 = engine blocked |\n# | obdIsPTOActive | | PID 1E - Auxiliary input status (power take off) |\n# | obdIsPluggedIn | | Aftermarket device plugged in status |\n# | obdLongTermFuelTrim1 | percent | PID 07 - Long Term (learned) Fuel Trim - Bank 1 - negative percent leaner, positive percent richer |\n# | obdLongTermFuelTrim2 | percent | PID 09 - Long Term (learned) Fuel Trim - Bank 2 - negative percent leaner, positive percent richer |\n# | obdMAP | kPa | PID 0B - Intake manifold pressure |\n# | obdMaxMAF | g/s | PID 50 - Maximum flow for mass air flow sensor |\n# | obdO2WRSensor1Voltage | V | |\n# | obdO2WRSensor2Voltage | V | |\n# | obdOilTemperature | celsius | PID 5C - Engine oil temperature |\n# | obdRunTime | s | PID 1F - Engine run time |\n# | obdShortTermFuelTrim1 | percent | PID 06 - Short Term (immediate) Fuel Trim - Bank 1 - negative percent leaner, positive percent richer |\n# | obdStatusDTCCount | | Number of Diagnostic Trouble Codes (DTC) |\n# | obdThrottlePosition | percent | PID 11 - Throttle position - 0 = closed throttle, 100 = open throttle |\n# | obdWarmupsSinceDTCClear | | PID 30 - Number of warm-ups since codes cleared |\n# ── POWERTRAIN (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | powertrainCombustionEngineDieselExhaustFluidCapacity | l | Capacity in liters of the Diesel Exhaust Fluid Tank |\n# | powertrainCombustionEngineDieselExhaustFluidLevel | percent | Level of the Diesel Exhaust Fluid tank as percent of capacity |\n# | powertrainCombustionEngineECT | celsius | Engine coolant temperature |\n# | powertrainCombustionEngineEOP | kPa | Engine oil pressure |\n# | powertrainCombustionEngineEOT | celsius | Engine oil temperature |\n# | powertrainCombustionEngineEngineOilLevel | | |\n# | powertrainCombustionEngineEngineOilRelativeLevel | percent | Engine oil level as a percentage |\n# | powertrainCombustionEngineMAF | g/s | Grams of air drawn into engine per second |\n# | powertrainCombustionEngineSpeed | rpm | Engine speed measured as rotations per minute |\n# | powertrainCombustionEngineTPS | percent | Current throttle position |\n# | powertrainCombustionEngineTorque | Nm | |\n# | powertrainCombustionEngineTorquePercent | percent | Actual engine output torque as a percentage of reference engine torque (FMS / J1939 parameter SPN 513) |\n# | powertrainFuelSystemAbsoluteLevel | l | Current available fuel in the fuel tank expressed in liters |\n# | powertrainFuelSystemAccumulatedConsumption | l | Accumulated fuel consumption (totalized) reported by the vehicle (FMS SPN 250) |\n# | powertrainFuelSystemRelativeLevel | percent | Level in fuel tank as percent of capacity |\n# | powertrainFuelSystemSupportedFuelTypes | | High level information of fuel types supported |\n# | powertrainRange | km | Remaining range in kilometers using all energy sources available in the vehicle |\n# | powertrainTractionBatteryChargingAddedEnergy | kWh | Amount of charge added to the high voltage battery during the current charging session, expressed in kilowatt-hours |\n# | powertrainTractionBatteryChargingChargeCurrentAC | A | Current AC charging current (rms) at inlet |\n# | powertrainTractionBatteryChargingChargeLimit | percent | Target charge limit (state of charge) for battery |\n# | powertrainTractionBatteryChargingChargeVoltageUnknownType | V | Current charging voltage at inlet |\n# | powertrainTractionBatteryChargingIsCharging | | True if charging is ongoing |\n# | powertrainTractionBatteryChargingIsChargingCableConnected | | Indicates if a charging cable is physically connected to the vehicle or not |\n# | powertrainTractionBatteryChargingPower | kW | Instantaneous charging power recorded during a charging event |\n# | powertrainTractionBatteryCurrentPower | W | Current electrical energy flowing in/out of battery |\n# | powertrainTractionBatteryCurrentVoltage | V | |\n# | powertrainTractionBatteryGrossCapacity | kWh | |\n# | powertrainTractionBatteryRange | km | Remaining range in kilometers using only battery |\n# | powertrainTractionBatteryStateOfChargeCurrent | percent | Physical state of charge of the high voltage battery, relative to net capacity |\n# | powertrainTractionBatteryStateOfChargeCurrentEnergy | kWh | Physical state of charge of high voltage battery expressed in kWh |\n# | powertrainTractionBatteryStateOfHealth | percent | Calculated battery state of health at standard conditions |\n# | powertrainTractionBatteryTemperatureAverage | celsius | Current average temperature of the battery cells |\n# | powertrainTransmissionActualGear | | Actual transmission gear currently engaged |\n# | powertrainTransmissionActualGearRatio | | |\n# | powertrainTransmissionCurrentGear | | |\n# | powertrainTransmissionIsClutchSwitchOperated | | Indicates if the Clutch switch is operated, so engine and transmission are partially or fully decoupled |\n# | powertrainTransmissionRetarderActualTorque | percent | Actual retarder torque as a percentage (FMS / J1939 SPN 520) |\n# | powertrainTransmissionRetarderTorqueMode | | Active engine torque mode |\n# | powertrainTransmissionSelectedGear | | |\n# | powertrainTransmissionTemperature | celsius | The current gearbox temperature |\n# | powertrainTransmissionTravelledDistance | km | Odometer reading, total distance travelled during the lifetime of the transmission |\n# | powertrainType | | Defines the powertrain type of the vehicle |\n# ── SERVICE (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | serviceDistanceToService | km | Remaining distance to service (of any kind) |\n# | serviceTimeToService | s | Remaining time to service (of any kind) |\n\ntype Query {\n signals(\n tokenId: Int!\n \"\"\"\n Duration string for data aggregation buckets (e.g., \"5m\", \"1h\", \"2h45m\"). Valid\n units: ms, s, m, h. Common values: \"5m\" (5 minutes), \"1h\" (1 hour), \"6h\", \"24h\".\n Days are not a valid unit — use \"24h\" instead of \"1d\".\n \"\"\"\n interval: String!\n from: Time!\n to: Time!\n filter: SignalFilter\n ): [SignalAggregations!]\n # Example - Hourly average speed over a time range:\n # query TimeSeries($tokenId:Int!,$from:Time!,$to:Time!) { signals(tokenId:$tokenId,interval:\"1h\",from:$from,to:$to) { timestamp speed(agg:AVG) } }\n\n signalsLatest(tokenId: Int!, filter: SignalFilter): SignalCollection\n # Example - Latest speed and battery charge:\n # query Latest($tokenId:Int!) { signalsLatest(tokenId:$tokenId) { lastSeen speed{timestamp value} powertrainTractionBatteryStateOfChargeCurrent{timestamp value} } }\n\n availableSignals(tokenId: Int!, filter: SignalFilter): [String!]\n \"Point-in-time snapshot of all accessible signals. Equivalent to availableSignals + signalsLatest in a single request.\"\n signalsSnapshot(tokenId: Int!, filter: SignalFilter): SignalsSnapshotResponse\n # Example - Full snapshot of all signals for a vehicle:\n # query Snapshot($tokenId:Int!) { signalsSnapshot(tokenId:$tokenId) { lastSeen signals { name timestamp valueNumber valueString valueLocation { latitude longitude hdop } } } }\n\n dataSummary(tokenId: Int!, filter: SignalFilter): DataSummary\n attestations(tokenId: Int, subject: String, filter: AttestationFilter): [Attestation]\n events(tokenId: Int!, from: Time!, to: Time!, filter: EventFilter): [Event!]\n \"\"\"\n Returns vehicle usage segments detected using the specified mechanism. Maximum\n date range: 31 days.\n Detection mechanisms:\n - ignitionDetection: Uses 'isIgnitionOn' signal with configurable debouncing\n - frequencyAnalysis: Analyzes signal update frequency to detect activity periods\n - changePointDetection: CUSUM-based regime change detection\n - idling: Idling segments (engine rpm idle)\n - refuel: Refueling segments (fuel level increased)\n - recharge: Charging segments (battery SoC increased)\n Segment IDs are stable and consistent across queries as long as the segment\n start is captured in the underlying data source.\n Each segment includes summary: signals, start/end location, and (when requested)\n eventCounts. A default set of signal requests is always applied (e.g. speed,\n odometer; for refuel/recharge also the level signal at start and end). When\n signalRequests is provided, those requests are added on top of the default set;\n duplicates (same name and agg) are omitted.\n \"\"\"\n segments(\n tokenId: Int!\n from: Time!\n to: Time!\n mechanism: DetectionMechanism!\n config: SegmentConfig\n signalRequests: [SegmentSignalRequest!]\n eventRequests: [SegmentEventRequest!]\n \"Maximum number of segments to return. Default 100, max 200.\"\n limit: Int = 100\n after: Time\n ): [Segment!]!\n # Example - Trip segments with start/end locations and signal aggregates:\n # query Trips($tokenId:Int!,$from:Time!,$to:Time!) { segments(tokenId:$tokenId,from:$from,to:$to,mechanism:frequencyAnalysis) { start{timestamp value{latitude longitude}} end{timestamp value{latitude longitude}} duration isOngoing signals{name agg value} eventCounts{name count} } }\n\n \"\"\"\n Returns one record per calendar day in the date range. Mechanism must be\n ignitionDetection, frequencyAnalysis, or changePointDetection (idling, refuel,\n and recharge not allowed). Maximum date range: 31 days.\n \"\"\"\n dailyActivity(tokenId: Int!, from: Time!, to: Time!, mechanism: DetectionMechanism!, config: SegmentConfig, signalRequests: [SegmentSignalRequest!], eventRequests: [SegmentEventRequest!], timezone: String): [DailyActivity!]!\n # Example - Daily activity summaries:\n # query Daily($tokenId:Int!,$from:Time!,$to:Time!) { dailyActivity(tokenId:$tokenId,from:$from,to:$to,mechanism:frequencyAnalysis) { segmentCount duration signals{name agg value} eventCounts{name count} } }\n\n \"Required Privileges: [VEHICLE_VIN_CREDENTIAL]\"\n vinVCLatest(tokenId: Int!): VINVC\n}\n\ntype Attestation { id: String!, vehicleTokenId: Int!, time: Time!, attestation: String!, type: String!, source: Address!, dataVersion: String!, producer: String, signature: String!, tags: [String!] }\n\ninput AttestationFilter {\n id: String\n \"The attesting party.\"\n source: Address\n dataVersion: String\n producer: String\n \"Before this timestamp.\"\n before: Time\n \"After this timestamp.\"\n after: Time\n \"Max results. Default 10.\"\n limit: Int\n \"Pagination cursor (exclusive).\"\n cursor: Time\n tags: StringArrayFilter\n}\n\ntype DailyActivity { start: SignalLocation, end: SignalLocation, segmentCount: Int!, duration: Int!, signals: [SignalAggregationValue!]!, eventCounts: [EventCount!]! }\n\ntype DataSummary { numberOfSignals: Uint64!, availableSignals: [String!]!, firstSeen: Time!, lastSeen: Time!, signalDataSummary: [SignalDataSummary!]!, eventDataSummary: [EventDataSummary!]! }\n\nenum DetectionMechanism {\n \"Ignition-based detection: Segments are identified by isIgnitionOn state transitions. Most reliable for vehicles with proper ignition signal support.\"\n ignitionDetection\n \"Frequency analysis: Segments are detected by analyzing signal update patterns. Uses pre-computed materialized view for optimal performance. Ideal for real-time APIs and bulk queries.\"\n frequencyAnalysis\n \"\"\"\n Change point detection: Uses CUSUM algorithm to detect statistical regime\n changes. Monitors cumulative deviation in signal frequency via materialized\n view. Excellent noise resistance with 100% accuracy match to ignition baseline.\n Best alternative when ignition signal is unavailable - same accuracy, same speed\n as frequency analysis.\n \"\"\"\n changePointDetection\n \"Idling: Segments are contiguous periods where engine RPM remains in idle range.\"\n idling\n \"Refuel: Detects where fuel level rises significantly.\"\n refuel\n \"Recharge: Hybrid detection. Uses charging signals and state of charge for detection.\"\n recharge\n}\n\ntype Event { timestamp: Time!, name: String!, source: String!, durationNs: Int!, metadata: String }\n\ntype EventCount { name: String!, count: Int! }\n\ntype EventDataSummary { name: String!, numberOfEvents: Uint64!, firstSeen: Time!, lastSeen: Time! }\n\ninput EventFilter {\n name: StringValueFilter\n \"Source connection that created the event.\"\n source: StringValueFilter\n tags: StringArrayFilter\n}\n\ninput FilterLocation {\n \"Latitude in the range [-90, 90].\"\n latitude: Float!\n \"Longitude in the range [-180, 180].\"\n longitude: Float!\n}\n\nenum FloatAggregation { AVG, MED, MAX, MIN, RAND, FIRST, LAST }\n\ninput InCircleFilter {\n center: FilterLocation!\n \"Radius in kilometers.\"\n radius: Float!\n}\n\ntype LatestSignal { name: String!, timestamp: Time!, valueNumber: Float, valueString: String, valueLocation: Location }\n\ntype Location { latitude: Float!, longitude: Float!, hdop: Float! }\n\nenum LocationAggregation { AVG, RAND, FIRST, LAST }\n\nenum Privilege { VEHICLE_NON_LOCATION_DATA, VEHICLE_COMMANDS, VEHICLE_CURRENT_LOCATION, VEHICLE_ALL_TIME_LOCATION, VEHICLE_VIN_CREDENTIAL, VEHICLE_APPROXIMATE_LOCATION, VEHICLE_RAW_DATA }\n\ntype Segment { start: SignalLocation!, end: SignalLocation, duration: Int!, isOngoing: Boolean!, startedBeforeRange: Boolean!, signals: [SignalAggregationValue!], eventCounts: [EventCount!] }\n\ninput SegmentConfig {\n \"\"\"\n Maximum gap (seconds) between data points before a segment is split. For\n ignitionDetection: filters noise from brief ignition OFF events. For\n frequencyAnalysis: maximum gap between active windows to merge. Default: 300 (5\n minutes), Min: 60, Max: 3600\n \"\"\"\n maxGapSeconds: Int = 300\n \"Minimum segment duration (seconds) to include in results. Filters very short segments (testing, engine cycling). Default: 240 (4 minutes), Min: 60, Max: 3600\"\n minSegmentDurationSeconds: Int = 240\n \"\"\"\n [frequencyAnalysis] Minimum signal count per window for activity detection.\n [idling] Minimum samples per window to consider it idle (same semantics). Higher\n values = more conservative. Lower values = more sensitive. Default: 10, Min: 1,\n Max: 3600\n \"\"\"\n signalCountThreshold: Int = 10\n \"[idling only] Upper bound for idle RPM. Windows with max(RPM) <= this are considered idle. Default: 1000, Min: 300, Max: 3000\"\n maxIdleRpm: Int = 1000\n \"[refuel and recharge only] Minimum percent increase within a window to consider it a level-increase window.\"\n minIncreasePercent: Int = 15\n}\n\ninput SegmentEventRequest { name: String! }\n\ninput SegmentSignalRequest { name: String!, agg: FloatAggregation! }\n\ntype SignalAggregationValue { name: String!, agg: String!, value: Float! }\n\ntype SignalAggregations {\n timestamp: Time!\n # + 117 signal fields (see SIGNAL FIELDS table above)\n}\n\ntype SignalCollection {\n lastSeen: Time\n # + 117 signal fields (see SIGNAL FIELDS table above)\n}\n\ntype SignalDataSummary { name: String!, numberOfSignals: Uint64!, firstSeen: Time!, lastSeen: Time! }\n\ninput SignalFilter {\n \"\"\"\n Filter by source ethr DID. Example:\n \"did:ethr:137:0xcd445F4c6bDAD32b68a2939b912150Fe3C88803E\"\n \"\"\"\n source: String\n}\n\ntype SignalFloat { timestamp: Time!, value: Float! }\n\ninput SignalFloatFilter { eq: Float, neq: Float, gt: Float, lt: Float, gte: Float, lte: Float, notIn: [Float!], in: [Float!], or: [SignalFloatFilter!] }\n\ntype SignalLocation { timestamp: Time!, value: Location! }\n\ninput SignalLocationFilter {\n \"Filter for locations within a polygon. The vertices should be ordered clockwise or counterclockwise, and there must be at least 3. May produce inaccurate results around the poles and the antimeridian.\"\n inPolygon: [FilterLocation!]\n \"Filter for locations within a given distance of a given point. Distances are computed using WGS 84, and points that are exactly a distance `radius` from the `center` will be included.\"\n inCircle: InCircleFilter\n}\n\ntype SignalString { timestamp: Time!, value: String! }\n\ntype SignalsSnapshotResponse { lastSeen: Time, signals: [LatestSignal!]! }\n\nenum StringAggregation {\n \"Randomly select a value from the group.\"\n RAND\n \"Select the most frequently occurring value in the group.\"\n TOP\n \"Return a list of unique values in the group.\"\n UNIQUE\n \"Return value in group associated with the minimum time value.\"\n FIRST\n \"Return value in group associated with the maximum time value.\"\n LAST\n}\n\ninput StringArrayFilter { containsAny: [String!], containsAll: [String!], notContainsAny: [String!], notContainsAll: [String!], or: [StringArrayFilter!] }\n\ninput StringValueFilter {\n eq: String\n neq: String\n notIn: [String!]\n in: [String!]\n \"Matches strings that begin with the given prefix.\"\n startsWith: String\n or: [StringValueFilter!]\n}\n\ntype VINVC { vehicleTokenId: Int, vin: String, recordedBy: String, recordedAt: Time, countryCode: String, vehicleContractAddress: String, validFrom: Time, validTo: Time, rawVC: String! }\n" +var CondensedSchema = "scalar Address # A 20-byte Ethereum address, encoded as a checksummed hex string with 0x prefix.\nscalar Map\nscalar Time # A point in time, encoded per RFC-3339.\nscalar Uint64 # A 64-bit unsigned integer.\n\n# ═══ SIGNAL FIELDS (117 total) ═══\n# All signals below exist on every signal type. Calling convention per type:\n# SignalAggregations:\n# fieldName(agg: LocationAggregation!): Location\n# fieldName(agg: FloatAggregation!, filter: SignalFloatFilter): Float\n# fieldName(agg: LocationAggregation!, filter: SignalLocationFilter): Location\n# fieldName(agg: StringAggregation!): String\n# SignalCollection:\n# fieldName(): SignalLocation\n# fieldName(): SignalFloat\n# fieldName(): SignalString\n# Float is the default type. Location: currentLocationApproximateCoordinates, currentLocationCoordinates. String: obdDTCList, obdFuelTypeName, powertrainCombustionEngineEngineOilLevel, powertrainFuelSystemSupportedFuelTypes, powertrainTransmissionRetarderTorqueMode, powertrainType.\n# | Signal | Unit | Description |\n# |--------|------|-------------|\n# Shared descriptions (blank rows below use these):\n# - Is item open or closed? True = Fully or partially open\n# - Is the belt engaged\n# - Measured Load on axle row 3\n# ── CURRENT (privilege: VEHICLE_ALL_TIME_LOCATION) ──\n# | currentLocationApproximateCoordinates | | Approximate location of the vehicle in WGS 84 coordinates (privilege: VEHICLE_APPROXIMATE_LOCATION VEHICLE_ALL_TIME_LOCATION) |\n# | currentLocationAltitude | m | Current altitude relative to WGS 84 reference ellipsoid, as measured at the position of GNSS receiver antenna |\n# | currentLocationCoordinates | | Current location of the vehicle in WGS 84 coordinates |\n# | currentLocationHeading | degrees | Current heading relative to geographic north |\n# ── OTHER (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | angularVelocityYaw | degrees/s | Vehicle rotation rate along Z (vertical) |\n# | connectivityCellularIsJammingDetected | | Indicates whether cellular radio signal jamming or interference is detected that prevents normal communication |\n# | exteriorAirTemperature | celsius | Air temperature outside the vehicle |\n# | isIgnitionOn | | Vehicle ignition status |\n# | lowVoltageBatteryCurrentVoltage | V | |\n# | speed | km/h | |\n# ── BODY (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | bodyLightsIsAirbagWarningOn | | Indicates whether the airbag/SRS warning telltale is active |\n# | bodyLockIsLocked | | Indicates whether the vehicle is locked via the central locking system |\n# | bodyTrunkFrontIsOpen | | |\n# | bodyTrunkRearIsOpen | | |\n# ── CABIN (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | cabinDoorRow1DriverSideIsOpen | | |\n# | cabinDoorRow1DriverSideWindowIsOpen | | |\n# | cabinDoorRow1PassengerSideIsOpen | | |\n# | cabinDoorRow1PassengerSideWindowIsOpen | | |\n# | cabinDoorRow2DriverSideIsOpen | | |\n# | cabinDoorRow2DriverSideWindowIsOpen | | |\n# | cabinDoorRow2PassengerSideIsOpen | | |\n# | cabinDoorRow2PassengerSideWindowIsOpen | | |\n# | cabinSeatRow1DriverSideIsBelted | | |\n# | cabinSeatRow1PassengerSideIsBelted | | |\n# | cabinSeatRow2DriverSideIsBelted | | |\n# | cabinSeatRow2MiddleIsBelted | | |\n# | cabinSeatRow2PassengerSideIsBelted | | |\n# | cabinSeatRow3DriverSideIsBelted | | |\n# | cabinSeatRow3PassengerSideIsBelted | | |\n# ── CHASSIS (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# shared: Rotational speed of a vehicle's wheel\n# shared: Pneumatic pressure in the service brake circuit or reservoir\n# | chassisAxleRow1WheelLeftSpeed | km/h | |\n# | chassisAxleRow1WheelLeftTirePressure | kPa | |\n# | chassisAxleRow1WheelRightSpeed | km/h | |\n# | chassisAxleRow1WheelRightTirePressure | kPa | |\n# | chassisAxleRow2WheelLeftTirePressure | kPa | |\n# | chassisAxleRow2WheelRightTirePressure | kPa | |\n# | chassisAxleRow3Weight | kg | |\n# | chassisAxleRow4Weight | kg | |\n# | chassisAxleRow5Weight | kg | |\n# | chassisBrakeABSIsWarningOn | | Indicates whether the ABS warning telltale is active (any non-off state) |\n# | chassisBrakeCircuit1PressurePrimary | kPa | |\n# | chassisBrakeCircuit2PressurePrimary | kPa | |\n# | chassisBrakeIsPedalPressed | | Indicates whether the brake pedal is pressed |\n# | chassisBrakePedalPosition | percent | Brake pedal position as percent |\n# | chassisParkingBrakeIsEngaged | | |\n# | chassisTireSystemIsWarningOn | | Indicates whether the tire system warning telltale is active |\n# ── OBD (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# shared: PID 2x (byte CD) - Voltage for wide range/band oxygen sensor\n# | obdBarometricPressure | kPa | PID 33 - Barometric pressure |\n# | obdCommandedEGR | percent | PID 2C - Commanded exhaust gas recirculation (EGR) |\n# | obdCommandedEVAP | percent | PID 2E - Commanded evaporative purge (EVAP) valve |\n# | obdDTCList | | List of currently active DTCs formatted according OBD II (SAE-J2012DA_201812) standard ([P|C|B|U]XXXXX ) |\n# | obdDistanceSinceDTCClear | km | PID 31 - Distance traveled since codes cleared |\n# | obdDistanceWithMIL | km | PID 21 - Distance traveled with MIL on |\n# | obdEngineLoad | percent | PID 04 - Engine load in percent - 0 = no load, 100 = full load |\n# | obdEthanolPercent | percent | PID 52 - Percentage of ethanol in the fuel |\n# | obdFuelPressure | kPa | PID 0A - Fuel pressure |\n# | obdFuelRailPressure | kPa | |\n# | obdFuelRate | l/h | PID 5E - Engine fuel rate |\n# | obdFuelTypeName | | Fuel type names decoded from PID 51 |\n# | obdIntakeTemp | celsius | PID 0F - Intake temperature |\n# | obdIsEngineBlocked | | Engine block status, 0 = engine unblocked, 1 = engine blocked |\n# | obdIsPTOActive | | PID 1E - Auxiliary input status (power take off) |\n# | obdIsPluggedIn | | Aftermarket device plugged in status |\n# | obdLongTermFuelTrim1 | percent | PID 07 - Long Term (learned) Fuel Trim - Bank 1 - negative percent leaner, positive percent richer |\n# | obdLongTermFuelTrim2 | percent | PID 09 - Long Term (learned) Fuel Trim - Bank 2 - negative percent leaner, positive percent richer |\n# | obdMAP | kPa | PID 0B - Intake manifold pressure |\n# | obdMaxMAF | g/s | PID 50 - Maximum flow for mass air flow sensor |\n# | obdO2WRSensor1Voltage | V | |\n# | obdO2WRSensor2Voltage | V | |\n# | obdOilTemperature | celsius | PID 5C - Engine oil temperature |\n# | obdRunTime | s | PID 1F - Engine run time |\n# | obdShortTermFuelTrim1 | percent | PID 06 - Short Term (immediate) Fuel Trim - Bank 1 - negative percent leaner, positive percent richer |\n# | obdStatusDTCCount | | Number of Diagnostic Trouble Codes (DTC) |\n# | obdThrottlePosition | percent | PID 11 - Throttle position - 0 = closed throttle, 100 = open throttle |\n# | obdWarmupsSinceDTCClear | | PID 30 - Number of warm-ups since codes cleared |\n# ── POWERTRAIN (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | powertrainCombustionEngineDieselExhaustFluidCapacity | l | Capacity in liters of the Diesel Exhaust Fluid Tank |\n# | powertrainCombustionEngineDieselExhaustFluidLevel | percent | Level of the Diesel Exhaust Fluid tank as percent of capacity |\n# | powertrainCombustionEngineECT | celsius | Engine coolant temperature |\n# | powertrainCombustionEngineEOP | kPa | Engine oil pressure |\n# | powertrainCombustionEngineEOT | celsius | Engine oil temperature |\n# | powertrainCombustionEngineEngineOilLevel | | |\n# | powertrainCombustionEngineEngineOilRelativeLevel | percent | Engine oil level as a percentage |\n# | powertrainCombustionEngineMAF | g/s | Grams of air drawn into engine per second |\n# | powertrainCombustionEngineSpeed | rpm | Engine speed measured as rotations per minute |\n# | powertrainCombustionEngineTPS | percent | Current throttle position |\n# | powertrainCombustionEngineTorque | Nm | |\n# | powertrainCombustionEngineTorquePercent | percent | Actual engine output torque as a percentage of reference engine torque (FMS / J1939 parameter SPN 513) |\n# | powertrainFuelSystemAbsoluteLevel | l | Current available fuel in the fuel tank expressed in liters |\n# | powertrainFuelSystemAccumulatedConsumption | l | Accumulated fuel consumption (totalized) reported by the vehicle (FMS SPN 250) |\n# | powertrainFuelSystemRelativeLevel | percent | Level in fuel tank as percent of capacity |\n# | powertrainFuelSystemSupportedFuelTypes | | High level information of fuel types supported |\n# | powertrainRange | km | Remaining range in kilometers using all energy sources available in the vehicle |\n# | powertrainTractionBatteryChargingAddedEnergy | kWh | Amount of charge added to the high voltage battery during the current charging session, expressed in kilowatt-hours |\n# | powertrainTractionBatteryChargingChargeCurrentAC | A | Current AC charging current (rms) at inlet |\n# | powertrainTractionBatteryChargingChargeLimit | percent | Target charge limit (state of charge) for battery |\n# | powertrainTractionBatteryChargingChargeVoltageUnknownType | V | Current charging voltage at inlet |\n# | powertrainTractionBatteryChargingIsCharging | | True if charging is ongoing |\n# | powertrainTractionBatteryChargingIsChargingCableConnected | | Indicates if a charging cable is physically connected to the vehicle or not |\n# | powertrainTractionBatteryChargingPower | kW | Instantaneous charging power recorded during a charging event |\n# | powertrainTractionBatteryCurrentPower | W | Current electrical energy flowing in/out of battery |\n# | powertrainTractionBatteryCurrentVoltage | V | |\n# | powertrainTractionBatteryGrossCapacity | kWh | |\n# | powertrainTractionBatteryRange | km | Remaining range in kilometers using only battery |\n# | powertrainTractionBatteryStateOfChargeCurrent | percent | Physical state of charge of the high voltage battery, relative to net capacity |\n# | powertrainTractionBatteryStateOfChargeCurrentEnergy | kWh | Physical state of charge of high voltage battery expressed in kWh |\n# | powertrainTractionBatteryStateOfHealth | percent | Calculated battery state of health at standard conditions |\n# | powertrainTractionBatteryTemperatureAverage | celsius | Current average temperature of the battery cells |\n# | powertrainTransmissionActualGear | | Actual transmission gear currently engaged |\n# | powertrainTransmissionActualGearRatio | | |\n# | powertrainTransmissionCurrentGear | | |\n# | powertrainTransmissionIsClutchSwitchOperated | | Indicates if the Clutch switch is operated, so engine and transmission are partially or fully decoupled |\n# | powertrainTransmissionRetarderActualTorque | percent | Actual retarder torque as a percentage (FMS / J1939 SPN 520) |\n# | powertrainTransmissionRetarderTorqueMode | | Active engine torque mode |\n# | powertrainTransmissionSelectedGear | | |\n# | powertrainTransmissionTemperature | celsius | The current gearbox temperature |\n# | powertrainTransmissionTravelledDistance | km | Odometer reading, total distance travelled during the lifetime of the transmission |\n# | powertrainType | | Defines the powertrain type of the vehicle |\n# ── SERVICE (privilege: VEHICLE_NON_LOCATION_DATA) ──\n# | serviceDistanceToService | km | Remaining distance to service (of any kind) |\n# | serviceTimeToService | s | Remaining time to service (of any kind) |\n\ntype Query {\n signals(\n tokenId: Int!\n \"\"\"\n Duration string for data aggregation buckets (e.g., \"5m\", \"1h\", \"2h45m\"). Valid\n units: ms, s, m, h. Common values: \"5m\" (5 minutes), \"1h\" (1 hour), \"6h\", \"24h\".\n Days are not a valid unit — use \"24h\" instead of \"1d\".\n \"\"\"\n interval: String!\n from: Time!\n to: Time!\n filter: SignalFilter\n ): [SignalAggregations!]\n # Example - Hourly average speed over a time range:\n # query TimeSeries($tokenId:Int!,$from:Time!,$to:Time!) { signals(tokenId:$tokenId,interval:\"1h\",from:$from,to:$to) { timestamp speed(agg:AVG) } }\n\n signalsLatest(tokenId: Int!, filter: SignalFilter): SignalCollection\n # Example - Latest speed and battery charge:\n # query Latest($tokenId:Int!) { signalsLatest(tokenId:$tokenId) { lastSeen speed{timestamp value} powertrainTractionBatteryStateOfChargeCurrent{timestamp value} } }\n\n availableSignals(tokenId: Int!, filter: SignalFilter): [String!]\n \"Point-in-time snapshot of all accessible signals. Equivalent to availableSignals + signalsLatest in a single request.\"\n signalsSnapshot(tokenId: Int!, filter: SignalFilter): SignalsSnapshotResponse\n # Example - Full snapshot of all signals for a vehicle:\n # query Snapshot($tokenId:Int!) { signalsSnapshot(tokenId:$tokenId) { lastSeen signals { name timestamp valueNumber valueString valueLocation { latitude longitude hdop } } } }\n\n dataSummary(tokenId: Int!, filter: SignalFilter): DataSummary\n attestations(tokenId: Int, subject: String, filter: AttestationFilter): [Attestation]\n events(tokenId: Int!, from: Time!, to: Time!, filter: EventFilter): [Event!]\n \"\"\"\n Returns vehicle usage segments detected using the specified mechanism. Maximum\n date range: 31 days.\n Detection mechanisms:\n - ignitionDetection: Uses 'isIgnitionOn' signal with configurable debouncing\n - frequencyAnalysis: Analyzes signal update frequency to detect activity periods\n - changePointDetection: CUSUM-based regime change detection\n - idling: Idling segments (engine rpm idle)\n - refuel: Refueling segments (fuel level increased)\n - recharge: Charging segments (battery SoC increased)\n Segment IDs are stable and consistent across queries as long as the segment\n start is captured in the underlying data source.\n Each segment includes summary: signals, start/end location, and (when requested)\n eventCounts. A default set of signal requests is always applied (e.g. speed,\n odometer; for refuel/recharge also the level signal at start and end). When\n signalRequests is provided, those requests are added on top of the default set;\n duplicates (same name and agg) are omitted.\n \"\"\"\n segments(\n tokenId: Int!\n from: Time!\n to: Time!\n mechanism: DetectionMechanism!\n config: SegmentConfig\n signalRequests: [SegmentSignalRequest!]\n eventRequests: [SegmentEventRequest!]\n \"Maximum number of segments to return. Default 100, max 200.\"\n limit: Int = 100\n after: Time\n ): [Segment!]!\n # Example - Trip segments with start/end locations and signal aggregates:\n # query Trips($tokenId:Int!,$from:Time!,$to:Time!) { segments(tokenId:$tokenId,from:$from,to:$to,mechanism:frequencyAnalysis) { start{timestamp value{latitude longitude}} end{timestamp value{latitude longitude}} duration isOngoing signals{name agg value} eventCounts{name count} } }\n\n \"\"\"\n Returns one record per calendar day in the date range. Mechanism must be\n ignitionDetection, frequencyAnalysis, or changePointDetection (idling, refuel,\n and recharge not allowed). Maximum date range: 31 days.\n \"\"\"\n dailyActivity(tokenId: Int!, from: Time!, to: Time!, mechanism: DetectionMechanism!, config: SegmentConfig, signalRequests: [SegmentSignalRequest!], eventRequests: [SegmentEventRequest!], timezone: String): [DailyActivity!]!\n # Example - Daily activity summaries:\n # query Daily($tokenId:Int!,$from:Time!,$to:Time!) { dailyActivity(tokenId:$tokenId,from:$from,to:$to,mechanism:frequencyAnalysis) { segmentCount duration signals{name agg value} eventCounts{name count} } }\n\n \"Required Privileges: [VEHICLE_VIN_CREDENTIAL]\"\n vinVCLatest(tokenId: Int!): VINVC\n}\n\ntype Attestation { id: String!, vehicleTokenId: Int!, time: Time!, attestation: String!, type: String!, source: Address!, dataVersion: String!, producer: String, signature: String!, tags: [String!] }\n\ninput AttestationFilter {\n id: String\n \"The attesting party.\"\n source: Address\n dataVersion: String\n producer: String\n \"Before this timestamp.\"\n before: Time\n \"After this timestamp.\"\n after: Time\n \"Max results. Default 10.\"\n limit: Int\n \"Pagination cursor (exclusive).\"\n cursor: Time\n tags: StringArrayFilter\n}\n\ntype DailyActivity { start: SignalLocation, end: SignalLocation, segmentCount: Int!, duration: Int!, signals: [SignalAggregationValue!]!, eventCounts: [EventCount!]! }\n\ntype DataSummary { numberOfSignals: Uint64!, availableSignals: [String!]!, firstSeen: Time!, lastSeen: Time!, signalDataSummary: [SignalDataSummary!]!, eventDataSummary: [EventDataSummary!]! }\n\nenum DetectionMechanism {\n \"Ignition-based detection: Segments are identified by isIgnitionOn state transitions. Most reliable for vehicles with proper ignition signal support.\"\n ignitionDetection\n \"Frequency analysis: Segments are detected by analyzing signal update patterns. Uses pre-computed materialized view for optimal performance. Ideal for real-time APIs and bulk queries.\"\n frequencyAnalysis\n \"\"\"\n Change point detection: Uses CUSUM algorithm to detect statistical regime\n changes. Monitors cumulative deviation in signal frequency via materialized\n view. Excellent noise resistance with 100% accuracy match to ignition baseline.\n Best alternative when ignition signal is unavailable - same accuracy, same speed\n as frequency analysis.\n \"\"\"\n changePointDetection\n \"Idling: Segments are contiguous periods where engine RPM remains in idle range.\"\n idling\n \"Refuel: Detects where fuel level rises significantly.\"\n refuel\n \"\"\"\n Recharge: Detects where battery state of charge rises while the vehicle is\n stationary. Aftermarket devices often sleep through a charge, so the segment\n spans from the last reading before the car stopped to the first reading after it\n woke: duration is an upper bound, and maxSampleGapSeconds reports the unobserved\n portion. A session is only reported when both of those readings fall inside\n [from, to]. Consecutive charges at the same odometer with no drop in between are\n reported as one session.\n \"\"\"\n recharge\n}\n\ntype Event { timestamp: Time!, name: String!, source: String!, durationNs: Int!, metadata: String }\n\ntype EventCount { name: String!, count: Int! }\n\ntype EventDataSummary { name: String!, numberOfEvents: Uint64!, firstSeen: Time!, lastSeen: Time! }\n\ninput EventFilter {\n name: StringValueFilter\n \"Source connection that created the event.\"\n source: StringValueFilter\n tags: StringArrayFilter\n}\n\ninput FilterLocation {\n \"Latitude in the range [-90, 90].\"\n latitude: Float!\n \"Longitude in the range [-180, 180].\"\n longitude: Float!\n}\n\nenum FloatAggregation { AVG, MED, MAX, MIN, RAND, FIRST, LAST }\n\ninput InCircleFilter {\n center: FilterLocation!\n \"Radius in kilometers.\"\n radius: Float!\n}\n\ntype LatestSignal { name: String!, timestamp: Time!, valueNumber: Float, valueString: String, valueLocation: Location }\n\ntype Location { latitude: Float!, longitude: Float!, hdop: Float! }\n\nenum LocationAggregation { AVG, RAND, FIRST, LAST }\n\nenum Privilege { VEHICLE_NON_LOCATION_DATA, VEHICLE_COMMANDS, VEHICLE_CURRENT_LOCATION, VEHICLE_ALL_TIME_LOCATION, VEHICLE_VIN_CREDENTIAL, VEHICLE_APPROXIMATE_LOCATION, VEHICLE_RAW_DATA }\n\ntype Segment { start: SignalLocation!, end: SignalLocation, duration: Int!, isOngoing: Boolean!, startedBeforeRange: Boolean!, signals: [SignalAggregationValue!], eventCounts: [EventCount!], maxSampleGapSeconds: Int }\n\ninput SegmentConfig {\n \"\"\"\n Maximum gap (seconds) between data points before a segment is split. For\n ignitionDetection: filters noise from brief ignition OFF events. For\n frequencyAnalysis: maximum gap between active windows to merge. Default: 300 (5\n minutes), Min: 60, Max: 3600\n \"\"\"\n maxGapSeconds: Int = 300\n \"Minimum segment duration (seconds) to include in results. Filters very short segments (testing, engine cycling). Default: 240 (4 minutes), Min: 60, Max: 3600\"\n minSegmentDurationSeconds: Int = 240\n \"\"\"\n [frequencyAnalysis] Minimum signal count per window for activity detection.\n [idling] Minimum samples per window to consider it idle (same semantics). Higher\n values = more conservative. Lower values = more sensitive. Default: 10, Min: 1,\n Max: 3600\n \"\"\"\n signalCountThreshold: Int = 10\n \"[idling only] Upper bound for idle RPM. Windows with max(RPM) <= this are considered idle. Default: 1000, Min: 300, Max: 3000\"\n maxIdleRpm: Int = 1000\n \"[refuel and recharge only] Minimum percent increase within a window to consider it a level-increase window.\"\n minIncreasePercent: Int = 15\n}\n\ninput SegmentEventRequest { name: String! }\n\ninput SegmentSignalRequest { name: String!, agg: FloatAggregation! }\n\ntype SignalAggregationValue { name: String!, agg: String!, value: Float! }\n\ntype SignalAggregations {\n timestamp: Time!\n # + 117 signal fields (see SIGNAL FIELDS table above)\n}\n\ntype SignalCollection {\n lastSeen: Time\n # + 117 signal fields (see SIGNAL FIELDS table above)\n}\n\ntype SignalDataSummary { name: String!, numberOfSignals: Uint64!, firstSeen: Time!, lastSeen: Time! }\n\ninput SignalFilter {\n \"\"\"\n Filter by source ethr DID. Example:\n \"did:ethr:137:0xcd445F4c6bDAD32b68a2939b912150Fe3C88803E\"\n \"\"\"\n source: String\n}\n\ntype SignalFloat { timestamp: Time!, value: Float! }\n\ninput SignalFloatFilter { eq: Float, neq: Float, gt: Float, lt: Float, gte: Float, lte: Float, notIn: [Float!], in: [Float!], or: [SignalFloatFilter!] }\n\ntype SignalLocation { timestamp: Time!, value: Location! }\n\ninput SignalLocationFilter {\n \"Filter for locations within a polygon. The vertices should be ordered clockwise or counterclockwise, and there must be at least 3. May produce inaccurate results around the poles and the antimeridian.\"\n inPolygon: [FilterLocation!]\n \"Filter for locations within a given distance of a given point. Distances are computed using WGS 84, and points that are exactly a distance `radius` from the `center` will be included.\"\n inCircle: InCircleFilter\n}\n\ntype SignalString { timestamp: Time!, value: String! }\n\ntype SignalsSnapshotResponse { lastSeen: Time, signals: [LatestSignal!]! }\n\nenum StringAggregation {\n \"Randomly select a value from the group.\"\n RAND\n \"Select the most frequently occurring value in the group.\"\n TOP\n \"Return a list of unique values in the group.\"\n UNIQUE\n \"Return value in group associated with the minimum time value.\"\n FIRST\n \"Return value in group associated with the maximum time value.\"\n LAST\n}\n\ninput StringArrayFilter { containsAny: [String!], containsAll: [String!], notContainsAny: [String!], notContainsAll: [String!], or: [StringArrayFilter!] }\n\ninput StringValueFilter {\n eq: String\n neq: String\n notIn: [String!]\n in: [String!]\n \"Matches strings that begin with the given prefix.\"\n startsWith: String\n or: [StringValueFilter!]\n}\n\ntype VINVC { vehicleTokenId: Int, vin: String, recordedBy: String, recordedAt: Time, countryCode: String, vehicleContractAddress: String, validFrom: Time, validTo: Time, rawVC: String! }\n" diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 959d26ba..83af517b 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -727,7 +727,9 @@ const ( // Recharge: Detects where battery state of charge rises while the vehicle is stationary. // Aftermarket devices often sleep through a charge, so the segment spans from the last // reading before the car stopped to the first reading after it woke: duration is an - // upper bound, and maxSampleGapSeconds reports the unobserved portion. + // upper bound, and maxSampleGapSeconds reports the unobserved portion. A session is + // only reported when both of those readings fall inside [from, to]. Consecutive charges + // at the same odometer with no drop in between are reported as one session. DetectionMechanismRecharge DetectionMechanism = "recharge" ) diff --git a/internal/service/ch/recharge_detector.go b/internal/service/ch/recharge_detector.go index 40d8b003..92324c64 100644 --- a/internal/service/ch/recharge_detector.go +++ b/internal/service/ch/recharge_detector.go @@ -3,6 +3,7 @@ package ch import ( "context" "fmt" + "math" "sort" "time" @@ -87,17 +88,17 @@ func detectRechargeSessions(soc, odo []levelSample, minDuration int, minRisePct } var candidates []timeRange for _, run := range findMonotoneRuns(soc) { - if tr, ok := validateRechargeRun(soc, odo, run, minDuration, minRisePct); ok { - candidates = append(candidates, tr) - } + candidates = validateRechargeRun(soc, odo, run, minDuration, minRisePct, candidates) } if len(candidates) == 0 { return nil } + // Two sessions merge only if the car sat at the same odometer for both. Compare the odometer at each + // peak (the stationary value) rather than at the boundary samples, which may be shared between sessions. shouldMerge := func(a, b timeRange) bool { - _, odoCurEnd, ok1 := levelFirstLastInRange(odo, a.start, a.end) - odoNextStart, _, ok2 := levelFirstLastInRange(odo, b.start, b.end) - return ok1 && ok2 && odoCurEnd == odoNextStart + odoA, okA := odometerAtOrBefore(odo, a.end) + odoB, okB := odometerAtOrBefore(odo, b.end) + return okA && okB && math.Abs(odoA-odoB) <= rechargeOdometerEpsilonKm } // Zero from/to: no clipping, the samples were already loaded for [from, to). merged := mergeTimeRanges(candidates, rechargeSessionGapMax, minDuration, time.Time{}, time.Time{}, shouldMerge) @@ -143,19 +144,36 @@ func findMonotoneRuns(soc []levelSample) []monotoneRun { return runs } -// validateRechargeRun anchors a run on its stationary core and applies the rise, duration and rate checks. -// The session start reading is the last SoC sample at or before the car last moved (bounded below by the trough), -// so a reading taken while still driving to the charger counts and energy is not under-reported; equal readings -// after that point are skipped so the session starts when SoC last sat at its start value. -func validateRechargeRun(soc, odo []levelSample, run monotoneRun, minDuration int, minRisePct float64) (timeRange, bool) { +// validateRechargeRun anchors a run on its stationary core, applies the rise, duration and rate checks, and +// appends the resulting session (if any) to out. +// +// The session start reading is the lowest SoC sample between the last moving and the first stationary odometer +// sample (bounded below by the trough), so a reading taken while still rolling to the charger counts and a sparse +// odometer does not lag the start; equal readings after that point are skipped so the session starts when SoC +// last sat at its start value. If the car moved after the trough, the part of the run before that movement is +// evaluated on its own so a charge followed by a short hop to a second charger is not folded into the second. +func validateRechargeRun(soc, odo []levelSample, run monotoneRun, minDuration int, minRisePct float64, out []timeRange) []timeRange { peak := soc[run.peakIdx] + // The anchored start can only be at or above the trough, so the raw rise bounds the real one. + if rawRise := peak.value - soc[run.troughIdx].value; rawRise < minRisePct || rawRise <= rechargeRunTolerancePct { + return out + } startIdx := run.troughIdx - if stationaryStart, ok := stationaryStartBefore(odo, peak.ts); ok { - // Last SoC sample at or before the car stopped moving. - idx := sort.Search(len(soc), func(i int) bool { return soc[i].ts.After(stationaryStart) }) - 1 + core, ok := stationaryCoreBefore(odo, peak.ts) + if ok { + idx := lowestSampleInWindow(soc, core.lastMoving, core.start) + if idx < 0 { + // No SoC sample while the car came to rest: last sample at or before it stopped. + idx = sort.Search(len(soc), func(i int) bool { return soc[i].ts.After(core.start) }) - 1 + } if idx > startIdx { startIdx = idx } + // The car moved after the trough: the earlier part of the run may be its own session. + if core.hasLastMoving && core.lastMoving.After(soc[run.troughIdx].ts) { + endIdx := min(startIdx, run.peakIdx-1) + out = validateRechargeRun(soc, odo, monotoneRun{troughIdx: run.troughIdx, peakIdx: firstMaxIdx(soc, run.troughIdx, endIdx)}, minDuration, minRisePct, out) + } } // Skip the flat lead-in: while the next sample has not risen above the start reading the charge has not // begun. This also absorbs sub-epsilon drives that an integer odometer cannot show. @@ -166,32 +184,73 @@ func validateRechargeRun(soc, odo []levelSample, run monotoneRun, minDuration in rise := peak.value - start.value // A rise within the run tolerance is indistinguishable from quantization flicker (46,46,47,46 on integer OBD). if rise < minRisePct || rise <= rechargeRunTolerancePct { - return timeRange{}, false + return out } dur := peak.ts.Sub(start.ts) if int(dur.Seconds()) < minDuration { - return timeRange{}, false + return out } if rise/dur.Hours() > rechargeMaxRatePctPerHour { - return timeRange{}, false + return out } - return timeRange{start: start.ts, end: peak.ts}, true + return append(out, timeRange{start: start.ts, end: peak.ts}) } -// stationaryStartBefore walks odometer samples backward from t and returns the timestamp of the earliest sample -// after which the odometer stayed within rechargeOdometerEpsilonKm of its value at t, i.e. when the car last moved. -// ok is false when there is no odometer sample at or before t (no odometer data: caller keeps the trough). -func stationaryStartBefore(odo []levelSample, t time.Time) (time.Time, bool) { +// stationaryCore describes the stretch before a peak during which the odometer did not move. +type stationaryCore struct { + start time.Time // first odometer sample within epsilon of the value at the peak + lastMoving time.Time // the odometer sample before start (the car was still moving at this time) + hasLastMoving bool // false when the odometer series begins inside the stationary stretch +} + +// stationaryCoreBefore walks odometer samples backward from t while they stay within rechargeOdometerEpsilonKm of +// the value at t. ok is false when there is no odometer sample at or before t (no odometer data: caller keeps the trough). +func stationaryCoreBefore(odo []levelSample, t time.Time) (stationaryCore, bool) { last := sort.Search(len(odo), func(i int) bool { return odo[i].ts.After(t) }) - 1 if last < 0 { - return time.Time{}, false + return stationaryCore{}, false } odoAtPeak := odo[last].value k := last - for k > 0 && odoAtPeak-odo[k-1].value <= rechargeOdometerEpsilonKm { + for k > 0 && math.Abs(odoAtPeak-odo[k-1].value) <= rechargeOdometerEpsilonKm { k-- } - return odo[k].ts, true + core := stationaryCore{start: odo[k].ts} + if k > 0 { + core.lastMoving, core.hasLastMoving = odo[k-1].ts, true + } + return core, true +} + +// lowestSampleInWindow returns the index of the first lowest sample with ts in (after, until], or -1 if none. +func lowestSampleInWindow(soc []levelSample, after, until time.Time) int { + best := -1 + for i := sort.Search(len(soc), func(i int) bool { return soc[i].ts.After(after) }); i < len(soc) && !soc[i].ts.After(until); i++ { + if best < 0 || soc[i].value < soc[best].value { + best = i + } + } + return best +} + +// firstMaxIdx returns the index of the first sample holding the maximum value in soc[from..to]. +func firstMaxIdx(soc []levelSample, from, to int) int { + best := from + for i := from + 1; i <= to; i++ { + if soc[i].value > soc[best].value { + best = i + } + } + return best +} + +// odometerAtOrBefore returns the odometer value at or before t. ok is false if there is none. +func odometerAtOrBefore(odo []levelSample, t time.Time) (float64, bool) { + idx := sort.Search(len(odo), func(i int) bool { return odo[i].ts.After(t) }) - 1 + if idx < 0 { + return 0, false + } + return odo[idx].value, true } // maxSampleGapSeconds returns the longest interval within tr with no SoC sample, including the lead-in from diff --git a/internal/service/ch/recharge_detector_test.go b/internal/service/ch/recharge_detector_test.go index 1389f871..3360ec4f 100644 --- a/internal/service/ch/recharge_detector_test.go +++ b/internal/service/ch/recharge_detector_test.go @@ -184,6 +184,42 @@ func TestDetectRechargeSessions(t *testing.T) { require.Equal(t, at(180), got[1].end) }) + t.Run("a short hop between two charges with no SoC drop yields two sessions", func(t *testing.T) { + // Slow charge 44->79 at home, 2 km hop (integer SoC shows no drop), fast charge 79->90. + soc := newSeries(base, pt{0, 44}, pt{300, 60}, pt{600, 79}, pt{640, 79}, pt{680, 85}, pt{700, 90}, pt{720, 89}) + odo := newSeries(base, pt{0, 1000}, pt{600, 1000}, pt{620, 1001}, pt{640, 1002}, pt{700, 1002}, pt{720, 1010}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 2) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(600), got[0].end) + require.Equal(t, at(640), got[1].start) + require.Equal(t, at(700), got[1].end) + }) + + t.Run("sparse odometer does not lag the start past the arrival reading", func(t *testing.T) { + // Tesla shape: SoC every minute, odometer every 5 minutes. The car stops at minute 7 and + // starts charging at minute 9; the first stationary odometer sample is at minute 10. + socPts := []pt{{0, 20}, {1, 18}, {2, 17}, {3, 15}, {4, 13}, {5, 12}, {6, 12}, {7, 11}, {8, 11}} + for m := 9; m <= 30; m++ { + socPts = append(socPts, pt{float64(m), 11 + 2*float64(m-8)}) + } + socPts = append(socPts, pt{45, 55}, pt{60, 55}, pt{65, 54}) + odo := newSeries(base, pt{0, 1000}, pt{5, 1003}, pt{10, 1005.2}, pt{15, 1005.2}, pt{60, 1005.2}, pt{65, 1008}) + got := detectRechargeSessions(newSeries(base, socPts...), odo, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(8), got[0].start, "start is the last 11%% reading, not the 15%% at the first stationary odometer sample") + require.Equal(t, at(30), got[0].end) + }) + + t.Run("duplicate timestamps do not panic and still detect the rise", func(t *testing.T) { + soc := newSeries(base, pt{0, 44}, pt{0, 44}, pt{600, 79}, pt{600, 79}) + odo := newSeries(base, pt{0, 1000}, pt{0, 1000}, pt{600, 1000}) + got := detectRechargeSessions(soc, odo, minDur, minRise) + require.Len(t, got, 1) + require.Equal(t, at(0), got[0].start) + require.Equal(t, at(600), got[0].end) + }) + t.Run("fewer than two SoC samples yields nothing", func(t *testing.T) { require.Empty(t, detectRechargeSessions(nil, nil, minDur, minRise)) require.Empty(t, detectRechargeSessions(newSeries(base, pt{0, 50}), nil, minDur, minRise)) @@ -208,6 +244,9 @@ func TestRechargeSessionsToSegments(t *testing.T) { require.Empty(t, rechargeSessionsToSegments(nil, from)) require.NotNil(t, rechargeSessionsToSegments(nil, from)) + + atFrom := rechargeSessionsToSegments([]rechargeSession{{start: from, end: base}}, from) + require.True(t, atFrom[0].StartedBeforeRange) } func TestLevelFirstLastInRange(t *testing.T) { diff --git a/internal/service/ch/recharge_realdata_test.go b/internal/service/ch/recharge_realdata_test.go index 901437b5..40e62086 100644 --- a/internal/service/ch/recharge_realdata_test.go +++ b/internal/service/ch/recharge_realdata_test.go @@ -21,6 +21,7 @@ import ( // "odo": [{"ts": "RFC3339", "value": 12345.6}, ...], // "expectedStarts": ["RFC3339", ...], // a session must start within expectTolerance of each // "unexpectedStarts": ["RFC3339", ...], // no session may start within expectTolerance of any +// "laterWindowFrom": "RFC3339", // optional: detection over [this, end) must not be empty // "expectedSoc": [{"start": "RFC3339", "from": 23, "to": 56}, ...] // } const rechargeRealDataEnv = "RECHARGE_REALDATA_JSON" @@ -40,6 +41,7 @@ type realDataExport struct { Odo []realDataSample `json:"odo"` ExpectedStarts []time.Time `json:"expectedStarts"` UnexpectedStarts []time.Time `json:"unexpectedStarts"` + LaterWindowFrom *time.Time `json:"laterWindowFrom"` // optional: a narrower window that must still yield sessions ExpectedSoc []struct { Start time.Time `json:"start"` From float64 `json:"from"` @@ -112,6 +114,9 @@ func TestRechargeRealData(t *testing.T) { } } cut := last.Add(-24 * time.Hour) + if export.LaterWindowFrom != nil { + cut = *export.LaterWindowFrom + } cutSoc := soc[sort.Search(len(soc), func(i int) bool { return !soc[i].ts.Before(cut) }):] cutOdo := odo[sort.Search(len(odo), func(i int) bool { return !odo[i].ts.Before(cut) }):] require.NotEmpty(t, detectRechargeSessions(cutSoc, cutOdo, rechargeDefaultMinDurationSeconds, rechargeMinRisePct), diff --git a/schema/segments.graphqls b/schema/segments.graphqls index 285a26b6..f87e664e 100644 --- a/schema/segments.graphqls +++ b/schema/segments.graphqls @@ -34,7 +34,9 @@ enum DetectionMechanism { Recharge: Detects where battery state of charge rises while the vehicle is stationary. Aftermarket devices often sleep through a charge, so the segment spans from the last reading before the car stopped to the first reading after it woke: duration is an - upper bound, and maxSampleGapSeconds reports the unobserved portion. + upper bound, and maxSampleGapSeconds reports the unobserved portion. A session is + only reported when both of those readings fall inside [from, to]. Consecutive charges + at the same odometer with no drop in between are reported as one session. """ recharge } From b9391d9cffbb194588ca703733f9c6575ae7b1ca Mon Sep 17 00:00:00 2001 From: zer0stars <74260741+zer0stars@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:34:12 -0400 Subject: [PATCH 3/4] test(e2e): real-data recharge harness through the full HTTP stack 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. --- e2e/recharge_realdata_test.go | 208 ++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 e2e/recharge_realdata_test.go diff --git a/e2e/recharge_realdata_test.go b/e2e/recharge_realdata_test.go new file mode 100644 index 00000000..8698802c --- /dev/null +++ b/e2e/recharge_realdata_test.go @@ -0,0 +1,208 @@ +package e2e_test + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/DIMO-Network/cloudevent" + "github.com/DIMO-Network/model-garage/pkg/vss" + "github.com/DIMO-Network/token-exchange-api/pkg/tokenclaims" + "github.com/stretchr/testify/require" +) + +// TestRechargeRealDataEndToEnd loads a local export of a vehicle's SoC and odometer series into the +// ClickHouse container and runs the full HTTP stack (JWT auth, resolvers, repository summary signals, +// MCP tool) over it. Skips unless RECHARGE_REALDATA_JSON points at an export; the file is never committed. +// Same format as internal/service/ch/recharge_realdata_test.go, plus a "tokenId" field. +func TestRechargeRealDataEndToEnd(t *testing.T) { + path := os.Getenv("RECHARGE_REALDATA_JSON") + if path == "" { + t.Skip("RECHARGE_REALDATA_JSON not set") + } + raw, err := os.ReadFile(path) + require.NoError(t, err) + var export struct { + TokenID int `json:"tokenId"` + From time.Time `json:"from"` + To time.Time `json:"to"` + Soc, Odo []struct { + Ts time.Time `json:"ts"` + Value float64 `json:"value"` + } + ExpectedStarts []time.Time `json:"expectedStarts"` + UnexpectedStarts []time.Time `json:"unexpectedStarts"` + LaterWindowFrom *time.Time `json:"laterWindowFrom"` + ExpectedSoc []struct { + Start time.Time `json:"start"` + From float64 `json:"from"` + To float64 `json:"to"` + } `json:"expectedSoc"` + } + require.NoError(t, json.Unmarshal(raw, &export)) + require.NotZero(t, export.TokenID) + + services := GetTestServices(t) + subject := fmt.Sprintf("did:erc721:137:0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF:%d", export.TokenID) + var signals []vss.Signal + for name, series := range map[string][]struct { + Ts time.Time `json:"ts"` + Value float64 `json:"value"` + }{vss.FieldPowertrainTractionBatteryStateOfChargeCurrent: export.Soc, vss.FieldPowertrainTransmissionTravelledDistance: export.Odo} { + for _, s := range series { + signals = append(signals, vss.Signal{ + CloudEventHeader: cloudevent.CloudEventHeader{Source: "0x0000000000000000000000000000000000000001", Subject: subject}, + Data: vss.SignalData{Timestamp: s.Ts, Name: name, ValueNumber: s.Value}, + }) + } + } + insertSignal(t, services.CH, signals) + t.Logf("inserted %d signal rows for token %d", len(signals), export.TokenID) + + client := NewGraphQLServer(t, services.Settings) + token := services.Auth.CreateVehicleToken(t, export.TokenID, []string{tokenclaims.PermissionGetNonLocationHistory, tokenclaims.PermissionGetLocationHistory}) + + type segment struct { + Start struct{ Timestamp string } + End *struct{ Timestamp string } + Duration int + IsOngoing bool + StartedBeforeRange bool + MaxSampleGapSeconds *int + Signals []struct { + Name string + Agg string + Value float64 + } + } + const selection = `start { timestamp } end { timestamp } duration isOngoing startedBeforeRange maxSampleGapSeconds signals { name agg value }` + query := func(t *testing.T, mechanism string, from, to time.Time, config string) []segment { + t.Helper() + var res struct{ Segments []segment } + if !strings.Contains(config, "limit:") { + config += ", limit: 200" + } + q := fmt.Sprintf(`query { segments(tokenId: %d, from: %q, to: %q, mechanism: %s%s) { %s } }`, + export.TokenID, from.Format(time.RFC3339), to.Format(time.RFC3339), mechanism, config, selection) + require.NoError(t, client.Post(q, &res, WithToken(token))) + return res.Segments + } + socAgg := func(s segment, agg string) float64 { + for _, sig := range s.Signals { + if sig.Name == vss.FieldPowertrainTractionBatteryStateOfChargeCurrent && sig.Agg == agg { + return sig.Value + } + } + return -1 + } + ts := func(s string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339Nano, s) + require.NoError(t, err) + return parsed + } + near := func(a, b time.Time) bool { d := a.Sub(b); return d > -15*time.Minute && d < 15*time.Minute } + findStart := func(segs []segment, want time.Time) (segment, bool) { + for _, s := range segs { + if near(ts(s.Start.Timestamp), want) { + return s, true + } + } + return segment{}, false + } + + // Segments are only detected up to now; the export window may extend past that. + to := export.To + if now := time.Now().UTC(); to.After(now) { + to = now + } + + segs := query(t, "recharge", export.From, to, "") + t.Logf("recharge over full window: %d segments", len(segs)) + for _, s := range segs { + gap := -1 + if s.MaxSampleGapSeconds != nil { + gap = *s.MaxSampleGapSeconds + } + t.Logf(" %s -> %s dur=%6d gap=%6d soc %.0f->%.0f", ts(s.Start.Timestamp).Format("01-02 15:04"), ts(s.End.Timestamp).Format("01-02 15:04"), s.Duration, gap, socAgg(s, "FIRST"), socAgg(s, "LAST")) + } + + t.Run("every recharge segment carries maxSampleGapSeconds within duration and a positive SoC delta", func(t *testing.T) { + require.NotEmpty(t, segs) + for _, s := range segs { + require.NotNil(t, s.MaxSampleGapSeconds, "segment at %s", s.Start.Timestamp) + require.LessOrEqual(t, *s.MaxSampleGapSeconds, s.Duration) + require.False(t, s.IsOngoing) + require.NotNil(t, s.End) + require.Equal(t, int(ts(s.End.Timestamp).Sub(ts(s.Start.Timestamp)).Seconds()), s.Duration) + require.Greater(t, socAgg(s, "LAST"), socAgg(s, "FIRST"), "segment at %s", s.Start.Timestamp) + } + }) + + t.Run("expected sessions are returned with the expected summary SoC", func(t *testing.T) { + for _, want := range export.ExpectedStarts { + _, ok := findStart(segs, want) + require.Truef(t, ok, "expected a segment starting near %s", want) + } + for _, unwanted := range export.UnexpectedStarts { + _, ok := findStart(segs, unwanted) + require.Falsef(t, ok, "expected no segment starting near %s", unwanted) + } + for _, e := range export.ExpectedSoc { + s, ok := findStart(segs, e.Start) + require.True(t, ok) + require.InDelta(t, e.From, socAgg(s, "FIRST"), 0.5, "FIRST SoC at %s", e.Start) + require.InDelta(t, e.To, socAgg(s, "LAST"), 0.5, "LAST SoC at %s", e.Start) + } + }) + + t.Run("a narrower window still yields sessions", func(t *testing.T) { + if export.LaterWindowFrom == nil { + t.Skip("laterWindowFrom not set") + } + later := query(t, "recharge", *export.LaterWindowFrom, to, "") + t.Logf("recharge from %s: %d segments", export.LaterWindowFrom.Format(time.RFC3339), len(later)) + require.NotEmpty(t, later) + for _, s := range later { + require.False(t, ts(s.Start.Timestamp).Before(*export.LaterWindowFrom)) + } + }) + + t.Run("minIncreasePercent override filters small sessions", func(t *testing.T) { + big := query(t, "recharge", export.From, to, ", config: {minIncreasePercent: 30}") + t.Logf("recharge with minIncreasePercent 30: %d segments", len(big)) + require.Less(t, len(big), len(segs)) + for _, s := range big { + require.GreaterOrEqual(t, socAgg(s, "LAST")-socAgg(s, "FIRST"), 30.0, "segment at %s", s.Start.Timestamp) + } + }) + + t.Run("pagination with after and limit", func(t *testing.T) { + first := query(t, "recharge", export.From, to, ", limit: 3") + require.Len(t, first, 3) + rest := query(t, "recharge", export.From, to, fmt.Sprintf(", after: %q", first[2].Start.Timestamp)) + require.Len(t, rest, len(segs)-3) + require.True(t, ts(rest[0].Start.Timestamp).After(ts(first[2].Start.Timestamp))) + }) + + t.Run("maxSampleGapSeconds is null for other mechanisms", func(t *testing.T) { + other := query(t, "frequencyAnalysis", export.From, to, ", config: {signalCountThreshold: 1}") + t.Logf("frequencyAnalysis: %d segments", len(other)) + for _, s := range other { + require.Nil(t, s.MaxSampleGapSeconds) + } + require.Empty(t, query(t, "refuel", export.From, to, "")) + }) + + t.Run("MCP get_trip_segments returns the recharge sessions", func(t *testing.T) { + mcp := newMCPServer(t, services.Settings) + text, isErr := callTool(t, mcp.URL, token, "telemetry_get_trip_segments", map[string]any{ + "tokenId": export.TokenID, "from": export.From.Format(time.RFC3339), "to": to.Format(time.RFC3339), "mechanism": "recharge", + }) + require.False(t, isErr, text) + require.Equal(t, len(segs), strings.Count(text, `"duration"`), "MCP should list the same sessions as GraphQL") + }) +} From 5c8c53d7163dd3677eb1952a4785d1b3580b3531 Mon Sep 17 00:00:00 2001 From: zer0stars <74260741+zer0stars@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:34:12 -0400 Subject: [PATCH 4/4] fix(segments): enforce the exclusive after cursor 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. --- internal/repositories/segments.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/repositories/segments.go b/internal/repositories/segments.go index 3c8c88f7..a621de66 100644 --- a/internal/repositories/segments.go +++ b/internal/repositories/segments.go @@ -225,6 +225,17 @@ func (r *Repository) GetSegments(ctx context.Context, tokenID int, from, to time if err != nil { return nil, handleDBError(ctx, err) } + // The ClickHouse bound is truncated to microseconds, so a segment starting exactly at `after` can come back; + // enforce the exclusive cursor here. + if after != nil { + kept := chSegments[:0] + for _, seg := range chSegments { + if seg.Start.Timestamp.After(*after) { + kept = append(kept, seg) + } + } + chSegments = kept + } // Apply limit before building ranges and batch queries so we don't run agg/event-count for segments we'll drop. if limit != nil && len(chSegments) > *limit { chSegments = chSegments[:*limit]