Skip to content

Commit eddcf0f

Browse files
malysclaude
andcommitted
feat(telemetry): version the distance conversion, restore the odometer
Two consequences of the 3.6x speed error, both of which outlive the fix. A trip distance is the integral of speed, so it inherits whatever conversion was in force when it was recorded. Every trip already stored carries a distance 3.6 times too long and nothing in the number says so: 8.46 km for a 2.4 km route reads exactly like 8.46 km for an 8.46 km route. A model trained on that history would learn the error. VehicleSpeedEvidence is BatteryPowerEvidence's counterpart on the distance side, and EnergyTripSummary.modellableDistanceKm answers only for a distance the current conversion produced. recordedDistanceKm is untouched — a driver's own record does not silently rewrite itself, it is simply not something a model may train on. And the odometer reads again. PERF_ODOMETER is declared and never published on this car, so total mileage showed unavailable while the head unit knew it; the navigation adapter answers, and that reading is what gave the speed error its independent reference in the first place. Refs: CP-003, CP-012 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 22304be commit eddcf0f

6 files changed

Lines changed: 170 additions & 0 deletions

File tree

‎CHANGELOG.md‎

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

99
### Added
1010

11+
- **A recorded distance says which speed conversion produced it.** `VehicleSpeedEvidence` is the
12+
counterpart of `BatteryPowerEvidence` on the distance side, and it exists because the
13+
conversion has already been wrong once: every trip stored before the km/h fix carries a
14+
distance 3.6 times too long, and nothing in the number said so — 8.46 km for a 2.4 km route
15+
reads exactly like 8.46 km for an 8.46 km route. `EnergyTripSummary.modellableDistanceKm`
16+
answers only for a distance produced by the conversion believed correct today, so a model
17+
refuses history it cannot vouch for instead of quietly training on it. The reported distance
18+
is unchanged: a driver's own record does not silently rewrite itself.
19+
20+
- **The odometer reads again.** `PERF_ODOMETER` is declared and never published on the MG4, so
21+
total mileage showed as unavailable while the head unit knew it. The navigation adapter
22+
answers it, and `getOdometerKm` now falls back to it — the same reading that gave the 3.6x
23+
speed error its independent reference.
24+
1125
- **Guidance can be read, not only waited for.** The five listener callbacks are change
1226
notifications, so a car standing still with a guidance already running publishes nothing —
1327
which made a parked capture unable to tell "the adapter says nothing" from "nothing happened

‎lib/src/main/java/com/evsuite/hardware/EVHardware.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,9 +447,21 @@ object EVHardware {
447447
?.div(1_000f)
448448
}
449449

450+
/**
451+
* Total mileage in km, from whichever service answers.
452+
*
453+
* `PERF_ODOMETER` is declared and never published on the MG4 — every read on
454+
* SWI68-29958-1300R67 came back with no `CarPropertyValue` — so the odometer read as
455+
* unavailable while the head unit knew it perfectly well. The navigation adapter answers
456+
* it, which is how the 3.6x speed error got an independent reference at all.
457+
*
458+
* The adapter needs [com.evsuite.hardware.saic.SaicNav.connect] to have been called; until
459+
* then it simply answers nothing, like any other unbound service.
460+
*/
450461
fun getOdometerKm(): Float? = supportedTelemetryRead {
451462
getFloatPropertyCPM(PROP_ODOMETER, AREA_GLOBAL)
452463
?.takeIf { it.isFinite() && it >= 0f }
464+
?: com.evsuite.hardware.saic.SaicNav.totalMileageKm()?.toFloat()
453465
}
454466

