Skip to content

Commit b0226a7

Browse files
committed
feat(telemetry): estimate adaptive range
1 parent 30ccf89 commit b0226a7

3 files changed

Lines changed: 224 additions & 0 deletions

File tree

‎CHANGELOG.md‎

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

2121
### Added
2222

23+
- **Adaptive range is now an explicit estimate with evidence-sized uncertainty.**
24+
`AdaptiveRangeEstimator` fits up to eight recent trip-consumption averages, requires three
25+
usable observations, and converts their sample spread into a kilometre uncertainty band.
26+
It prefers measured usable energy, falls back to SOC times capacity, and refuses to estimate
27+
when the current firmware does not publish battery power; the vehicle's own range is untouched.
28+
2329
- **Consumption is now one shared, nullable derivation instead of UI arithmetic.**
2430
`ConsumptionCalculator` exposes raw and five-second exponentially smoothed instantaneous
2531
kWh/100 km plus a two-minute energy/distance average. It masks missing inputs and speeds
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import kotlin.math.sqrt
4+
5+
/**
6+
* A deliberately small range model fitted only from the driver's recent completed averages.
7+
*
8+
* Each trip contributes one observation so a long motorway drive does not silently erase every
9+
* other driving context. The newest observations win, the current trip is considered newest,
10+
* and the fixed-size window keeps both memory and model history explicit.
11+
*/
12+
class AdaptiveRangeEstimator(
13+
private val maxConsumptionSamples: Int = MAX_CONSUMPTION_SAMPLES,
14+
private val minimumConsumptionSamples: Int = MINIMUM_CONSUMPTION_SAMPLES,
15+
) {
16+
init {
17+
require(minimumConsumptionSamples >= 2) { "at least two samples are needed for spread" }
18+
require(maxConsumptionSamples >= minimumConsumptionSamples) {
19+
"the range window must hold the minimum sample count"
20+
}
21+
}
22+
23+
/**
24+
* Estimates remaining range without replacing [EnergySnapshot.rangeKm].
25+
*
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.
28+
*/
29+
fun estimate(
30+
snapshot: EnergySnapshot,
31+
currentTrip: EnergyTripSummary?,
32+
recentTrips: List<EnergyTripSummary>,
33+
missingReason: UnavailableReason = UnavailableReason.SIGNAL_ABSENT,
34+
): Provenanced<Double> {
35+
if (snapshot.batteryPowerKw?.isFinite() != true) {
36+
return Provenanced.unavailable(missingReason)
37+
}
38+
39+
val samples = consumptionSamples(currentTrip, recentTrips)
40+
if (samples.size < minimumConsumptionSamples) {
41+
return Provenanced.unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
42+
}
43+
44+
val usableEnergyKwh = usableEnergy(snapshot)
45+
?: return Provenanced.unavailable(missingReason)
46+
val meanConsumption = samples.average()
47+
val estimatedKm = usableEnergyKwh * 100.0 / meanConsumption
48+
if (!estimatedKm.isFinite()) return Provenanced.unavailable(missingReason)
49+
50+
// Sample standard deviation describes the consumption observations we actually saw.
51+
// First-order propagation through range = energy * 100 / consumption converts that
52+
// spread to the symmetric kilometre half-band Provenanced exposes.
53+
val variance = samples.sumOf { sample ->
54+
val delta = sample - meanConsumption
55+
delta * delta
56+
} / (samples.size - 1).toDouble()
57+
val consumptionSpread = sqrt(variance)
58+
val rangeUncertaintyKm = estimatedKm * consumptionSpread / meanConsumption
59+
if (!rangeUncertaintyKm.isFinite()) return Provenanced.unavailable(missingReason)
60+
return Provenanced.estimated(estimatedKm, rangeUncertaintyKm)
61+
}
62+
63+
private fun consumptionSamples(
64+
currentTrip: EnergyTripSummary?,
65+
recentTrips: List<EnergyTripSummary>,
66+
): List<Double> {
67+
val current = currentTrip?.averageConsumptionKwhPer100Km
68+
?.takeIf(::isUsableConsumption)
69+
val history = recentTrips.asSequence()
70+
.sortedByDescending { it.endedAtMs }
71+
.mapNotNull { it.averageConsumptionKwhPer100Km }
72+
.filter(::isUsableConsumption)
73+
return sequenceOf(current).filterNotNull()
74+
.plus(history)
75+
.take(maxConsumptionSamples)
76+
.toList()
77+
}
78+
79+
private fun usableEnergy(snapshot: EnergySnapshot): Double? {
80+
snapshot.batteryEnergyKwh?.toDouble()
81+
?.takeIf { it.isFinite() && it >= 0.0 }
82+
?.let { return it }
83+
val soc = snapshot.socPercent?.toDouble()
84+
?.takeIf { it.isFinite() && it in 0.0..100.0 } ?: return null
85+
val capacity = snapshot.batteryCapacityKwh?.toDouble()
86+
?.takeIf { it.isFinite() && it > 0.0 } ?: return null
87+
return capacity * soc / 100.0
88+
}
89+
90+
private fun isUsableConsumption(value: Double): Boolean = value.isFinite() && value > 0.0
91+
92+
companion object {
93+
/** Current trip plus the seven most recent valid stored-trip averages. */
94+
const val MAX_CONSUMPTION_SAMPLES = 8
95+
96+
/** Three observations permit a centre and a non-degenerate observed spread. */
97+
const val MINIMUM_CONSUMPTION_SAMPLES = 3
98+
}
99+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import com.evsuite.hardware.FirmwareInfo
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
9+
class AdaptiveRangeEstimatorTest {
10+
private val estimator = AdaptiveRangeEstimator()
11+
12+
@Test fun `cold start is unavailable because consumption history is insufficient`() {
13+
val estimate = estimator.estimate(snapshot(energyKwh = 40f), null, emptyList())
14+
15+
assertNull(estimate.value)
16+
assertEquals(Provenance.UNAVAILABLE, estimate.provenance)
17+
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, estimate.reason)
18+
}
19+
20+
@Test fun `steady consumption matches the analytic range`() {
21+
val estimate = estimator.estimate(
22+
snapshot(energyKwh = 40f),
23+
trip(20.0, endedAtMs = 3L),
24+
listOf(trip(20.0, 2L), trip(20.0, 1L)),
25+
)
26+
27+
assertEquals(Provenance.ESTIMATED, estimate.provenance)
28+
assertEquals(200.0, estimate.value!!, 1e-9)
29+
assertEquals(0.0, estimate.uncertainty!!, 1e-9)
30+
}
31+
32+
@Test fun `volatile consumption widens the band without moving the estimate`() {
33+
val steady = estimator.estimate(
34+
snapshot(energyKwh = 40f), null,
35+
listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)),
36+
)
37+
val volatile = estimator.estimate(
38+
snapshot(energyKwh = 40f), null,
39+
listOf(trip(10.0, 3L), trip(20.0, 2L), trip(30.0, 1L)),
40+
)
41+
42+
assertEquals(steady.value!!, volatile.value!!, 1e-9)
43+
assertTrue(volatile.uncertainty!! > steady.uncertainty!!)
44+
assertEquals(100.0, volatile.uncertainty!!, 1e-9)
45+
}
46+
47+
@Test fun `soc and capacity provide usable energy when direct energy is absent`() {
48+
val estimate = estimator.estimate(
49+
snapshot(energyKwh = null, soc = 50f, capacityKwh = 64f), null,
50+
listOf(trip(16.0, 3L), trip(16.0, 2L), trip(16.0, 1L)),
51+
)
52+
53+
assertEquals(200.0, estimate.value!!, 1e-9)
54+
}
55+
56+
@Test fun `firmware without a current battery power signal never estimates`() {
57+
val estimate = estimator.estimate(
58+
snapshot(energyKwh = 40f, powerKw = null), null,
59+
listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)),
60+
UnavailableReason.UNSUPPORTED_FIRMWARE,
61+
)
62+
63+
assertNull(estimate.value)
64+
assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, estimate.reason)
65+
}
66+
67+
@Test fun `only the bounded newest history window affects the fit`() {
68+
val estimate = AdaptiveRangeEstimator(maxConsumptionSamples = 3).estimate(
69+
snapshot(energyKwh = 40f), null,
70+
listOf(
71+
trip(20.0, 30L), trip(20.0, 20L), trip(20.0, 10L),
72+
trip(100.0, 1L),
73+
),
74+
)
75+
76+
assertEquals(200.0, estimate.value!!, 1e-9)
77+
assertEquals(0.0, estimate.uncertainty!!, 1e-9)
78+
}
79+
80+
private fun trip(consumption: Double, endedAtMs: Long): EnergyTripSummary {
81+
val distanceKm = 10.0
82+
return EnergyTripSummary(
83+
startedAtMs = 0L,
84+
endedAtMs = endedAtMs,
85+
durationMs = 1_000L,
86+
distanceKm = distanceKm,
87+
startSocPercent = null,
88+
endSocPercent = null,
89+
consumedKwh = consumption * distanceKm / 100.0,
90+
regeneratedKwh = 0.0,
91+
distanceAvailable = true,
92+
)
93+
}
94+
95+
private fun snapshot(
96+
energyKwh: Float?,
97+
soc: Float? = null,
98+
capacityKwh: Float? = null,
99+
powerKw: Float? = 10f,
100+
) = EnergySnapshot(
101+
timestampMs = 0L,
102+
firmware = FirmwareInfo.Gen.SWI68,
103+
socPercent = soc,
104+
rangeKm = null,
105+
speedKmh = 50f,
106+
batteryPowerKw = powerKw,
107+
outsideTempCelsius = null,
108+
cabinTempCelsius = null,
109+
batteryTempCelsius = null,
110+
batteryEnergyKwh = energyKwh,
111+
batteryCapacityKwh = capacityKwh,
112+
odometerKm = null,
113+
chargePortConnected = null,
114+
chargingStatus = null,
115+
parked = null,
116+
climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null),
117+
tirePressures = TirePressureSnapshot(null, null, null, null),
118+
)
119+
}

0 commit comments

Comments
 (0)