Skip to content

Commit 43327cd

Browse files
malysclaude
andcommitted
feat(telemetry): take the pack capacity from the driver, as an estimate
INFO_EV_BATTERY_CAPACITY is declared and never published on the MG4, and deriving the pack size from an energy total needs battery power, which is still unvalidated. Without a capacity there is no bridge between kWh and state of charge, and every energy model stops at kWh/100 km. BatteryCapacityConfig takes the figure from whoever knows it — a dealer, a specification — and labels it for what it is. Nothing built on it can be drawn like a vehicle reading: it is ESTIMATED with a 5 % band covering a state-of-health reading nobody took today and a usable capacity that is a specification rather than a measurement of this pack. It asks for the usable capacity, not the nameplate one. State of charge spans the usable window and the buffers at each end sit outside it, so entering a 64 kWh nameplate where 61.7 kWh is usable overstates a full pack by 2.2 kWh — in the optimistic direction, which is the direction that strands people. No conversion between the two is offered, because that relationship is not published either. Out-of-range figures are refused rather than clamped: a clamped capacity is a number the driver did not enter, standing in silently for the one they did. Refs: CP-041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent eddcf0f commit 43327cd

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

‎CHANGELOG.md‎

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

99
### Added
1010

11+
- **A declared battery capacity bridges kWh and state of charge.** `INFO_EV_BATTERY_CAPACITY` is
12+
declared and never published on the MG4, and deriving the pack size from an energy total needs
13+
battery power, which is unvalidated — so nothing could turn a model's kWh/100 km into a state
14+
of charge. `BatteryCapacityConfig` takes the figure from whoever knows it. It asks for the
15+
**usable** capacity, not the nameplate one, because state of charge spans the usable window
16+
and the buffers at each end sit outside it: entering 64 kWh where 61.7 is usable overstates a
17+
full pack by 2.2 kWh, in the optimistic direction. Everything derived from it is `ESTIMATED`
18+
with a 5 % band covering a dealer's state-of-health reading and a specification figure that
19+
was never a measurement of this pack. Out-of-range input is refused, never clamped.
20+
1121
- **A recorded distance says which speed conversion produced it.** `VehicleSpeedEvidence` is the
1222
counterpart of `BatteryPowerEvidence` on the distance side, and it exists because the
1323
conversion has already been wrong once: every trip stored before the km/h fix carries a
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
/**
4+
* The pack size, as the driver declares it, because the vehicle will not say.
5+
*
6+
* `INFO_EV_BATTERY_CAPACITY` is declared and never published on the MG4, and deriving the
7+
* capacity from an energy total needs battery power, which is itself unvalidated. Without a
8+
* capacity there is no bridge between kWh and state of charge, and every energy model stops
9+
* at kWh/100 km. A declared figure is the only one available, so it is taken — and labelled
10+
* for exactly what it is.
11+
*
12+
* **Usable, not nameplate.** State of charge spans the usable window, not the gross pack: the
13+
* buffers at each end are outside it. An MG4 sold as a 64 kWh car does not put 64 kWh between
14+
* 0 % and 100 %, and using the nameplate figure would overstate every arrival forecast by the
15+
* size of those buffers, in the optimistic direction. The field asks for the usable figure and
16+
* nothing here converts one into the other, because the relationship is not published either.
17+
*
18+
* **Everything derived from this is [Provenance.ESTIMATED].** A dealer's state-of-health
19+
* reading is itself an estimate, the usable figure is a specification rather than a
20+
* measurement of this pack, and neither was taken today. [RELATIVE_UNCERTAINTY] carries that,
21+
* so a value built on a declared capacity can never be drawn like a vehicle reading.
22+
*/
23+
data class BatteryCapacityConfig(
24+
/** kWh between 0 % and 100 % state of charge on a healthy pack — the usable figure. */
25+
val usableCapacityKwhWhenNew: Double,
26+
/** State of health in percent, as measured by whoever measured it. */
27+
val stateOfHealthPercent: Double,
28+
) {
29+
/** What 100 % state of charge is worth on this pack today. */
30+
val effectiveUsableKwh: Double
31+
get() = usableCapacityKwhWhenNew * stateOfHealthPercent / 100.0
32+
33+
/**
34+
* The energy behind a state of charge.
35+
*
36+
* @param socPercent the vehicle's own reading, 0..100.
37+
*/
38+
fun energyAtSocKwh(socPercent: Double?): Provenanced<Double> {
39+
if (socPercent == null || !socPercent.isFinite() || socPercent !in 0.0..100.0) {
40+
return Provenanced.unavailable(UnavailableReason.SIGNAL_ABSENT)
41+
}
42+
val kwh = effectiveUsableKwh * socPercent / 100.0
43+
return Provenanced.estimated(kwh, uncertainty = kwh * RELATIVE_UNCERTAINTY)
44+
}
45+
46+
/** How many state-of-charge points an amount of energy is worth on this pack. */
47+
fun socPercentForEnergy(kwh: Double?): Provenanced<Double> {
48+
if (kwh == null || !kwh.isFinite() || effectiveUsableKwh <= 0.0) {
49+
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
50+
}
51+
val soc = kwh * 100.0 / effectiveUsableKwh
52+
return Provenanced.estimated(soc, uncertainty = kotlin.math.abs(soc) * RELATIVE_UNCERTAINTY)
53+
}
54+
55+
companion object {
56+
/**
57+
* How wrong a declared capacity may reasonably be.
58+
*
59+
* It covers a dealer's state-of-health figure, a usable capacity taken from a
60+
* specification rather than from this pack, and the drift since either was taken. It
61+
* is deliberately not smaller: a narrow band on a number nobody measured today would
62+
* be a false precision, and arrival forecasts are where that gets someone stranded.
63+
*/
64+
const val RELATIVE_UNCERTAINTY = 0.05
65+
66+
private val CAPACITY_RANGE = 10.0..250.0
67+
private val HEALTH_RANGE = 50.0..100.0
68+
69+
/**
70+
* A configuration, or null when the figures are not usable.
71+
*
72+
* Out-of-range input is refused rather than clamped: a clamped capacity is a number
73+
* the driver did not enter, silently standing in for the one they did.
74+
*/
75+
fun of(usableCapacityKwhWhenNew: Double?, stateOfHealthPercent: Double?):
76+
BatteryCapacityConfig? {
77+
if (usableCapacityKwhWhenNew == null || stateOfHealthPercent == null) return null
78+
if (!usableCapacityKwhWhenNew.isFinite() || !stateOfHealthPercent.isFinite()) return null
79+
if (usableCapacityKwhWhenNew !in CAPACITY_RANGE) return null
80+
if (stateOfHealthPercent !in HEALTH_RANGE) return null
81+
return BatteryCapacityConfig(usableCapacityKwhWhenNew, stateOfHealthPercent)
82+
}
83+
}
84+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNotNull
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
9+
class BatteryCapacityConfigTest {
10+
11+
/** The reported MG4: 64 kWh nameplate, about 61.7 kWh usable, 95 % state of health. */
12+
private val mg4 = BatteryCapacityConfig.of(61.7, 95.0)!!
13+
14+
@Test fun `state of health scales the usable pack`() {
15+
assertEquals(58.615, mg4.effectiveUsableKwh, 1e-6)
16+
}
17+
18+
@Test fun `energy at a state of charge is an estimate with a band`() {
19+
val energy = mg4.energyAtSocKwh(56.1)
20+
assertEquals(Provenance.ESTIMATED, energy.provenance)
21+
assertEquals(32.883, energy.value!!, 1e-3)
22+
assertNotNull("a declared capacity is never drawn as a reading", energy.uncertainty)
23+
assertEquals(energy.value!! * 0.05, energy.uncertainty!!, 1e-6)
24+
}
25+
26+
@Test fun `a full pack is the effective capacity and an empty one is zero`() {
27+
assertEquals(58.615, mg4.energyAtSocKwh(100.0).value!!, 1e-6)
28+
assertEquals(0.0, mg4.energyAtSocKwh(0.0).value!!, 1e-9)
29+
}
30+
31+
@Test fun `an absent or impossible state of charge is unavailable, not zero`() {
32+
listOf(null, -1.0, 101.0, Double.NaN).forEach {
33+
val energy = mg4.energyAtSocKwh(it)
34+
assertEquals("soc=$it", Provenance.UNAVAILABLE, energy.provenance)
35+
assertNull(energy.value)
36+
}
37+
}
38+
39+
@Test fun `energy converts back to state of charge with the same relative band`() {
40+
val soc = mg4.socPercentForEnergy(58.615)
41+
assertEquals(100.0, soc.value!!, 1e-6)
42+
assertEquals(5.0, soc.uncertainty!!, 1e-6)
43+
}
44+
45+
@Test fun `figures outside the plausible range are refused rather than clamped`() {
46+
assertNull(BatteryCapacityConfig.of(0.0, 95.0))
47+
assertNull(BatteryCapacityConfig.of(500.0, 95.0))
48+
assertNull(BatteryCapacityConfig.of(61.7, 0.0))
49+
assertNull(BatteryCapacityConfig.of(61.7, 140.0))
50+
assertNull(BatteryCapacityConfig.of(null, 95.0))
51+
assertNull(BatteryCapacityConfig.of(61.7, null))
52+
}
53+
54+
@Test fun `the nameplate figure would overstate a full pack`() {
55+
// Entering 64 kWh instead of the usable 61.7 inflates every forecast in the optimistic
56+
// direction — which is the mistake this field's wording exists to prevent.
57+
val nameplate = BatteryCapacityConfig.of(64.0, 95.0)!!
58+
assertTrue(nameplate.effectiveUsableKwh > mg4.effectiveUsableKwh)
59+
assertEquals(2.185, nameplate.effectiveUsableKwh - mg4.effectiveUsableKwh, 1e-3)
60+
}
61+
}

0 commit comments

Comments
 (0)