455467
fun getCabinTemperatureCelsius(): Float? = supportedTelemetryRead {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.evsuite.hardware
2+
3+
/**
4+
* Which speed conversion produced a recorded distance.
5+
*
6+
* A trip distance is the integral of speed, so it inherits whatever the speed conversion was
7+
* at the time — and that conversion has already been wrong once. Every trip recorded before
8+
* [KMH_DIRECT_V2] on a generation that reports km/h carries a distance 3.6 times too long, and
9+
* nothing in the stored number says so: 8.46 km for a 2.4 km route reads exactly like 8.46 km
10+
* for an 8.46 km route.
11+
*
12+
* [BatteryPowerEvidence] exists for the same reason on the energy side. This is its
13+
* counterpart for distance, so a model can refuse a trip whose distance it cannot vouch for
14+
* instead of quietly training on it.
15+
*/
16+
data class VehicleSpeedEvidence(
17+
val firmware: FirmwareInfo.Gen,
18+
val conversionVersion: Int,
19+
) {
20+
init {
21+
require(firmware != FirmwareInfo.Gen.UNKNOWN) { "speed evidence names a known firmware" }
22+
require(conversionVersion > 0) { "speed conversion version is positive" }
23+
}
24+
25+
/** True when this distance was produced by the conversion currently believed correct. */
26+
fun matchesCurrent(): Boolean =
27+
conversionVersion == CURRENT && firmware == FirmwareInfo.getGeneration()
28+
29+
companion object {
30+
/** `PERF_VEHICLE_SPEED` read as m/s and multiplied by 3.6, as AAOS specifies. */
31+
const val MPS_TIMES_3_6_V1 = 1
32+
33+
/** The property read as the km/h it already carries, per generation evidence. */
34+
const val KMH_DIRECT_V2 = 2
35+
36+
/** What a distance recorded now is produced by. */
37+
const val CURRENT = KMH_DIRECT_V2
38+
39+
/**
40+
* The evidence for a distance recorded right now, or null on an unknown firmware —
41+
* where no claim about the conversion can honestly be made.
42+
*/
43+
fun current(): VehicleSpeedEvidence? =
44+
FirmwareInfo.getGeneration()
45+
.takeIf { it != FirmwareInfo.Gen.UNKNOWN }
46+
?.let { VehicleSpeedEvidence(it, CURRENT) }
47+
}
48+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.evsuite.hardware.telemetry
22

33
import com.evsuite.hardware.BatteryPowerEvidence
44
import com.evsuite.hardware.CarPropertyEvidence
5+
import com.evsuite.hardware.VehicleSpeedEvidence
56

67
data class EnergyTripSummary(
78
val startedAtMs: Long,
@@ -25,7 +26,22 @@ data class EnergyTripSummary(
2526
val distanceAvailable: Boolean? = null,
2627
/** Null for legacy or unvalidated records; models must not reuse their energy totals. */
2728
val batteryPowerEvidence: BatteryPowerEvidence? = null,
29+
/**
30+
* Which speed conversion produced [distanceKm]. Null on records written before the
31+
* conversion was versioned — their distance may carry the 3.6x error and no model may
32+
* train on it. See [VehicleSpeedEvidence].
33+
*/
34+
val speedEvidence: VehicleSpeedEvidence? = null,
2835
) {
36+
/**
37+
* The distance, only when the conversion behind it is the one believed correct today.
38+
*
39+
* `recordedDistanceKm` stays what the trip reported, because history is what it is and a
40+
* driver's own record should not silently change. This is the one a model may use.
41+
*/
42+
val modellableDistanceKm: Double?
43+
get() = recordedDistanceKm?.takeIf { speedEvidence?.matchesCurrent() == true }
44+
2945
val recordedDistanceKm: Double?
3046
get() = distanceKm.takeIf { distanceAvailable != false }
3147

@@ -47,6 +63,7 @@ class EnergyTripAccumulator(
4763
private val startSocPercent: Float?,
4864
private val track: TripSampleTrack = TripSampleTrack(),
4965
private val batteryPowerEvidence: BatteryPowerEvidence? = null,
66+
private val speedEvidence: VehicleSpeedEvidence? = VehicleSpeedEvidence.current(),
5067
) {
5168
private var last: EnergySnapshot? = null
5269
private var distanceKm = 0.0
@@ -92,6 +109,7 @@ class EnergyTripAccumulator(
92109
consumedKwh = consumedKwh.takeIf { hasPowerInterval },
93110
regeneratedKwh = regeneratedKwh.takeIf { hasPowerInterval },
94111
distanceAvailable = hasSpeedInterval,
112+
speedEvidence = speedEvidence.takeIf { hasSpeedInterval },
95113
batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval },
96114
)
97115

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.evsuite.hardware
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertThrows
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Test
9+
10+
class VehicleSpeedEvidenceTest {
11+
12+
@Test fun `the two conversions are distinct versions`() {
13+
assertEquals(1, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
14+
assertEquals(2, VehicleSpeedEvidence.KMH_DIRECT_V2)
15+
assertEquals(VehicleSpeedEvidence.KMH_DIRECT_V2, VehicleSpeedEvidence.CURRENT)
16+
}
17+
18+
@Test fun `a distance from the old conversion never matches the current one`() {
19+
val old = VehicleSpeedEvidence(FirmwareInfo.Gen.SWI68, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
20+
assertFalse(old.matchesCurrent())
21+
}
22+
23+
@Test fun `evidence naming an unknown firmware is refused`() {
24+
assertThrows(IllegalArgumentException::class.java) {
25+
VehicleSpeedEvidence(FirmwareInfo.Gen.UNKNOWN, VehicleSpeedEvidence.CURRENT)
26+
}
27+
}
28+
29+
@Test fun `a conversion version must be positive`() {
30+
assertThrows(IllegalArgumentException::class.java) {
31+
VehicleSpeedEvidence(FirmwareInfo.Gen.SWI68, 0)
32+
}
33+
}
34+
35+
@Test fun `an unknown firmware makes no claim rather than a wrong one`() {
36+
// No vehicle in a JVM test, so the generation is UNKNOWN and current() must stay silent.
37+
assertNull(VehicleSpeedEvidence.current())
38+
}
39+
40+
@Test fun `evidence for a different firmware does not match this one`() {
41+
val other = VehicleSpeedEvidence(FirmwareInfo.Gen.SWI69, VehicleSpeedEvidence.CURRENT)
42+
assertFalse(other.matchesCurrent())
43+
assertTrue(other.conversionVersion == VehicleSpeedEvidence.CURRENT)
44+
}
45+
}

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,36 @@ class EnergyTelemetryTest {
144144
override fun evidenceProbes() = probes
145145
}
146146
}
147+
148+
class TripDistanceEvidenceTest {
149+
150+
private fun summary(evidence: com.evsuite.hardware.VehicleSpeedEvidence?) = EnergyTripSummary(
151+
startedAtMs = 0L,
152+
endedAtMs = 1_000L,
153+
durationMs = 1_000L,
154+
distanceKm = 8.46,
155+
startSocPercent = 60f,
156+
endSocPercent = 59f,
157+
consumedKwh = null,
158+
regeneratedKwh = null,
159+
distanceAvailable = true,
160+
speedEvidence = evidence,
161+
)
162+
163+
@Test fun `a legacy distance stays reported but is not modellable`() {
164+
val trip = summary(evidence = null)
165+
assertEquals(8.46, trip.recordedDistanceKm!!, 1e-9)
166+
assertNull("a model must not train on an unversioned distance", trip.modellableDistanceKm)
167+
}
168+
169+
@Test fun `a distance from the 3_6x conversion is not modellable`() {
170+
val trip = summary(
171+
com.evsuite.hardware.VehicleSpeedEvidence(
172+
com.evsuite.hardware.FirmwareInfo.Gen.SWI68,
173+
com.evsuite.hardware.VehicleSpeedEvidence.MPS_TIMES_3_6_V1,
174+
)
175+
)
176+
assertEquals(8.46, trip.recordedDistanceKm!!, 1e-9)
177+
assertNull(trip.modellableDistanceKm)
178+
}
179+
}

0 commit comments

Comments
 (0)