Skip to content

Commit 2c42870

Browse files
malysclaude
andcommitted
fix(telemetry): stop the estimate flickering, and stop the history eating itself
Five defects in the paths that feed EVChargePilot's range figure and its trip store. The adaptive range gated on the live battery power reading. The question that gate asks is whether this firmware can measure power at all, and the answer does not change between two polls — but one unanswered sample erased a figure computed from stored trips and the pack's charge, neither of which had moved. Latched instead: never published means refuse, once published stays published. Trips that happened to agree gave a band of exactly zero. "266,7 ± 0,0 km" reads as a measurement, which is the one thing provenance exists to stop an estimate doing; the spread of eight of one driver's trips is not the error of extrapolating to a pack. The band gets a floor. Quarantining a corrupt history copied it aside and left the original, so every later read failed on it again and wrote another copy — one file per read, unbounded, where the driver cannot see them. The original now goes. Bounding the file re-encoded every remaining trip after each evicted track, up to two hundred trips of four thousand samples, on the path that ends a drive. The envelope's size is arithmetic, so eviction subtracts as it goes. Decimation dropped the newest sample. The track halves when the sample just added overflows it, and on an even-sized track that sample sits at an odd index — so every doubling discarded the most recent moment of the trip, which is the one a model can least do without. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7e67352 commit 2c42870

5 files changed

Lines changed: 151 additions & 24 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,39 @@ All notable changes to this project are documented here. Format follows
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- **A momentarily unreadable battery power no longer blanks a settled range estimate.**
12+
`AdaptiveRangeEstimator` gated on the live power reading, so one sample the firmware failed to
13+
answer erased a figure computed from stored trips and the pack's charge — neither of which had
14+
moved. The question the gate asks is "can this firmware measure power at all", and that answer
15+
does not change between two polls, so it is latched: a firmware that has never published power
16+
still refuses to borrow another firmware's history, and one that published it a second ago is
17+
not treated as having lost the ability since.
18+
19+
- **An estimate no longer claims a band of exactly zero.** Eight of one driver's trips that
20+
happened to agree produced "266,7 ± 0,0 km", which reads as a measurement — the one thing
21+
provenance exists to stop an estimate doing. The spread of a handful of trips is not the error
22+
of extrapolating to a whole pack: terrain, load, weather and speed all sit outside the fit. The
23+
band now has a floor of ten percent of the estimate.
24+
25+
- **A corrupt trip history no longer multiplies itself.** `EnergyTripHistoryStore.quarantine`
26+
copied the unreadable file aside and left the original in place, so the next read failed to
27+
parse it again and wrote another copy — one file per read, unbounded, in storage the driver
28+
cannot see. The original now goes, by rename or by copy-then-delete, and a copy that itself
29+
fails is removed rather than left behind.
30+
31+
- **Ending a drive no longer re-encodes the whole history once per evicted track.** Bounding the
32+
file measured every remaining trip again after each step, over as many as two hundred trips of
33+
four thousand samples, on the path that closes a trip. The envelope's size is arithmetic —
34+
empty envelope, plus each trip, plus one comma between each pair — so eviction now subtracts as
35+
it goes and each trip is serialised once.
36+
37+
- **Decimating a track no longer throws away the newest sample.** `TripSampleTrack` halves itself
38+
when the sample just added overflows it, and on an even-sized track that sample sits at an odd
39+
index: taking every second one from the start discarded the most recent moment of the trip at
40+
every doubling. It is now kept explicitly.
41+
942
### Added
1043

1144
- **`SaicNav.probeTransaction` — what a navigation transaction replies, recorded rather than

‎lib/src/main/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimator.kt‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.evsuite.hardware.telemetry
22

3+
import kotlin.math.max
34
import kotlin.math.sqrt
45

