Skip to content

Commit 4127ba2

Browse files
malysclaude
andcommitted
feat(telemetry): forecast the arrival state of charge
CP-041 was written to predict arrival SOC through the CP-030 energy model. That is a dead end on this vehicle: the model trains on energy integrated from battery power, and EV_INSTANTANEOUS_CHARGE_RATE publishes nothing. The goal was never the model, though — it was a number at the destination. State of charge per kilometre needs no energy unit, and both of its inputs read on this car. SocRateEstimator takes that rate from the vehicle's own range on the very first reading, and from recorded trips once three exist, preferring what the driver has actually driven. A trip whose distance the current speed conversion did not produce is never averaged in: under the old conversion it would make the car look three and a half times more efficient than it is. ArrivalSocForecast never returns a bare number. The band widens with distance because the rate it rests on is uncertain; a band wider than fifteen points is refused rather than shown, since a figure spanning half the pack tells a driver nothing while looking like it does. A negative arrival is not clamped — "the destination is beyond this charge" is the most useful thing this can say. matchesCurrent and modellableDistanceKm now take the firmware generation instead of reading it from a global. A correctness gate that only runs on a car is a gate nobody has tested, and this one now has tests. Refs: CP-041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0580689 commit 4127ba2

8 files changed

Lines changed: 381 additions & 10 deletions

File tree

‎CHANGELOG.md‎

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

99
### Added
1010

11+
- **An arrival state of charge, built on what this car actually publishes.** The obvious route
12+
runs through the energy model — kWh/100 km, a distance, a pack capacity — and it is a dead end
13+
here, because the model trains on energy integrated from a battery power that never publishes.
14+
State of charge per kilometre needs no energy unit at all, and both its inputs read.
15+
`SocRateEstimator` takes the rate from the vehicle's own range on the first reading and from
16+
recorded trips once there are enough, preferring what the driver has actually driven; trips
17+
whose distance the current speed conversion did not produce are never averaged in.
18+
`ArrivalSocForecast` never returns a bare number: the band widens with distance, a band wider
19+
than 15 points is refused rather than shown, and a negative arrival is left negative because
20+
"the destination is beyond this charge" is the most useful thing it can say.
21+
1122
- **A declared battery capacity bridges kWh and state of charge.** `INFO_EV_BATTERY_CAPACITY` is
1223
declared and never published on the MG4, and deriving the pack size from an energy total needs
1324
battery power, which is unvalidated — so nothing could turn a model's kWh/100 km into a state

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,15 @@ data class VehicleSpeedEvidence(
2222
require(conversionVersion > 0) { "speed conversion version is positive" }
2323
}
2424

25-
/** True when this distance was produced by the conversion currently believed correct. */
26-
fun matchesCurrent(): Boolean =
27-
conversionVersion == CURRENT && firmware == FirmwareInfo.getGeneration()
25+
/**
26+
* True when this distance was produced by the conversion currently believed correct.
27+
*
28+
* The generation is a parameter rather than a read of [FirmwareInfo.getGeneration] so the
29+
* check can be exercised off a vehicle. A correctness gate that only runs on a car is a
30+
* gate nobody has tested.
31+
*/
32+
fun matchesCurrent(generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration()): Boolean =
33+
conversionVersion == CURRENT && firmware == generation
2834