56
/**
@@ -13,6 +14,16 @@ class AdaptiveRangeEstimator(
1314
private val maxConsumptionSamples: Int = MAX_CONSUMPTION_SAMPLES,
1415
private val minimumConsumptionSamples: Int = MINIMUM_CONSUMPTION_SAMPLES,
1516
) {
17+
/**
18+
* Whether this vehicle has ever published battery power to this instance.
19+
*
20+
* The capability question is "can this firmware measure power at all", and the answer does
21+
* not change between two polls. Asking the live reading instead made a single unreadable
22+
* sample blank an estimate computed from stored trips and the pack's charge — neither of
23+
* which had moved — so the figure flickered for a reason that had nothing to do with it.
24+
*/
25+
private var powerEverPublished = false
26+
1627
init {
1728
require(minimumConsumptionSamples >= 2) { "at least two samples are needed for spread" }
1829
require(maxConsumptionSamples >= minimumConsumptionSamples) {
@@ -23,18 +34,18 @@ class AdaptiveRangeEstimator(
2334
/**
2435
* Estimates remaining range without replacing [EnergySnapshot.rangeKm].
2536
*
26-
* A current power reading is the capability gate. A firmware that cannot publish battery
27-
* power must never reuse another firmware's stored history to claim an adaptive range.
37+
* Battery power is the capability gate, latched rather than sampled: a firmware that has
38+
* never published it must not reuse another firmware's stored history to claim an adaptive
39+
* range, but one that published it a second ago has not lost the ability since.
2840
*/
2941
fun estimate(
3042
snapshot: EnergySnapshot,
3143
currentTrip: EnergyTripSummary?,
3244
recentTrips: List<EnergyTripSummary>,
3345
missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
3446
): Provenanced<Double> {
35-
if (snapshot.batteryPowerKw?.isFinite() != true) {
36-
return Provenanced.unavailable(missingReason)
37-
}
47+
if (snapshot.batteryPowerKw?.isFinite() == true) powerEverPublished = true
48+
if (!powerEverPublished) return Provenanced.unavailable(missingReason)
3849

3950
val samples = consumptionSamples(currentTrip, recentTrips)
4051
if (samples.size < minimumConsumptionSamples) {
@@ -55,8 +66,13 @@ class AdaptiveRangeEstimator(
5566
delta * delta
5667
} / (samples.size - 1).toDouble()
5768
val consumptionSpread = sqrt(variance)
58-
val rangeUncertaintyKm = estimatedKm * consumptionSpread / meanConsumption
59-
if (!rangeUncertaintyKm.isFinite()) return Provenanced.unavailable(missingReason)
69+
val observedBandKm = estimatedKm * consumptionSpread / meanConsumption
70+
if (!observedBandKm.isFinite()) return Provenanced.unavailable(missingReason)
71+
// A floor, because the spread of at most eight of one driver's trips is not the model's
72+
// error. Trips that happened to agree produce a band of exactly zero, and "266.7 ± 0.0
73+
// km" reads as a measurement — the one thing Provenance exists to stop an estimate
74+
// doing. The floor is what the model cannot claim to know better than.
75+
val rangeUncertaintyKm = max(observedBandKm, estimatedKm * MIN_RELATIVE_UNCERTAINTY)
6076
return Provenanced.estimated(estimatedKm, rangeUncertaintyKm)
6177
}
6278

@@ -95,5 +111,14 @@ class AdaptiveRangeEstimator(
95111

96112
/** Three observations permit a centre and a non-degenerate observed spread. */
97113
const val MINIMUM_CONSUMPTION_SAMPLES = 3
114+
115+
/**
116+
* The narrowest band this model is allowed to claim, as a fraction of the estimate.
117+
*
118+
* Eight trips of one driver do not measure the error of extrapolating to a whole pack:
119+
* terrain, load, weather and speed all sit outside the fit. Ten percent is the width
120+
* below which the figure would invite more trust than the method can carry.
121+
*/
122+
const val MIN_RELATIVE_UNCERTAINTY = 0.10
98123
}
99124
}

‎lib/src/main/java/com/evsuite/hardware/telemetry/EnergyTripHistoryStore.kt‎

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -105,31 +105,62 @@ class EnergyTripHistoryStore(
105105
val trips: List<StoredTrip>? = null,
106106
)
107107

108-
/** The unreadable file keeps its contents under a new name; the history starts again. */
108+
/**
109+
* The unreadable file keeps its contents under a new name; the history starts again.
110+
*
111+
* The original has to go, by rename or by copy-then-delete. Leaving it in place made every
112+
* later read fail to parse it again and write another quarantine copy — one file per read,
113+
* without a bound, in storage the driver cannot see. When even the copy fails the partial
114+
* is removed rather than left behind: nothing was preserved, so nothing should accumulate.
115+
*/
109116
private fun quarantine() {
110117
val kept = File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}")
111-
if (!target.renameTo(kept)) {
112-
runCatching { target.copyTo(kept, overwrite = true) }
113-
}
118+
if (target.renameTo(kept)) return
119+
if (runCatching { target.copyTo(kept, overwrite = true) }.isSuccess) target.delete()
120+
else kept.delete()
114121
}
115122

123+
/**
124+
* Applies the three bounds, costing one serialisation per trip rather than one per step.
125+
*
126+
* The envelope is `{...,"trips":[t1,t2,...]}`, so its size is the empty envelope plus every
127+
* trip's own JSON plus one comma between each pair. Keeping that arithmetic lets eviction
128+
* subtract as it goes. Measuring the whole file again after every dropped track re-encoded
129+
* a graph of up to two hundred trips and four thousand samples each, once per step, on the
130+
* path that ends a drive.
131+
*/
116132
private fun bound(trips: List<StoredTrip>): List<StoredTrip> {
117-
var candidate = trips.take(maxTrips.coerceAtLeast(1))
133+
val candidate = trips.take(maxTrips.coerceAtLeast(1)).toMutableList()
134+
val sizes = candidate.mapTo(ArrayList()) { sizeOf(it) }
135+
var total = totalOf(sizes)
136+
118137
// Tracks first: a trip without its track is still a trip, a missing trip is not.
119-
while (sizeOf(candidate) > maxBytes && candidate.any { it.samples != null }) {
138+
while (total > maxBytes) {
120139
val oldest = candidate.indexOfLast { it.samples != null }
121-
candidate = candidate.toMutableList().also {
122-
it[oldest] = it[oldest].copy(samples = null)
123-
}
140+
if (oldest < 0) break
141+
val stripped = candidate[oldest].copy(samples = null)
142+
candidate[oldest] = stripped
143+
sizes[oldest] = sizeOf(stripped)
144+
total = totalOf(sizes)
124145
}
125-
while (sizeOf(candidate) > maxBytes && candidate.size > 1) {
126-
candidate = candidate.dropLast(1)
146+
while (total > maxBytes && candidate.size > 1) {
147+
candidate.removeAt(candidate.lastIndex)
148+
sizes.removeAt(sizes.lastIndex)
149+
total = totalOf(sizes)
127150
}
128151
return candidate
129152
}
130153

131-
private fun sizeOf(trips: List<StoredTrip>): Int =
132-
gson.toJson(TripHistoryFile(trips = trips)).toByteArray(Charsets.UTF_8).size
154+
/** Empty envelope, plus every trip, plus the comma between each pair. */
155+
private fun totalOf(sizes: List<Int>): Int =
156+
emptyEnvelopeBytes + sizes.sum() + (sizes.size - 1).coerceAtLeast(0)
157+
158+
private val emptyEnvelopeBytes: Int by lazy {
159+
gson.toJson(TripHistoryFile(trips = emptyList())).toByteArray(Charsets.UTF_8).size
160+
}
161+
162+
private fun sizeOf(trip: StoredTrip): Int =
163+
gson.toJson(trip).toByteArray(Charsets.UTF_8).size
133164

134165
private fun write(file: TripHistoryFile): Boolean {
135166
val bytes = gson.toJson(file).toByteArray(Charsets.UTF_8)

‎lib/src/main/java/com/evsuite/hardware/telemetry/TripSample.kt‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,21 @@ class TripSampleTrack(
7171

7272
fun samples(): List<TripSample> = samples.toList()
7373

74-
/** Halves the track in place and doubles the interval, keeping first and last. */
74+
/**
75+
* Halves the track in place and doubles the interval, keeping first and last.
76+
*
77+
* The last sample is kept explicitly rather than by parity. Decimation is triggered by the
78+
* sample that has just been added, and on an even-sized track that sample sits at an odd
79+
* index — taking every second one from the start would throw away the newest moment of the
80+
* trip at every doubling, which is the one a model is least able to do without.
81+
*/
7582
private fun decimate() {
83+
val newest = samples.lastIndex
7684
var write = 0
7785
for (read in samples.indices) {
78-
if (read % 2 == 0) samples[write++] = samples[read]
86+
if (read % 2 == 0 && read != newest) samples[write++] = samples[read]
7987
}
88+
samples[write++] = samples[newest]
8089
while (samples.size > write) samples.removeAt(samples.size - 1)
8190
intervalMs *= 2
8291
}

‎lib/src/test/java/com/evsuite/hardware/telemetry/AdaptiveRangeEstimatorTest.kt‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,36 @@ class AdaptiveRangeEstimatorTest {
2626

2727
assertEquals(Provenance.ESTIMATED, estimate.provenance)
2828
assertEquals(200.0, estimate.value!!, 1e-9)
29-
assertEquals(0.0, estimate.uncertainty!!, 1e-9)
29+
// Trips that happened to agree do not make the model certain: the band falls back to
30+
// its floor rather than to zero, which would read as a measurement.
31+
assertEquals(20.0, estimate.uncertainty!!, 1e-9)
32+
}
33+
34+
@Test fun `an estimate never claims a band narrower than the model's floor`() {
35+
val estimate = estimator.estimate(
36+
snapshot(energyKwh = 40f), null,
37+
listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)),
38+
)
39+
40+
assertEquals(
41+
estimate.value!! * AdaptiveRangeEstimator.MIN_RELATIVE_UNCERTAINTY,
42+
estimate.uncertainty!!,
43+
1e-9,
44+
)
45+
assertTrue(estimate.uncertainty!! > 0.0)
46+
}
47+
48+
@Test fun `a momentarily unreadable power signal does not blank a settled estimate`() {
49+
val history = listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L))
50+
val withPower = snapshot(energyKwh = 40f)
51+
val withoutPower = withPower.copy(batteryPowerKw = null)
52+
53+
assertEquals(Provenance.ESTIMATED, estimator.estimate(withPower, null, history).provenance)
54+
// The capability was established a sample ago and the car has not lost it since; the
55+
// estimate depends on stored trips and pack charge, neither of which moved.
56+
val next = estimator.estimate(withoutPower, null, history)
57+
assertEquals(Provenance.ESTIMATED, next.provenance)
58+
assertEquals(200.0, next.value!!, 1e-9)
3059
}
3160

3261
@Test fun `volatile consumption widens the band without moving the estimate`() {
@@ -74,7 +103,7 @@ class AdaptiveRangeEstimatorTest {
74103
)
75104

76105
assertEquals(200.0, estimate.value!!, 1e-9)
77-
assertEquals(0.0, estimate.uncertainty!!, 1e-9)
106+
assertEquals(20.0, estimate.uncertainty!!, 1e-9)
78107
}
79108

80109
private fun trip(consumption: Double, endedAtMs: Long): EnergyTripSummary {

0 commit comments

Comments
 (0)