2935
companion object {
3036
/** `PERF_VEHICLE_SPEED` read as m/s and multiplied by 3.6, as AAOS specifies. */
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
/**
4+
* The state of charge on arrival, and how wrong it might be.
5+
*
6+
* An arrival-SOC point estimate is how drivers get stranded, so this never returns one: the
7+
* value always carries a band, and the band always widens with distance because the rate it
8+
* rests on is itself uncertain. Where the band grows wider than [MAX_BAND_PERCENT] the
9+
* forecast is refused outright rather than shown wide — a number spanning half the pack tells
10+
* a driver nothing while looking like it does.
11+
*
12+
* A negative arrival figure is deliberately not clamped. It means the destination is beyond
13+
* what the charge covers, which is the single most useful thing this can say.
14+
*/
15+
object ArrivalSocForecast {
16+
17+
/**
18+
* Widest band worth showing, in points of charge.
19+
*
20+
* Past this the honest answer is "not enough evidence", and saying so is more use than a
21+
* figure the driver has to discount themselves.
22+
*/
23+
const val MAX_BAND_PERCENT = 15.0
24+
25+
/**
26+
* @param socPercent the vehicle's current state of charge, 0..100.
27+
* @param remainingDistanceKm distance still to cover, from the head unit's navigation.
28+
* @param rate what a kilometre costs, from [SocRateEstimator].
29+
*/
30+
fun of(
31+
socPercent: Double?,
32+
remainingDistanceKm: Double?,
33+
rate: SocRate?,
34+
): Provenanced<Double> {
35+
if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) {
36+
return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT)
37+
}
38+
if (remainingDistanceKm == null || !remainingDistanceKm.isFinite() ||
39+
remainingDistanceKm < 0.0
40+
) {
41+
return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT)
42+
}
43+
if (rate == null) {
44+
return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED)
45+
}
46+
val arrival = socPercent - rate.percentPerKm * remainingDistanceKm
47+
// The band comes from the rate alone: the distance is the head unit's own figure and
48+
// the current charge is a vehicle reading, so neither contributes an error bar here.
49+
val band = rate.uncertaintyPercentPerKm * remainingDistanceKm
50+
if (!arrival.isFinite() || !band.isFinite()) {
51+
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
52+
}
53+
if (band > MAX_BAND_PERCENT) {
54+
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
55+
}
56+
return Provenanced.estimated(arrival, uncertainty = band)
57+
}
58+
59+
/**
60+
* How far the charge reaches at this rate, for the same inputs.
61+
*
62+
* The complement of [of]: an arrival figure answers "will I get there", this one answers
63+
* "how far does this get me", and a driver deciding whether to stop wants both.
64+
*/
65+
fun rangeAtRateKm(socPercent: Double?, rate: SocRate?): Provenanced<Double> {
66+
if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) {
67+
return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT)
68+
}
69+
if (rate == null || rate.percentPerKm <= 0.0) {
70+
return Provenanced.unavailable(UnavailableReason.MODEL_NOT_TRAINED)
71+
}
72+
val km = socPercent / rate.percentPerKm
73+
// A rate known to ±x% puts the distance between soc/(rate+x) and soc/(rate-x); the
74+
// near edge is the half-width worth quoting, because overstating range is the danger.
75+
val worst = socPercent / (rate.percentPerKm + rate.uncertaintyPercentPerKm)
76+
val band = km - worst
77+
if (!km.isFinite() || !band.isFinite() || band < 0.0) {
78+
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
79+
}
80+
return Provenanced.estimated(km, uncertainty = band)
81+
}
82+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ data class EnergyTripSummary(
3939
* `recordedDistanceKm` stays what the trip reported, because history is what it is and a
4040
* driver's own record should not silently change. This is the one a model may use.
4141
*/
42-
val modellableDistanceKm: Double?
43-
get() = recordedDistanceKm?.takeIf { speedEvidence?.matchesCurrent() == true }
42+
fun modellableDistanceKm(
43+
generation: com.evsuite.hardware.FirmwareInfo.Gen =
44+
com.evsuite.hardware.FirmwareInfo.getGeneration(),
45+
): Double? = recordedDistanceKm?.takeIf { speedEvidence?.matchesCurrent(generation) == true }
4446

4547
val recordedDistanceKm: Double?
4648
get() = distanceKm.takeIf { distanceAvailable != false }
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import kotlin.math.abs
4+
import kotlin.math.sqrt
5+
6+
/**
7+
* How much state of charge a kilometre costs.
8+
*
9+
* The obvious route to an arrival forecast runs through the energy model: kWh/100 km, times a
10+
* distance, divided by a pack capacity. On this vehicle it is a dead end — the model trains on
11+
* energy integrated from battery power, and `EV_INSTANTANEOUS_CHARGE_RATE` publishes nothing.
12+
*
13+
* State of charge per kilometre needs no energy unit at all. Both of its inputs read on a car
14+
* where battery power does not, which is why the forecast is built on it instead.
15+
*/
16+
data class SocRate(
17+
/** Percentage points of charge per kilometre. Always positive for a car that is driving. */
18+
val percentPerKm: Double,
19+
/** Half-width of the band on [percentPerKm], in the same unit. */
20+
val uncertaintyPercentPerKm: Double,
21+
val source: Source,
22+
/** Trips behind the figure, or 0 when it came from the vehicle's own range. */
23+
val sampleCount: Int = 0,
24+
) {
25+
enum class Source {
26+
/**
27+
* State of charge divided by the vehicle's own remaining range. Available from the
28+
* first reading, and only as good as the car's own estimate.
29+
*/
30+
VEHICLE_RANGE,
31+
32+
/** Measured across recorded trips: what this driver actually spends per kilometre. */
33+
TRIP_HISTORY,
34+
}
35+
}
36+
37+
/** Builds a [SocRate] from whichever evidence exists, preferring what the driver has driven. */
38+
object SocRateEstimator {
39+
40+
/**
41+
* How wrong the vehicle's own range estimate may be.
42+
*
43+
* The car publishes a range and no error bar. It is a model like any other — it reacts to
44+
* recent driving, temperature and its own assumptions — so a fifth is a stated assumption
45+
* rather than a measurement, and it is deliberately wide. Trip history replaces it with a
46+
* band that was actually observed.
47+
*/
48+
const val VEHICLE_RANGE_RELATIVE_UNCERTAINTY = 0.20
49+
50+
/** Below this a trip's own state-of-charge resolution dominates whatever it measured. */
51+
const val MIN_TRIP_DISTANCE_KM = 1.0
52+
53+
/** Fewer than this and the spread across trips is not a band, it is noise. */
54+
const val MIN_TRIPS = 3
55+
56+
/** A floor on the observed spread, so a run of similar trips cannot imply false precision. */
57+
const val MIN_TRIP_RELATIVE_UNCERTAINTY = 0.10
58+
59+
/**
60+
* The rate implied by the vehicle's own range reading.
61+
*
62+
* @param socPercent the vehicle's state of charge, 0..100.
63+
* @param rangeKm the vehicle's own remaining range.
64+
*/
65+
fun fromVehicleRange(socPercent: Double?, rangeKm: Double?): SocRate? {
66+
if (socPercent == null || rangeKm == null) return null
67+
if (!socPercent.isFinite() || !rangeKm.isFinite()) return null
68+
if (socPercent !in 0.0..100.0 || rangeKm <= 0.0) return null
69+
val rate = socPercent / rangeKm
70+
if (!rate.isFinite() || rate <= 0.0) return null
71+
return SocRate(
72+
percentPerKm = rate,
73+
uncertaintyPercentPerKm = rate * VEHICLE_RANGE_RELATIVE_UNCERTAINTY,
74+
source = SocRate.Source.VEHICLE_RANGE,
75+
)
76+
}
77+
78+
/**
79+
* The rate this driver has actually spent, across trips whose distance can be vouched for.
80+
*
81+
* Only trips carrying [EnergyTripSummary.modellableDistanceKm] count: a distance recorded
82+
* under the old speed conversion is 3.6 times too long, and averaging it in would make the
83+
* car look three and a half times more efficient than it is.
84+
*
85+
* Charging stops are excluded by construction — a trip whose charge went up is not a trip
86+
* whose consumption means anything.
87+
*/
88+
fun fromTrips(
89+
trips: List<EnergyTripSummary>,
90+
generation: com.evsuite.hardware.FirmwareInfo.Gen =
91+
com.evsuite.hardware.FirmwareInfo.getGeneration(),
92+
): SocRate? {
93+
val rates = trips.mapNotNull { trip ->
94+
val km = trip.modellableDistanceKm(generation)?.takeIf { it >= MIN_TRIP_DISTANCE_KM }
95+
?: return@mapNotNull null
96+
val start = trip.startSocPercent ?: return@mapNotNull null
97+
val end = trip.endSocPercent ?: return@mapNotNull null
98+
val spent = (start - end).toDouble()
99+
if (spent <= 0.0) return@mapNotNull null
100+
(spent / km).takeIf { it.isFinite() && it > 0.0 }
101+
}
102+
if (rates.size < MIN_TRIPS) return null
103+
val mean = rates.average()
104+
if (!mean.isFinite() || mean <= 0.0) return null
105+
val variance = rates.sumOf { (it - mean) * (it - mean) } / (rates.size - 1)
106+
val observed = sqrt(variance)
107+
return SocRate(
108+
percentPerKm = mean,
109+
uncertaintyPercentPerKm = maxOf(observed, abs(mean) * MIN_TRIP_RELATIVE_UNCERTAINTY),
110+
source = SocRate.Source.TRIP_HISTORY,
111+
sampleCount = rates.size,
112+
)
113+
}
114+
115+
/** Trip history when there is enough of it, the vehicle's own range otherwise. */
116+
fun best(
117+
socPercent: Double?,
118+
rangeKm: Double?,
119+
trips: List<EnergyTripSummary>,
120+
generation: com.evsuite.hardware.FirmwareInfo.Gen =
121+
com.evsuite.hardware.FirmwareInfo.getGeneration(),
122+
): SocRate? = fromTrips(trips, generation) ?: fromVehicleRange(socPercent, rangeKm)
123+
}

‎lib/src/test/java/com/evsuite/hardware/VehicleSpeedEvidenceTest.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class VehicleSpeedEvidenceTest {
1717

1818
@Test fun `a distance from the old conversion never matches the current one`() {
1919
val old = VehicleSpeedEvidence(FirmwareInfo.Gen.SWI68, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
20-
assertFalse(old.matchesCurrent())
20+
assertFalse(old.matchesCurrent(FirmwareInfo.Gen.SWI68))
2121
}
2222

2323
@Test fun `evidence naming an unknown firmware is refused`() {
@@ -39,7 +39,7 @@ class VehicleSpeedEvidenceTest {
3939

4040
@Test fun `evidence for a different firmware does not match this one`() {
4141
val other = VehicleSpeedEvidence(FirmwareInfo.Gen.SWI69, VehicleSpeedEvidence.CURRENT)
42-
assertFalse(other.matchesCurrent())
43-
assertTrue(other.conversionVersion == VehicleSpeedEvidence.CURRENT)
42+
assertFalse(other.matchesCurrent(FirmwareInfo.Gen.SWI68))
43+
assertTrue(other.matchesCurrent(FirmwareInfo.Gen.SWI69))
4444
}
4545
}

0 commit comments

Comments
 (0)