Fatih'sMG4 — ABRP telemetry for an MG4 EH32 on SWI69

A fork of malys/EVABRPUploader (itself a fork of Leon Kernan's ABRP_Uploader)
carrying the changes that make it work on this car. History starts here: the
tree is what matters, and the upstream projects are where the earlier work
belongs.

EVHardware is vendored rather than tracked as a submodule, so one clone
builds.

Telemetry. SOC, battery power and range read as unavailable on SWI69 because
the vendor fallbacks were gated to SWI68 alone, while the property ids they
use are the ones this car publishes — the gates now admit SWI69. Tyre
pressure, the odometer and charge status come from the vendor block too,
since the generic properties for all three are declared here and never
publish. Tyre pressure is confirmed on the vehicle; the other two are not
yet.

Vehicle page. Every signal the car answers, in a table. One that it does not
publish shows a dash: an unimplemented VHAL property returns zero, and
"0 °C" would read as a measurement.

Wi-Fi page. Joins a network the unit already has saved, so a phone hotspot
does not need a trip into Settings on every drive. Tried on the upload tick
and from a button, skipped while already associated. This leans on the unit
being API 28 and the build being platform-signed — getConfiguredNetworks and
enableNetwork are closed to ordinary apps from API 29 — and fails quietly
where that does not hold.

The OTA updater points at this repository. An APK from upstream is signed
with a different key, which Android will not install over a platform-signed
build.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-09-22 10:45:00 +03:00
co-authored by Claude
commit 2fd7b2dde1
245 changed files with 35594 additions and 0 deletions
@@ -0,0 +1,148 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AdaptiveRangeEstimatorTest {
private val estimator = AdaptiveRangeEstimator()
@Test fun `cold start is unavailable because consumption history is insufficient`() {
val estimate = estimator.estimate(snapshot(energyKwh = 40f), null, emptyList())
assertNull(estimate.value)
assertEquals(Provenance.UNAVAILABLE, estimate.provenance)
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, estimate.reason)
}
@Test fun `steady consumption matches the analytic range`() {
val estimate = estimator.estimate(
snapshot(energyKwh = 40f),
trip(20.0, endedAtMs = 3L),
listOf(trip(20.0, 2L), trip(20.0, 1L)),
)
assertEquals(Provenance.ESTIMATED, estimate.provenance)
assertEquals(200.0, estimate.value!!, 1e-9)
// Trips that happened to agree do not make the model certain: the band falls back to
// its floor rather than to zero, which would read as a measurement.
assertEquals(20.0, estimate.uncertainty!!, 1e-9)
}
@Test fun `an estimate never claims a band narrower than the model's floor`() {
val estimate = estimator.estimate(
snapshot(energyKwh = 40f), null,
listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)),
)
assertEquals(
estimate.value!! * AdaptiveRangeEstimator.MIN_RELATIVE_UNCERTAINTY,
estimate.uncertainty!!,
1e-9,
)
assertTrue(estimate.uncertainty!! > 0.0)
}
@Test fun `a momentarily unreadable power signal does not blank a settled estimate`() {
val history = listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L))
val withPower = snapshot(energyKwh = 40f)
val withoutPower = withPower.copy(batteryPowerKw = null)
assertEquals(Provenance.ESTIMATED, estimator.estimate(withPower, null, history).provenance)
// The capability was established a sample ago and the car has not lost it since; the
// estimate depends on stored trips and pack charge, neither of which moved.
val next = estimator.estimate(withoutPower, null, history)
assertEquals(Provenance.ESTIMATED, next.provenance)
assertEquals(200.0, next.value!!, 1e-9)
}
@Test fun `volatile consumption widens the band without moving the estimate`() {
val steady = estimator.estimate(
snapshot(energyKwh = 40f), null,
listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)),
)
val volatile = estimator.estimate(
snapshot(energyKwh = 40f), null,
listOf(trip(10.0, 3L), trip(20.0, 2L), trip(30.0, 1L)),
)
assertEquals(steady.value!!, volatile.value!!, 1e-9)
assertTrue(volatile.uncertainty!! > steady.uncertainty!!)
assertEquals(100.0, volatile.uncertainty!!, 1e-9)
}
@Test fun `soc and capacity provide usable energy when direct energy is absent`() {
val estimate = estimator.estimate(
snapshot(energyKwh = null, soc = 50f, capacityKwh = 64f), null,
listOf(trip(16.0, 3L), trip(16.0, 2L), trip(16.0, 1L)),
)
assertEquals(200.0, estimate.value!!, 1e-9)
}
@Test fun `firmware without a current battery power signal never estimates`() {
val estimate = estimator.estimate(
snapshot(energyKwh = 40f, powerKw = null), null,
listOf(trip(20.0, 3L), trip(20.0, 2L), trip(20.0, 1L)),
UnavailableReason.UNSUPPORTED_FIRMWARE,
)
assertNull(estimate.value)
assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, estimate.reason)
}
@Test fun `only the bounded newest history window affects the fit`() {
val estimate = AdaptiveRangeEstimator(maxConsumptionSamples = 3).estimate(
snapshot(energyKwh = 40f), null,
listOf(
trip(20.0, 30L), trip(20.0, 20L), trip(20.0, 10L),
trip(100.0, 1L),
),
)
assertEquals(200.0, estimate.value!!, 1e-9)
assertEquals(20.0, estimate.uncertainty!!, 1e-9)
}
private fun trip(consumption: Double, endedAtMs: Long): EnergyTripSummary {
val distanceKm = 10.0
return EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = endedAtMs,
durationMs = 1_000L,
distanceKm = distanceKm,
startSocPercent = null,
endSocPercent = null,
consumedKwh = consumption * distanceKm / 100.0,
regeneratedKwh = 0.0,
distanceAvailable = true,
)
}
private fun snapshot(
energyKwh: Float?,
soc: Float? = null,
capacityKwh: Float? = null,
powerKw: Float? = 10f,
) = EnergySnapshot(
timestampMs = 0L,
firmware = FirmwareInfo.Gen.SWI68,
socPercent = soc,
rangeKm = null,
speedKmh = 50f,
batteryPowerKw = powerKw,
outsideTempCelsius = null,
cabinTempCelsius = null,
batteryTempCelsius = null,
batteryEnergyKwh = energyKwh,
batteryCapacityKwh = capacityKwh,
odometerKm = null,
chargePortConnected = null,
chargingStatus = null,
parked = null,
climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null),
tirePressures = TirePressureSnapshot(null, null, null, null),
)
}
@@ -0,0 +1,144 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.VehicleSpeedEvidence
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SocRateEstimatorTest {
/** The 2026-09-04 bundle: 56,1 % of charge and the car's own 278 km of range. */
@Test fun `the vehicle's own range gives a rate from the first reading`() {
val rate = SocRateEstimator.fromVehicleRange(56.1, 278.0)!!
assertEquals(0.2018, rate.percentPerKm, 1e-4)
assertEquals(SocRate.Source.VEHICLE_RANGE, rate.source)
assertEquals(rate.percentPerKm * 0.20, rate.uncertaintyPercentPerKm, 1e-9)
}
@Test fun `an absent or impossible reading yields no rate rather than a zero one`() {
assertNull(SocRateEstimator.fromVehicleRange(null, 278.0))
assertNull(SocRateEstimator.fromVehicleRange(56.1, null))
assertNull(SocRateEstimator.fromVehicleRange(56.1, 0.0))
assertNull(SocRateEstimator.fromVehicleRange(-1.0, 278.0))
assertNull(SocRateEstimator.fromVehicleRange(56.1, Double.NaN))
}
private fun trip(km: Double, spentSoc: Double, vouched: Boolean = true) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = 1_000L,
durationMs = 1_000L,
distanceKm = km,
startSocPercent = 80f,
endSocPercent = (80.0 - spentSoc).toFloat(),
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
speedEvidence = VehicleSpeedEvidence(
GENERATION,
if (vouched) VehicleSpeedEvidence.CURRENT else VehicleSpeedEvidence.MPS_TIMES_3_6_V1,
),
)
private companion object {
/** The generation the vehicle evidence was gathered on. */
val GENERATION = FirmwareInfo.Gen.SWI68
}
@Test fun `fewer than three usable trips is not a band`() {
assertNull(SocRateEstimator.fromTrips(listOf(trip(10.0, 2.0), trip(10.0, 2.1)), GENERATION))
}
@Test fun `a distance the current conversion did not produce is never averaged in`() {
// Three trips, all recorded under the 3.6x conversion: the rate would come out 3.6
// times too optimistic, so no rate is offered at all.
val stale = List(3) { trip(36.0, 2.0, vouched = false) }
assertNull(SocRateEstimator.fromTrips(stale, GENERATION))
}
@Test fun `a trip whose charge rose is not a trip about consumption`() {
val charging = EnergyTripSummary(
startedAtMs = 0L, endedAtMs = 1L, durationMs = 1L, distanceKm = 10.0,
startSocPercent = 40f, endSocPercent = 80f,
consumedKwh = null, regeneratedKwh = null, distanceAvailable = true,
)
assertNull(SocRateEstimator.fromTrips(listOf(charging, charging, charging), GENERATION))
}
@Test fun `trip history is preferred over the vehicle's range when there is enough of it`() {
val trips = List(4) { trip(10.0, 2.0) }
val best = SocRateEstimator.best(56.1, 278.0, trips, GENERATION)!!
assertEquals(SocRate.Source.TRIP_HISTORY, best.source)
assertEquals(0.2, best.percentPerKm, 1e-9)
assertEquals(4, best.sampleCount)
}
@Test fun `identical trips still carry a floor on their band`() {
val best = SocRateEstimator.fromTrips(List(4) { trip(10.0, 2.0) }, GENERATION)!!
assertEquals(0.2 * 0.10, best.uncertaintyPercentPerKm, 1e-9)
}
@Test fun `with too little history the vehicle's own range is used instead`() {
val best = SocRateEstimator.best(56.1, 278.0, listOf(trip(10.0, 2.0)), GENERATION)!!
assertEquals(SocRate.Source.VEHICLE_RANGE, best.source)
}
}
class ArrivalSocForecastTest {
private val vehicleRate = SocRateEstimator.fromVehicleRange(56.1, 278.0)!!
@Test fun `the captured route forecasts an arrival with a band`() {
// The 2026-09-04 guidance capture: 9788 m remaining at 56,1 %.
val arrival = ArrivalSocForecast.of(56.1, 9.788, vehicleRate)
assertEquals(Provenance.ESTIMATED, arrival.provenance)
assertEquals(54.12, arrival.value!!, 0.01)
assertNotNull("an arrival figure is never a bare number", arrival.uncertainty)
}
@Test fun `the band widens with distance`() {
val near = ArrivalSocForecast.of(56.1, 10.0, vehicleRate).uncertainty!!
val far = ArrivalSocForecast.of(56.1, 50.0, vehicleRate).uncertainty!!
assertTrue("a longer route is a less certain one", far > near)
assertEquals(5.0, far / near, 1e-9)
}
@Test fun `a band wider than the ceiling is refused rather than shown`() {
val arrival = ArrivalSocForecast.of(56.1, 500.0, vehicleRate)
assertEquals(Provenance.UNAVAILABLE, arrival.provenance)
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, arrival.reason)
assertNull(arrival.value)
}
@Test fun `a destination beyond the charge reports a negative arrival, not a zero`() {
// 56,1 % at 0,2018 %/km reaches about 278 km; 300 km does not fit.
val trips = SocRate(0.2018, 0.002, SocRate.Source.TRIP_HISTORY, 5)
val arrival = ArrivalSocForecast.of(56.1, 300.0, trips)
assertTrue("running out has to be sayable", arrival.value!! < 0.0)
}
@Test fun `no rate means no forecast, with a reason`() {
val arrival = ArrivalSocForecast.of(56.1, 9.788, null)
assertEquals(Provenance.UNAVAILABLE, arrival.provenance)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, arrival.reason)
}
@Test fun `an absent charge or distance is unavailable`() {
assertEquals(
Provenance.UNAVAILABLE,
ArrivalSocForecast.of(null, 9.788, vehicleRate).provenance,
)
assertEquals(
Provenance.UNAVAILABLE,
ArrivalSocForecast.of(56.1, null, vehicleRate).provenance,
)
}
@Test fun `range at this rate matches the vehicle's own when the rate came from it`() {
val range = ArrivalSocForecast.rangeAtRateKm(56.1, vehicleRate)
assertEquals(278.0, range.value!!, 0.1)
assertTrue("the quoted band never overstates range", range.uncertainty!! > 0.0)
}
}
@@ -0,0 +1,64 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BatteryCapacityConfigTest {
/**
* The owner's MG4 Long Range: 64 kWh gross and 61,7 kWh usable per EVKX's specification
* sheet, at the 95 % state of health the dealer reported.
*/
private val mg4 = BatteryCapacityConfig.of(61.7, 95.0)!!
@Test fun `state of health scales the usable pack`() {
assertEquals(58.615, mg4.effectiveUsableKwh, 1e-6)
}
@Test fun `energy at a state of charge is an estimate with a band`() {
val energy = mg4.energyAtSocKwh(56.1)
assertEquals(Provenance.ESTIMATED, energy.provenance)
assertEquals(32.883, energy.value!!, 1e-3)
assertNotNull("a declared capacity is never drawn as a reading", energy.uncertainty)
assertEquals(energy.value!! * 0.05, energy.uncertainty!!, 1e-6)
}
@Test fun `a full pack is the effective capacity and an empty one is zero`() {
assertEquals(58.615, mg4.energyAtSocKwh(100.0).value!!, 1e-6)
assertEquals(0.0, mg4.energyAtSocKwh(0.0).value!!, 1e-9)
}
@Test fun `an absent or impossible state of charge is unavailable, not zero`() {
listOf(null, -1.0, 101.0, Double.NaN).forEach {
val energy = mg4.energyAtSocKwh(it)
assertEquals("soc=$it", Provenance.UNAVAILABLE, energy.provenance)
assertNull(energy.value)
}
}
@Test fun `energy converts back to state of charge with the same relative band`() {
val soc = mg4.socPercentForEnergy(58.615)
assertEquals(100.0, soc.value!!, 1e-6)
assertEquals(5.0, soc.uncertainty!!, 1e-6)
}
@Test fun `figures outside the plausible range are refused rather than clamped`() {
assertNull(BatteryCapacityConfig.of(0.0, 95.0))
assertNull(BatteryCapacityConfig.of(500.0, 95.0))
assertNull(BatteryCapacityConfig.of(61.7, 0.0))
assertNull(BatteryCapacityConfig.of(61.7, 140.0))
assertNull(BatteryCapacityConfig.of(null, 95.0))
assertNull(BatteryCapacityConfig.of(61.7, null))
}
@Test fun `the nameplate figure would overstate a full pack`() {
// Entering 64 kWh instead of the usable 61.7 inflates every forecast in the optimistic
// direction — which is the mistake this field's wording exists to prevent.
val nameplate = BatteryCapacityConfig.of(64.0, 95.0)!!
assertTrue(nameplate.effectiveUsableKwh > mg4.effectiveUsableKwh)
assertEquals(2.185, nameplate.effectiveUsableKwh - mg4.effectiveUsableKwh, 1e-3)
}
}
@@ -0,0 +1,109 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BatteryExposureTest {
@Test fun `a night ending at eighty five counts every one of its hours as high`() {
val report = BatteryExposure().analyse(
listOf(entry(0L, 90f), entry(10 * HOUR, 85f))
)!!
assertEquals(10.0, report.hoursAboveHighSoc, 1e-6)
assertEquals(0.0, report.hoursBelowLowSoc, 1e-6)
assertEquals(10.0, report.observedHours, 1e-6)
}
@Test fun `a segment that crosses the threshold is split where it crosses`() {
// 90 % to 70 % over ten hours: half of the fall is above 80 %, so half the hours are.
val report = BatteryExposure().analyse(
listOf(entry(0L, 90f), entry(10 * HOUR, 70f))
)!!
assertEquals(5.0, report.hoursAboveHighSoc, 1e-6)
assertEquals(80.0, report.meanSocPercent, 1e-6)
}
@Test fun `the charge spent is counted in equivalent full cycles`() {
val report = BatteryExposure().analyse(
listOf(
entry(0L, 100f, odometerKm = 10_000f),
entry(DAY, 20f, odometerKm = 10_400f),
entry(DAY + 4 * HOUR, 100f, odometerKm = 10_400f),
entry(2 * DAY, 20f, odometerKm = 10_800f),
)
)!!
assertEquals(1.6, report.equivalentFullCycles, 1e-6)
assertEquals(800.0 / 1.6, report.kmPerEquivalentCycle!!, 1e-6)
}
@Test fun `a rise is one session however many entries it wrote`() {
val report = BatteryExposure().analyse(
listOf(
entry(0L, 20f),
entry(HOUR, 45f),
entry(2 * HOUR, 70f),
entry(3 * HOUR, 80f),
entry(DAY, 30f),
)
)!!
val session = report.sessions.single()
assertEquals(20.0, session.startSocPercent, 1e-6)
assertEquals(80.0, session.endSocPercent, 1e-6)
assertEquals(60.0, session.gainedPercent, 1e-6)
assertEquals(20.0, session.meanPercentPerHour, 1e-6)
assertEquals(25.0, session.peakPercentPerHour, 1e-6)
}
@Test fun `the rate is what tells a wall box from a fast charger`() {
val slow = BatteryExposure().analyse(
listOf(entry(0L, 30f), entry(8 * HOUR, 90f), entry(DAY, 40f))
)!!.sessions.single()
val fast = BatteryExposure().analyse(
listOf(entry(0L, 30f), entry(HOUR / 2, 80f), entry(DAY, 40f))
)!!.sessions.single()
assertTrue(slow.meanPercentPerHour < 10.0)
assertTrue(fast.meanPercentPerHour > 50.0)
}
@Test fun `outside temperature is carried, and stays outside temperature`() {
val report = BatteryExposure().analyse(
listOf(entry(0L, 30f, temp = 2f), entry(2 * HOUR, 70f, temp = 4f))
)!!
assertEquals(4.0, report.sessions.single().meanOutsideTempCelsius!!, 1e-6)
}
@Test fun `a ledger with nothing to compare is not a habit`() {
assertNull(BatteryExposure().analyse(listOf(entry(0L, 50f))))
assertNull(BatteryExposure().analyse(emptyList()))
}
@Test fun `hours below the low threshold are counted too`() {
val report = BatteryExposure().analyse(
listOf(entry(0L, 10f), entry(5 * HOUR, 5f))
)!!
assertEquals(5.0, report.hoursBelowLowSoc, 1e-6)
assertEquals(0.0, report.hoursAboveHighSoc, 1e-6)
}
private fun entry(atMs: Long, soc: Float, odometerKm: Float? = null, temp: Float? = null) =
BatteryLedgerEntry(
atMs = atMs,
socPercent = soc,
odometerKm = odometerKm,
outsideTempCelsius = temp,
)
private companion object {
const val HOUR = 3_600_000L
const val DAY = 86_400_000L
}
}
@@ -0,0 +1,159 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class BatteryLedgerTest {
@Test fun `entries round trip oldest first`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
assertTrue(store.append(entry(atMs = 2_000L, soc = 70f)))
assertTrue(store.append(entry(atMs = 1_000L, soc = 80f)))
assertEquals(listOf(1_000L, 2_000L), store.read().map { it.atMs })
}
@Test fun `an unreadable file is quarantined and never read as an empty ledger twice`() {
val directory = tempDirectory()
val target = File(directory, BatteryLedgerStore.FILE_NAME)
target.writeText("{\"schemaVersion\":1,\"entries\":[{\"atMs\":1,")
val store = BatteryLedgerStore(target)
assertTrue(store.read().isEmpty())
assertFalse(target.exists())
assertEquals(1, directory.listFiles()!!.count { it.name.contains("quarantine") })
}
@Test fun `a file from a schema this build does not know is not guessed at`() {
val target = File(tempDirectory(), BatteryLedgerStore.FILE_NAME)
target.writeText("{\"schemaVersion\":99,\"entries\":[{\"atMs\":1,\"socPercent\":50}]}")
assertTrue(BatteryLedgerStore(target).read().isEmpty())
}
@Test fun `the newest entries survive the bound`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME), maxEntries = 3)
repeat(5) { store.append(entry(atMs = it * 1_000L, soc = 90f - it)) }
assertEquals(listOf(2_000L, 3_000L, 4_000L), store.read().map { it.atMs })
}
@Test fun `a sample with no charge reading earns nothing`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
assertFalse(recorder.observe(snapshot(atMs = 0L, soc = null)))
assertTrue(store.read().isEmpty())
}
@Test fun `an entry is earned by a point of charge, not by a sample`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
assertTrue(recorder.observe(snapshot(atMs = 0L, soc = 80f)))
assertFalse(recorder.observe(snapshot(atMs = 1_000L, soc = 79.5f)))
assertTrue(recorder.observe(snapshot(atMs = 2_000L, soc = 79f)))
assertEquals(listOf(80f, 79f), store.read().map { it.socPercent })
}
@Test fun `a charging state change earns an entry even at the same charge`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
recorder.observe(snapshot(atMs = 0L, soc = 50f, chargingStatus = 0))
assertTrue(recorder.observe(snapshot(atMs = 1_000L, soc = 50f, chargingStatus = 1)))
}
@Test fun `the integrals are cumulative and bounded by the same five seconds a trip uses`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
recorder.observe(snapshot(atMs = 0L, soc = 80f, powerKw = 36f, speedKmh = 72f))
// 36 kW and 72 km/h held for an hour, sampled every second, would be 36 kWh and 72 km;
// one second of it is a thousandth of that.
recorder.observe(snapshot(atMs = 1_000L, soc = 80f, powerKw = 36f, speedKmh = 72f))
// A gap longer than five seconds is time the app did not watch: nothing is added.
recorder.observe(snapshot(atMs = 60_000L, soc = 79f, powerKw = 36f, speedKmh = 72f))
val last = store.read().last()
assertEquals(0.01, last.packEnergyKwh!!, 1e-9)
assertEquals(0.02, last.integratedDistanceKm!!, 1e-9)
}
@Test fun `a restart continues the counters instead of starting again at zero`() {
val target = File(tempDirectory(), BatteryLedgerStore.FILE_NAME)
val store = BatteryLedgerStore(target)
val first = BatteryLedgerRecorder(store)
first.observe(snapshot(atMs = 0L, soc = 80f, powerKw = 36f, speedKmh = 72f))
first.observe(snapshot(atMs = 1_000L, soc = 79f, powerKw = 36f, speedKmh = 72f))
val before = store.read().last().packEnergyKwh!!
val afterRestart = BatteryLedgerRecorder(BatteryLedgerStore(target))
afterRestart.observe(snapshot(atMs = 10_000L, soc = 78f, powerKw = 36f, speedKmh = 72f))
afterRestart.observe(snapshot(atMs = 11_000L, soc = 77f, powerKw = 36f, speedKmh = 72f))
assertTrue(store.read().last().packEnergyKwh!! > before)
assertEquals(before + 0.01, store.read().last().packEnergyKwh!!, 1e-9)
}
@Test fun `the vehicle's own counters are carried through when the car keeps any`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
BatteryLedgerRecorder(store).observe(
snapshot(atMs = 0L, soc = 80f, consumedKwh = 4.5f, regeneratedKwh = 0.5f)
)
val entry = store.read().single()
assertEquals(4.5f, entry.vehicleConsumedKwh!!, 0f)
assertEquals(0.5f, entry.vehicleRegeneratedKwh!!, 0f)
}
@Test fun `a reader never sees an integral the app never integrated`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
BatteryLedgerRecorder(store).observe(snapshot(atMs = 0L, soc = 80f, powerKw = null))
assertNull(store.read().single().packEnergyKwh)
assertNotNull(store.read().single().socPercent)
}
private fun entry(atMs: Long, soc: Float) = BatteryLedgerEntry(atMs = atMs, socPercent = soc)
private fun snapshot(
atMs: Long,
soc: Float?,
powerKw: Float? = null,
speedKmh: Float? = null,
chargingStatus: Int? = null,
consumedKwh: Float? = null,
regeneratedKwh: Float? = null,
) = EnergySnapshot(
timestampMs = atMs,
firmware = FirmwareInfo.Gen.SWI68,
socPercent = soc,
rangeKm = null,
speedKmh = speedKmh,
batteryPowerKw = powerKw,
outsideTempCelsius = 18f,
cabinTempCelsius = null,
batteryTempCelsius = null,
batteryEnergyKwh = null,
batteryCapacityKwh = null,
odometerKm = 20_000f,
chargePortConnected = null,
chargingStatus = chargingStatus,
vehicleConsumedKwh = consumedKwh,
vehicleRegeneratedKwh = regeneratedKwh,
parked = true,
climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null),
tirePressures = TirePressureSnapshot(null, null, null, null),
)
private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile()
}
@@ -0,0 +1,123 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CalibrationDriftTest {
@Test fun `a charge to full last week settles it`() {
val entries = listOf(
entry(day(0), 98f),
// The odometer moves, because the charge was spent driving: a step is only a step
// where the car went nowhere.
entry(day(3), 60f, odometerKm = 10_200f),
)
val report = CalibrationDrift().analyse(entries, nowMs = day(7))!!
assertEquals(CalibrationVerdict.SETTLED, report.verdict)
assertEquals(day(0), report.lastFullChargeAtMs)
assertEquals(7.0, report.daysSinceFullCharge!!, 1e-9)
assertEquals(0.38, report.equivalentCyclesSinceFullCharge, 1e-6)
}
@Test fun `ninety days without a full charge asks for one`() {
val entries = listOf(
entry(day(0), 98f),
entry(day(50), 60f, odometerKm = 10_300f),
entry(day(95), 55f, odometerKm = 10_400f),
)
val report = CalibrationDrift().analyse(entries, nowMs = day(100))!!
assertEquals(CalibrationVerdict.RE_ANCHOR_SUGGESTED, report.verdict)
assertEquals(100.0, report.daysSinceFullCharge!!, 1e-9)
}
@Test fun `a history that never saw a full charge is not silently settled`() {
val entries = listOf(entry(day(0), 80f), entry(day(1), 60f, odometerKm = 10_100f))
assertEquals(
CalibrationVerdict.RE_ANCHOR_SUGGESTED,
CalibrationDrift().analyse(entries, nowMs = day(2))!!.verdict,
)
}
@Test fun `between the two it watches`() {
val entries = listOf(entry(day(0), 98f), entry(day(40), 70f))
assertEquals(
CalibrationVerdict.WATCH,
CalibrationDrift().analyse(entries, nowMs = day(45))!!.verdict,
)
}
@Test fun `a parked car that lost four points overnight is one unexplained step`() {
val entries = listOf(
entry(day(0), 98f),
entry(day(1), 70f, odometerKm = 10_100f),
entry(day(1) + 8 * HOUR, 66f, odometerKm = 10_100f),
)
val report = CalibrationDrift().analyse(entries, nowMs = day(2))!!
assertEquals(1, report.steps.size)
assertEquals(4.0, report.steps.single().dropPercent, 1e-6)
assertTrue(report.steps.single().parked)
assertEquals(CalibrationVerdict.RE_ANCHOR_SUGGESTED, report.verdict)
}
@Test fun `a fortnight of standing still is self-discharge, not a step`() {
val entries = listOf(
entry(day(0), 98f, odometerKm = 10_000f),
entry(day(14), 85f, odometerKm = 10_000f),
)
assertTrue(CalibrationDrift().analyse(entries, nowMs = day(15))!!.steps.isEmpty())
}
@Test fun `a drop with the odometer moving is a drive, whatever the app sampled`() {
val entries = listOf(
entry(day(0), 98f, odometerKm = 10_000f),
entry(day(0) + HOUR, 80f, odometerKm = 10_090f),
)
assertTrue(CalibrationDrift().analyse(entries, nowMs = day(1))!!.steps.isEmpty())
}
@Test fun `without an odometer at both ends nothing is called a step`() {
val entries = listOf(
entry(day(0), 98f, odometerKm = null),
entry(day(0) + HOUR, 90f, odometerKm = null),
)
assertTrue(CalibrationDrift().analyse(entries, nowMs = day(1))!!.steps.isEmpty())
}
@Test fun `the estimate's band is carried as the spread, in points`() {
val estimate = StateOfHealthEstimate(
usableCapacityKwh = Provenanced.estimated(60.0, 1.0),
stateOfHealthPercent = Provenanced.estimated(97.2, 1.6),
source = SohEnergySource.VEHICLE_COUNTERS,
windowCount = 6,
coldWindowsExcluded = 0,
spanDays = 30.0,
spanKm = 1_800.0,
trendPercentPoints = null,
)
val report = CalibrationDrift()
.analyse(listOf(entry(day(0), 98f), entry(day(1), 60f)), day(2), estimate)!!
assertEquals(3.2, report.estimateSpreadPercentPoints!!, 1e-9)
}
@Test fun `one entry is not a history`() {
assertNull(CalibrationDrift().analyse(listOf(entry(day(0), 90f)), nowMs = day(1)))
}
private fun entry(atMs: Long, soc: Float, odometerKm: Float? = 10_000f) =
BatteryLedgerEntry(atMs = atMs, socPercent = soc, odometerKm = odometerKm, parked = true)
private fun day(index: Int): Long = index * DAY
private companion object {
const val DAY = 86_400_000L
const val HOUR = 3_600_000L
}
}
@@ -0,0 +1,217 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeStopPlanTest {
private fun rate(percentPerKm: Double, uncertainty: Double) = SocRate(
percentPerKm = percentPerKm,
uncertaintyPercentPerKm = uncertainty,
source = SocRate.Source.TRIP_HISTORY,
sampleCount = 8,
)
@Test
fun `a short route on a full pack needs no stop`() {
val plan = ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04))
assertTrue(plan is ChargeStopPlan.Plan.NoStop)
plan as ChargeStopPlan.Plan.NoStop
assertEquals(50.0, plan.arrivalPercent, 0.001)
assertEquals(40.0, plan.marginPercent, 0.001)
}
@Test
fun `the reserve is what makes a marginal trip need a stop`() {
// 100 km at 0.4 %/km spends 40 %; from 45 % that arrives at 5, under a 10 % reserve.
val withoutReserve = ChargeStopPlan.of(45.0, 100.0, rate(0.4, 0.02), reservePercent = 0.0)
val withReserve = ChargeStopPlan.of(45.0, 100.0, rate(0.4, 0.02))
assertTrue(withoutReserve is ChargeStopPlan.Plan.NoStop)
assertTrue(withReserve is ChargeStopPlan.Plan.Stop)
}
@Test
fun `the stop distance is pessimistic, not the midpoint`() {
// 35 % spendable at 0.4 +- 0.04 %/km: the midpoint reaches 87,5 km, the top of the
// band only 79,5. The driver is told the shorter one.
val plan = ChargeStopPlan.of(45.0, 200.0, rate(0.4, 0.04))
assertTrue(plan is ChargeStopPlan.Plan.Stop)
plan as ChargeStopPlan.Plan.Stop
assertEquals(79.5, plan.afterKm, 0.5)
assertTrue("pessimistic", plan.afterKm < 35.0 / 0.4)
assertEquals(200.0 - plan.afterKm, plan.shortfallKm, 0.001)
}
@Test
fun `a long route with a loose rate is refused rather than shown wide`() {
val plan = ChargeStopPlan.of(80.0, 600.0, rate(0.4, 0.05))
assertEquals(
ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.BAND_TOO_WIDE),
plan,
)
}
@Test
fun `the same route with a tight rate is planned`() {
val plan = ChargeStopPlan.of(80.0, 600.0, rate(0.4, 0.01))
assertTrue(plan is ChargeStopPlan.Plan.Stop)
}
@Test
fun `missing inputs each name themselves`() {
assertEquals(
ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_CHARGE),
ChargeStopPlan.of(null, 100.0, rate(0.4, 0.01)),
)
assertEquals(
ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_ROUTE),
ChargeStopPlan.of(80.0, null, rate(0.4, 0.01)),
)
assertEquals(
ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_RATE),
ChargeStopPlan.of(80.0, 100.0, null),
)
}
@Test
fun `a flat route plans exactly as it did before grade existed`() {
val flat = RouteGrade.of(0.0, 0.0, BatteryCapacityConfig(61.7, 100.0))
assertEquals(
ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04)),
ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04), grade = flat),
)
}
@Test
fun `a col between here and there is charge that has to be spent`() {
val pack = BatteryCapacityConfig(61.7, 100.0)
val plain = ChargeStopPlan.of(90.0, 200.0, rate(0.3, 0.02)) as ChargeStopPlan.Plan.NoStop
val overACol = ChargeStopPlan.of(
90.0, 200.0, rate(0.3, 0.02),
grade = RouteGrade.of(1200.0, 400.0, pack),
) as ChargeStopPlan.Plan.NoStop
assertTrue("the climb costs charge", overACol.arrivalPercent < plain.arrivalPercent)
assertTrue("and it is less certain", overACol.bandPercent > plain.bandPercent)
// Downhill the other way, and the same route returns some of it.
val backDown = ChargeStopPlan.of(
90.0, 200.0, rate(0.3, 0.02),
grade = RouteGrade.of(400.0, 1200.0, pack),
) as ChargeStopPlan.Plan.NoStop
assertTrue(backDown.arrivalPercent > plain.arrivalPercent)
assertTrue(
"the descent gives back less than the climb took",
backDown.arrivalPercent - plain.arrivalPercent <
plain.arrivalPercent - overACol.arrivalPercent,
)
}
@Test
fun `a climb brings the stop forward rather than leaving it where it was`() {
val pack = BatteryCapacityConfig(61.7, 100.0)
val plain = ChargeStopPlan.of(45.0, 300.0, rate(0.4, 0.02)) as ChargeStopPlan.Plan.Stop
val overACol = ChargeStopPlan.of(
45.0, 300.0, rate(0.4, 0.02),
grade = RouteGrade.of(1200.0, 0.0, pack),
) as ChargeStopPlan.Plan.Stop
assertTrue(overACol.afterKm < plain.afterKm)
}
@Test
fun `a car already below its reserve is told to stop immediately`() {
val plan = ChargeStopPlan.of(8.0, 50.0, rate(0.4, 0.01))
assertTrue(plan is ChargeStopPlan.Plan.Stop)
plan as ChargeStopPlan.Plan.Stop
assertEquals(0.0, plan.afterKm, 0.001)
}
// --- chain ----------------------------------------------------------------------------
@Test
fun `Auzielle to Paris is two stops where one plan refused the whole trip`() {
// 680 km at 0,28 +- 0,028 %/km: the single-stop plan measures its band over all of it,
// 2 x 0,028 x 680 = 38 %, and refuses. Each leg is a third of that road.
val whole = ChargeStopPlan.of(80.0, 680.0, rate(0.28, 0.028))
assertTrue(whole is ChargeStopPlan.Plan.Refused)
assertEquals(
ChargeStopPlan.Reason.BAND_TOO_WIDE,
(whole as ChargeStopPlan.Plan.Refused).reason,
)
val chain = ChargeStopPlan.chain(80.0, 680.0, rate(0.28, 0.028))
assertNull("the chain reaches Paris", chain.refusal)
assertTrue("and stops twice on the way", chain.stops.size >= 2)
assertNotNull(chain.arrival)
// Every stop is somewhere on the road, in the order they are driven.
var previous = 0.0
for (stop in chain.stops) {
assertTrue(stop.endKm > previous)
assertTrue(stop.endKm < 680.0)
previous = stop.endKm
}
// A stop leaves with the declared charge, not with a full pack.
assertEquals(
ChargeStopPlan.DEFAULT_DEPARTURE_PERCENT,
chain.legs[1].startPercent,
0.001,
)
}
@Test
fun `a route one charge covers is the plan of already made`() {
val single = ChargeStopPlan.of(90.0, 200.0, rate(0.3, 0.02)) as ChargeStopPlan.Plan.NoStop
val chain = ChargeStopPlan.chain(90.0, 200.0, rate(0.3, 0.02))
assertEquals(1, chain.legs.size)
assertEquals(single, chain.arrival)
assertEquals(0.0, chain.legs[0].startKm, 0.001)
assertEquals(200.0, chain.legs[0].endKm, 0.001)
}
@Test
fun `a last leg too uncertain to vouch for refuses itself and not the legs before it`() {
// 414 km at 0,3 +- 0,06 %/km — the fifth the vehicle's own range figure is worth. The
// leg to the stop is 194 km and its band holds; the 220 km left after it are reachable
// on the charge the stop leaves with, and arriving there is a 26 % wide claim.
val chain = ChargeStopPlan.chain(80.0, 414.0, rate(0.3, 0.06))
assertEquals(ChargeStopPlan.Reason.BAND_TOO_WIDE, chain.refusal)
assertEquals("the stop before it still reads", 1, chain.stops.size)
assertTrue(chain.stops.all { it.plan is ChargeStopPlan.Plan.Stop })
}
@Test
fun `a departure charge that buys no distance is refused rather than looped over`() {
// Leaving a stop on the reserve itself: the next leg reaches nowhere, for ever.
val chain = ChargeStopPlan.chain(
80.0, 900.0, rate(0.3, 0.01), reservePercent = 10.0, departurePercent = 10.0,
)
assertEquals(ChargeStopPlan.Reason.NO_PROGRESS, chain.refusal)
assertEquals("the first leg was still driveable", 1, chain.stops.size)
}
@Test
fun `a trip needing more legs than the cap is refused as one`() {
val chain = ChargeStopPlan.chain(80.0, 4000.0, rate(0.3, 0.01))
assertEquals(ChargeStopPlan.Reason.TOO_MANY_LEGS, chain.refusal)
assertEquals(ChargeStopPlan.MAX_LEGS, chain.stops.size)
}
@Test
fun `the refusals about the trip are answered by a chain of one leg`() {
assertEquals(
ChargeStopPlan.Reason.NO_CHARGE,
ChargeStopPlan.chain(null, 100.0, rate(0.3, 0.01)).refusal,
)
assertEquals(
ChargeStopPlan.Reason.NO_ROUTE,
ChargeStopPlan.chain(80.0, null, rate(0.3, 0.01)).refusal,
)
assertEquals(
ChargeStopPlan.Reason.NO_RATE,
ChargeStopPlan.chain(80.0, 100.0, null).refusal,
)
}
}
@@ -0,0 +1,34 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ChargingSessionMeterTest {
@Test fun integratesTrapezoidsAndRoundsForPayloads() {
val meter = ChargingSessionMeter()
assertEquals(0.0, meter.sample(0L, true, 10f)!!, 0.0)
assertEquals(0.25, meter.sample(60_000L, true, 20f)!!, 0.0)
}
@Test fun closedOrNeverOpenedSessionIsOmitted() {
val meter = ChargingSessionMeter()
assertNull(meter.sample(0L, null, null))
meter.sample(1L, true, 5f)
assertNull(meter.sample(2L, false, 0f))
}
@Test fun missingRateAndLongGapNeverInventEnergy() {
val meter = ChargingSessionMeter(maxSampleGapMs = 1_000L)
meter.sample(0L, true, 10f)
assertEquals(0.0, meter.sample(500L, true, null)!!, 0.0)
assertEquals(0.0, meter.sample(2_000L, true, 10f)!!, 0.0)
assertEquals(0.0, meter.sample(4_000L, true, 10f)!!, 0.0)
}
@Test fun negativeChargeRateCannotReduceSessionTotal() {
val meter = ChargingSessionMeter()
meter.sample(0L, true, 10f)
assertEquals(0.08, meter.sample(60_000L, true, -5f)!!, 0.0)
}
}
@@ -0,0 +1,111 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import kotlin.math.exp
class ConsumptionCalculatorTest {
@Test fun `zero and low speed are unavailable rather than zero or infinity`() {
val zero = ConsumptionCalculator.instantaneous(12f, 0f)
val low = ConsumptionCalculator.instantaneous(12f, 4.9f)
assertNull(zero.value)
assertEquals(Provenance.UNAVAILABLE, zero.provenance)
assertEquals(UnavailableReason.SPEED_TOO_LOW, zero.reason)
assertNull(low.value)
assertEquals(UnavailableReason.SPEED_TOO_LOW, low.reason)
}
@Test fun `a missing input propagates the stated reason`() {
val missingPower = ConsumptionCalculator.instantaneous(null, 80f)
val missingSpeed = ConsumptionCalculator.instantaneous(
18f,
null,
UnavailableReason.UNSUPPORTED_FIRMWARE,
)
assertEquals(UnavailableReason.SIGNAL_ABSENT, missingPower.reason)
assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, missingSpeed.reason)
}
@Test fun `regeneration remains a negative instantaneous consumption`() {
val value = ConsumptionCalculator.instantaneous(-18f, 90f)
assertEquals(Provenance.DERIVED, value.provenance)
assertEquals(-20.0, value.value!!, 1e-9)
}
@Test fun `dashboard smoothing resets when consumption changes to regeneration`() {
val calculator = ConsumptionCalculator()
calculator.add(0L, 18f, 90f)
val regeneration = calculator.add(1_000L, -18f, 90f)
assertEquals(-20.0, regeneration.rawInstantaneous.value!!, 1e-9)
assertEquals(-20.0, regeneration.smoothedInstantaneous.value!!, 1e-9)
}
@Test fun `dashboard value uses a five second exponential moving average`() {
val calculator = ConsumptionCalculator()
calculator.add(0L, 18f, 90f)
val second = calculator.add(5_000L, 36f, 90f)
val expected = 20.0 + (1.0 - exp(-1.0)) * (40.0 - 20.0)
assertEquals(40.0, second.rawInstantaneous.value!!, 1e-9)
assertEquals(expected, second.smoothedInstantaneous.value!!, 1e-9)
}
@Test fun `rolling average matches trip integration for the same samples`() {
val calculator = ConsumptionCalculator()
val accumulator = EnergyTripAccumulator(0L, 80f)
val samples = listOf(
snapshot(0L, 120f, 18f),
snapshot(5_000L, 120f, 18f),
)
var reading: ConsumptionCalculator.Reading? = null
samples.forEach {
accumulator.add(it)
reading = calculator.add(it)
}
assertEquals(
accumulator.snapshot(5_000L).averageConsumptionKwhPer100Km!!,
reading!!.rollingAverage.value!!,
1e-9,
)
}
@Test fun `rolling average drops samples outside its bounded window`() {
val calculator = ConsumptionCalculator(rollingWindowMs = 2_000L)
calculator.add(0L, 40f, 100f)
calculator.add(1_000L, 40f, 100f)
calculator.add(4_000L, 20f, 200f)
val recent = calculator.add(6_000L, 20f, 200f)
assertEquals(10.0, recent.rollingAverage.value!!, 1e-9)
}
private fun snapshot(atMs: Long, speedKmh: Float, powerKw: Float) = EnergySnapshot(
timestampMs = atMs,
firmware = com.evsuite.hardware.FirmwareInfo.Gen.SWI68,
socPercent = 80f,
rangeKm = null,
speedKmh = speedKmh,
batteryPowerKw = powerKw,
outsideTempCelsius = null,
cabinTempCelsius = null,
batteryTempCelsius = null,
batteryEnergyKwh = null,
batteryCapacityKwh = null,
odometerKm = null,
chargePortConnected = null,
chargingStatus = null,
parked = null,
climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null),
tirePressures = TirePressureSnapshot(null, null, null, null),
)
}
@@ -0,0 +1,182 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.BatteryPowerEvidence
import com.evsuite.hardware.FirmwareInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.file.Files
class EnergyTelemetryTest {
@Test fun `trip carries the exact evidence used for power integration`() {
val evidence = BatteryPowerEvidence(
FirmwareInfo.Gen.SWI68,
BatteryPowerEvidence.OUTPUT_POSITIVE_MW_V1,
)
val trip = EnergyTripAccumulator(0L, 80f, batteryPowerEvidence = evidence)
trip.add(snapshot(0L, 50f, 10f))
trip.add(snapshot(1_000L, 50f, 10f))
assertEquals(evidence, trip.snapshot(1_000L).batteryPowerEvidence)
}
@Test fun `reader keeps unknowns null and rejects invalid physical values`() {
val source = FakeSignals(soc = 120f, range = -1f, power = 18f)
val snapshot = EnergyTelemetryReader(source).read(42L)
assertEquals(42L, snapshot.timestampMs)
assertNull(snapshot.socPercent)
assertNull(snapshot.rangeKm)
assertEquals(18f, snapshot.batteryPowerKw)
}
@Test fun `candidate probes exist only in the explicit evidence read`() {
val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
source.probes = listOf(
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
61_700.0,
)
)
val reader = EnergyTelemetryReader(source)
val normal = reader.read(41L)
val evidence = reader.readEvidence(42L)
assertEquals(41L, normal.timestampMs)
assertEquals(42L, evidence.snapshot.timestampMs)
assertEquals(61_700.0, evidence.probes.single().value!!, 0.0)
}
@Test fun `non finite candidate values fail closed`() {
val source = FakeSignals(soc = null, range = null, power = null)
source.probes = listOf(TelemetryEvidenceProbe("candidate.invalid", Double.NaN))
assertNull(EnergyTelemetryReader(source).readEvidence(42L).probes.single().value)
}
@Test fun `trip integrates distance consumption and regeneration`() {
val trip = EnergyTripAccumulator(0L, 80f)
trip.add(snapshot(0L, 60f, 18f))
trip.add(snapshot(1_000L, 60f, 18f))
trip.add(snapshot(2_000L, 60f, -6f))
trip.add(snapshot(3_000L, 60f, -6f))
val result = trip.snapshot(3_000L)
assertEquals(0.05, result.distanceKm, 0.000001)
assertTrue(result.distanceAvailable == true)
assertEquals(0.05, result.recordedDistanceKm!!, 0.000001)
assertEquals(0.006666, result.consumedKwh!!, 0.000001)
assertEquals(0.001666, result.regeneratedKwh!!, 0.000001)
}
@Test fun `trip keeps distance unknown without a usable speed interval`() {
val trip = EnergyTripAccumulator(0L, 80f)
trip.add(snapshot(0L, null, 18f))
trip.add(snapshot(1_000L, null, 18f))
val result = trip.snapshot(1_000L)
assertFalse(result.distanceAvailable == true)
assertNull(result.recordedDistanceKm)
assertNull(result.averageConsumptionKwhPer100Km)
}
@Test fun `a suspended sampler never inflates trip duration`() {
val trip = EnergyTripAccumulator(0L, 80f)
trip.add(snapshot(0L, 60f, 18f))
trip.add(snapshot(1_000L, 60f, 18f))
trip.add(snapshot(600_000L, 60f, 18f)) // ten minutes with the dashboard hidden
val result = trip.snapshot(600_000L)
assertEquals(1_000L, result.durationMs)
assertEquals(600_000L, result.endedAtMs)
assertEquals(0.016666, result.distanceKm, 0.000001)
}
@Test fun `history is bounded newest first and leaves no temp file`() {
val directory = Files.createTempDirectory("energy-history").toFile()
val target = directory.resolve("trips.json")
val store = EnergyTripHistoryStore(target, maxTrips = 2)
assertEquals(true, store.append(summary(1L)))
assertEquals(true, store.append(summary(2L)))
assertEquals(true, store.append(summary(3L)))
assertEquals(listOf(3L, 2L), store.readSummaries().map { it.startedAtMs })
assertEquals(emptyList<String>(), directory.list()?.filter { it.endsWith(".tmp") })
}
private fun snapshot(at: Long, speed: Float?, power: Float) = EnergySnapshot(
timestampMs = at, firmware = FirmwareInfo.Gen.SWI68, socPercent = 80f,
rangeKm = null, speedKmh = speed, batteryPowerKw = power,
outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null,
batteryEnergyKwh = null, batteryCapacityKwh = null, odometerKm = null,
chargePortConnected = null, chargingStatus = null, parked = null,
climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null),
tirePressures = TirePressureSnapshot(null, null, null, null),
)
private fun summary(start: Long) = EnergyTripSummary(
start, start + 1, 1, 0.0, null, null, null, null
)
private class FakeSignals(
private val soc: Float?,
private val range: Float?,
private val power: Float?,
) : EnergySignalSource {
var probes: List<TelemetryEvidenceProbe> = emptyList()
override fun firmware() = FirmwareInfo.Gen.SWI68
override fun socPercent() = soc
override fun rangeKm() = range
override fun speedKmh(): Float? = null
override fun batteryPowerKw() = power
override fun outsideTempCelsius(): Float? = null
override fun cabinTempCelsius(): Float? = null
override fun batteryTempCelsius(): Float? = null
override fun batteryEnergyKwh(): Float? = null
override fun batteryCapacityKwh(): Float? = null
override fun odometerKm(): Float? = null
override fun chargePortConnected(): Boolean? = null
override fun chargingStatus(): Int? = null
override fun parked(): Boolean? = null
override fun climate() =
ClimateSnapshot(null, null, null, null, null, null, null, null, null)
override fun tirePressures() = TirePressureSnapshot(null, null, null, null)
override fun evidenceProbes() = probes
}
}
class TripDistanceEvidenceTest {
private fun summary(evidence: com.evsuite.hardware.VehicleSpeedEvidence?) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = 1_000L,
durationMs = 1_000L,
distanceKm = 8.46,
startSocPercent = 60f,
endSocPercent = 59f,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
speedEvidence = evidence,
)
@Test fun `a legacy distance stays reported but is not modellable`() {
val trip = summary(evidence = null)
assertEquals(8.46, trip.recordedDistanceKm!!, 1e-9)
assertNull(
"a model must not train on an unversioned distance",
trip.modellableDistanceKm(com.evsuite.hardware.FirmwareInfo.Gen.SWI68),
)
}
@Test fun `a distance from the 3_6x conversion is not modellable`() {
val trip = summary(
com.evsuite.hardware.VehicleSpeedEvidence(
com.evsuite.hardware.FirmwareInfo.Gen.SWI68,
com.evsuite.hardware.VehicleSpeedEvidence.MPS_TIMES_3_6_V1,
)
)
assertEquals(8.46, trip.recordedDistanceKm!!, 1e-9)
assertNull(trip.modellableDistanceKm(com.evsuite.hardware.FirmwareInfo.Gen.SWI68))
}
}
@@ -0,0 +1,156 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PlanDriftTest {
/** 300 km at 0,20 %/km ± 0,02: leaves at 90 %, plans to arrive at 30 %, reserve 10 %. */
private val plan = PlanDrift.Followed(
legKm = 300.0,
socAtDeparturePercent = 90.0,
plannedRatePercentPerKm = 0.20,
plannedUncertaintyPercentPerKm = 0.02,
reservePercent = 10.0,
)
/** Charge left after [km] at [ratePerKm], as the gauge would read it. */
private fun socAfter(km: Double, ratePerKm: Double) =
plan.socAtDeparturePercent - km * ratePerKm
@Test
fun `a drive spending what the plan assumed is holding`() {
val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.20))
val holding = verdict as PlanDrift.Verdict.Holding
assertEquals(0.20, holding.reading.observedPercentPerKm, 1e-9)
assertEquals(200.0, holding.reading.remainingKm, 1e-9)
assertEquals(30.0, holding.reading.arrivalPercent, 1e-9)
}
@Test
fun `spending inside the plan's own band raises nothing`() {
// 0,215 %/km against a plan of 0,20 ± 0,02, with the measurement's own band on top:
// the two overlap, so there is no drift to report however the arrival lands.
val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.215))
assertTrue(verdict.toString(), verdict is PlanDrift.Verdict.Holding)
}
@Test
fun `spending well past the plan, and short of the reserve, drifts`() {
// 0,30 %/km: half again what the plan assumed, so the arrival lands on empty.
val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.30))
val short = verdict as PlanDrift.Verdict.Short
assertEquals(0.30, short.reading.observedPercentPerKm, 1e-9)
assertEquals(0.0, short.reading.arrivalPercent, 1e-9)
assertTrue(short.shortfallPercent > 0.0)
// The shortfall is measured at the pessimistic edge, so it is wider than 10 − 0.
assertEquals(
plan.reservePercent - (0.0 - short.reading.bandPercent),
short.shortfallPercent,
1e-9,
)
}
/**
* The gauge steps a tenth, not a whole percent, and that is the difference between a companion
* that speaks here and one that does not. At 0,26 %/km the arrival lands at 12 % against a
* 10 % reserve; the measurement's own band on that arrival is 0,72 points, so the reserve is
* still clear and there is nothing to say. A whole-percent gauge would have made the band 2,5
* points, crossed the reserve on quantisation alone, and raised a line the drive never earned.
*/
@Test
fun `an arrival two points above the reserve is not short at a tenth-percent gauge`() {
val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.26))
val holding = verdict as PlanDrift.Verdict.Holding
assertEquals(12.0, holding.reading.arrivalPercent, 1e-9)
assertTrue(
holding.reading.bandPercent.toString(),
holding.reading.bandPercent < 1.0,
)
}
@Test
fun `spending past the plan but still arriving well clear of the reserve is not reported`() {
// Same 0,26 %/km, but only 60 km of leg left to spend it on: the arrival still clears
// the reserve, and a companion that speaks here is one nobody reads when it matters.
val near = plan.copy(legKm = 100.0)
val verdict = PlanDrift.check(near, drivenKm = 40.0, socNowPercent = socAfter(40.0, 0.26))
assertTrue(verdict.toString(), verdict is PlanDrift.Verdict.Holding)
}
@Test
fun `the car's own remaining distance wins over the leg's arithmetic`() {
val verdict = PlanDrift.check(
plan,
drivenKm = 100.0,
socNowPercent = socAfter(100.0, 0.20),
remainingKm = 240.0,
)
val holding = verdict as PlanDrift.Verdict.Holding
assertEquals(240.0, holding.reading.remainingKm, 1e-9)
assertEquals(70.0 - 240.0 * 0.20, holding.reading.arrivalPercent, 1e-9)
}
@Test
fun `too little road behind the car says so rather than measuring`() {
val verdict = PlanDrift.check(plan, drivenKm = 5.0, socNowPercent = 88.0)
assertEquals(
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.TOO_SOON),
verdict,
)
}
@Test
fun `a charge en route is refused, a col's worth of regeneration is not`() {
// 97 % after leaving at 90 %: seven points back is more than a descent gives.
assertEquals(
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.CHARGED_EN_ROUTE),
PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = 97.0),
)
// 94 % after 100 km: four points above departure, which a long descent can give back.
val recovered = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = 94.0)
val holding = recovered as PlanDrift.Verdict.Holding
assertEquals(0.0, holding.reading.observedPercentPerKm, 1e-9)
}
@Test
fun `no plan, no charge, no distance and an arrival each say which`() {
assertEquals(
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_PLAN),
PlanDrift.check(null, 100.0, 60.0),
)
assertEquals(
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_CHARGE),
PlanDrift.check(plan, 100.0, null),
)
assertEquals(
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_DISTANCE),
PlanDrift.check(plan, null, 60.0),
)
assertEquals(
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.ARRIVED),
PlanDrift.check(plan, 320.0, 20.0),
)
}
@Test
fun `the measurement's band shrinks as the road behind it grows`() {
val early = PlanDrift.check(plan, 20.0, socAfter(20.0, 0.20)) as PlanDrift.Verdict.Holding
val late = PlanDrift.check(plan, 200.0, socAfter(200.0, 0.20)) as PlanDrift.Verdict.Holding
// Same rate, same route: the only thing that changed is how much evidence there is.
assertTrue(
"${early.reading.bandPercent} vs ${late.reading.bandPercent}",
early.reading.bandPercent > late.reading.bandPercent,
)
}
@Test
fun `the rate the plan was made with is the one with the climb already in it`() {
// What the app hands over: the planner's own fold, not a second copy of it.
val rate = SocRate(0.20, 0.02, SocRate.Source.TRIP_HISTORY, sampleCount = 8)
val effective = ChargeStopPlan.effectiveRate(rate, 300.0, RouteGrade.Cost(6.0, 1.5))
assertEquals(0.22, effective.percentPerKm, 1e-9)
assertEquals(0.025, effective.uncertaintyPercentPerKm, 1e-9)
}
}
@@ -0,0 +1,89 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ProvenanceTest {
@Test fun `a missing reading is unavailable with a reason, not a zero`() {
val value = Provenanced.measured<Float>(null)
assertEquals(Provenance.UNAVAILABLE, value.provenance)
assertEquals(UnavailableReason.SIGNAL_ABSENT, value.reason)
assertNull(value.value)
}
@Test fun `an unsupported firmware says so rather than reporting an absent signal`() {
val value = Provenanced.measured<Float>(null, UnavailableReason.UNSUPPORTED_FIRMWARE)
assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, value.reason)
}
@Test(expected = IllegalArgumentException::class)
fun `an estimate cannot be built without a band`() {
Provenanced(42.0, Provenance.ESTIMATED)
}
@Test(expected = IllegalArgumentException::class)
fun `a band must be finite and non-negative`() {
Provenanced.estimated(42.0, -1.0)
}
@Test(expected = IllegalArgumentException::class)
fun `a measurement carries no band — the vehicle publishes no error bars`() {
Provenanced(42.0, Provenance.MEASURED, uncertainty = 1.0)
}
@Test(expected = IllegalArgumentException::class)
fun `an unavailable value carries no number`() {
Provenanced(42.0, Provenance.UNAVAILABLE, reason = UnavailableReason.SIGNAL_ABSENT)
}
@Test fun `an estimate exposes its band edges`() {
val value = Provenanced.estimated(14.2, 1.6)
assertEquals(12.6, value.bandLow!!, 1e-9)
assertEquals(15.8, value.bandHigh!!, 1e-9)
}
@Test fun `a derivation over an unavailable input is unavailable, never partly computed`() {
val power = Provenanced.measured(18.0)
val speed = Provenanced.measured<Double>(null)
val consumption = Provenanced.derive(power, speed) { p, v -> p * 100.0 / v }
assertEquals(Provenance.UNAVAILABLE, consumption.provenance)
assertEquals(UnavailableReason.SIGNAL_ABSENT, consumption.reason)
}
@Test fun `a derivation carries the first missing input's reason`() {
val power = Provenanced.measured<Double>(null, UnavailableReason.UNSUPPORTED_FIRMWARE)
val speed = Provenanced.measured(90.0)
val consumption = Provenanced.derive(power, speed) { p, v -> p * 100.0 / v }
assertEquals(UnavailableReason.UNSUPPORTED_FIRMWARE, consumption.reason)
}
@Test fun `a derivation over available inputs is derived, not measured`() {
val power = Provenanced.measured(18.0)
val speed = Provenanced.measured(90.0)
val consumption = Provenanced.derive(power, speed) { p, v -> p * 100.0 / v }
assertEquals(Provenance.DERIVED, consumption.provenance)
assertEquals(20.0, consumption.value!!, 1e-9)
assertNull(consumption.uncertainty)
}
@Test fun `a transform that declines to produce a value yields unavailable`() {
val speed = Provenanced.measured(0.0)
val consumption = Provenanced.derive(speed) { v -> if (v < 5.0) null else 100.0 / v }
assertEquals(Provenance.UNAVAILABLE, consumption.provenance)
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, consumption.reason)
}
@Test(expected = IllegalArgumentException::class)
fun `deriving from an estimate is refused rather than silently dropping its band`() {
val range = Provenanced.estimated(280.0, 25.0)
Provenanced.derive(range) { it / 2.0 }
}
@Test fun `availability is readable without unwrapping the value`() {
assertTrue(Provenanced.measured(1.0).isAvailable)
assertTrue(!Provenanced.unavailable<Double>(UnavailableReason.MODEL_NOT_TRAINED).isAvailable)
}
}
@@ -0,0 +1,64 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class RouteGradeTest {
private val pack = BatteryCapacityConfig(usableCapacityKwhWhenNew = 61.7, stateOfHealthPercent = 100.0)
@Test
fun `a kilometre of climb costs what the physics says, near enough to check by hand`() {
// 1750 kg over 1000 m is 17,2 MJ = 4,77 kWh at the wheels, 5,61 kWh from the pack at
// 85 %, which is 9,1 % of 61,7 kWh.
val cost = RouteGrade.of(1000.0, 0.0, pack)
assertNotNull(cost)
assertEquals(9.1, cost!!.percent, 0.2)
assertTrue("the band carries the constants", cost.uncertaintyPercent > cost.percent * 0.4)
}
@Test
fun `a flat route costs nothing, and says so as a number rather than as silence`() {
val cost = RouteGrade.of(0.0, 0.0, pack)
assertEquals(0.0, cost!!.percent, 1e-9)
}
@Test
fun `a descent returns charge, but never as much as the same climb spent`() {
val down = RouteGrade.of(0.0, 1000.0, pack)!!
val up = RouteGrade.of(1000.0, 0.0, pack)!!
assertTrue("a descent returns charge", down.percent < 0.0)
assertTrue("regeneration is not free", -down.percent < up.percent)
}
@Test
fun `a round trip over a col is not a free lunch`() {
val roundTrip = RouteGrade.of(1000.0, 1000.0, pack)!!
val climbOnly = RouteGrade.of(1000.0, 0.0, pack)!!
assertTrue("a col costs something both ways", roundTrip.percent > 0.0)
assertTrue("but less than climbing twice", roundTrip.percent < climbOnly.percent)
// Two thirds of a kWh per hundred metres of col, there and back. If this ever comes out
// at zero the efficiencies have been made equal and the arithmetic has stopped being
// physics.
assertEquals(4.4, roundTrip.percent, 0.3)
}
@Test
fun `no profile is null, and null is not a flat road`() {
assertNull(RouteGrade.of(null, 0.0, pack))
assertNull(RouteGrade.of(0.0, null, pack))
assertNull(RouteGrade.of(100.0, 100.0, null))
assertNull(RouteGrade.of(Double.NaN, 0.0, pack))
assertNull(RouteGrade.of(-10.0, 0.0, pack))
}
@Test
fun `a smaller pack means the same col costs a larger share of it`() {
val small = RouteGrade.of(1000.0, 0.0, BatteryCapacityConfig(40.0, 100.0))!!
val large = RouteGrade.of(1000.0, 0.0, pack)!!
assertTrue(small.percent > large.percent)
}
}
@@ -0,0 +1,226 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class StateOfHealthTest {
@Test fun `six discharges on the car's own counters recover the capacity`() {
val result = StateOfHealthEstimator()
.estimate(counterLedger(windows = 6, capacityKwh = 60.0), CAPACITY_WHEN_NEW)
val ready = result as StateOfHealthResult.Ready
assertEquals(60.0, ready.estimate.usableCapacityKwh.value!!, 0.2)
assertEquals(
60.0 * 100.0 / CAPACITY_WHEN_NEW,
ready.estimate.stateOfHealthPercent.value!!,
0.5,
)
assertEquals(SohEnergySource.VEHICLE_COUNTERS, ready.estimate.source)
assertEquals(6, ready.estimate.windowCount)
assertEquals(Provenance.ESTIMATED, ready.estimate.stateOfHealthPercent.provenance)
assertNotNull(ready.estimate.stateOfHealthPercent.uncertainty)
}
@Test fun `five discharges refuse rather than estimate`() {
val result = StateOfHealthEstimator()
.estimate(counterLedger(windows = 5, capacityKwh = 60.0), CAPACITY_WHEN_NEW)
val unavailable = result as StateOfHealthResult.Unavailable
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, unavailable.reason)
assertEquals(5, unavailable.windowsSeen)
}
@Test fun `the band is never tighter than the counter's own whole kilowatt-hour`() {
val ready = StateOfHealthEstimator()
.estimate(counterLedger(windows = 8, capacityKwh = 60.0), CAPACITY_WHEN_NEW)
as StateOfHealthResult.Ready
// Identical windows have no spread at all, so a floor is all that is left — and for the
// car's own counter the coarser of the two is its whole-kWh step, not the charge gauge.
val expected = StateOfHealthEstimator.COUNTER_STEP_KWH * 100.0 / 60.0
assertEquals(expected, ready.estimate.usableCapacityKwh.uncertainty!!, 1e-6)
assertTrue(expected > 60.0 * StateOfHealthEstimator.SOC_STEP_PERCENT / 60.0)
}
@Test fun `an integral has no step of its own, so the gauge is its floor`() {
val ready = StateOfHealthEstimator()
.estimate(packPairLedger(windows = 8, capacityKwh = 58.0), CAPACITY_WHEN_NEW)
as StateOfHealthResult.Ready
val expected = 58.0 * StateOfHealthEstimator.SOC_STEP_PERCENT / 60.0
assertEquals(expected, ready.estimate.usableCapacityKwh.uncertainty!!, 1e-3)
}
@Test fun `a window under the minimum drop contributes nothing`() {
val entries = listOf(
entry(day(0), 60f, consumed = 0f),
entry(day(1), 40f, consumed = 12.0f),
)
assertTrue(StateOfHealthEstimator().windows(entries).isEmpty())
}
@Test fun `a charge splits the window it falls in`() {
val entries = listOf(
entry(day(0), 95f, consumed = 0f),
entry(day(1), 35f, consumed = 36f),
entry(day(1) + HOUR, 90f, consumed = 0f),
entry(day(2), 30f, consumed = 36f),
)
val windows = StateOfHealthEstimator().windows(entries)
assertEquals(2, windows.size)
assertTrue(windows.all { it.socDropPercent == 60.0 })
}
@Test fun `a counter that went backwards is a charge the gauge denied, and is refused`() {
val entries = listOf(
entry(day(0), 95f, consumed = 30f),
entry(day(1), 35f, consumed = 4f),
)
assertTrue(StateOfHealthEstimator().windows(entries).isEmpty())
}
@Test fun `the car's counters outrank this app's integral even when the integral has more windows`() {
val entries = ArrayList<BatteryLedgerEntry>()
entries += packPairLedger(windows = 9, capacityKwh = 52.0)
entries += counterLedger(windows = 6, capacityKwh = 60.0, startDay = 40)
val ready = StateOfHealthEstimator().estimate(entries, CAPACITY_WHEN_NEW)
as StateOfHealthResult.Ready
assertEquals(SohEnergySource.VEHICLE_COUNTERS, ready.estimate.source)
assertEquals(60.0, ready.estimate.usableCapacityKwh.value!!, 0.2)
}
@Test fun `the pack pair answers when the car keeps no counters`() {
val ready = StateOfHealthEstimator()
.estimate(packPairLedger(windows = 6, capacityKwh = 58.0), CAPACITY_WHEN_NEW)
as StateOfHealthResult.Ready
assertEquals(SohEnergySource.PACK_PAIR_INTEGRAL, ready.estimate.source)
assertEquals(58.0, ready.estimate.usableCapacityKwh.value!!, 0.2)
}
@Test fun `an integral that covered less ground than the odometer is refused`() {
val entries = listOf(
packEntry(day(0), 95f, packKwh = 0.0, odometerKm = 10_000f, integratedKm = 0.0),
// The car drove 300 km; the app was awake for 200 of them, so a third of the
// energy is missing and the capacity would come out a third too small.
packEntry(day(1), 35f, packKwh = 34.8, odometerKm = 10_300f, integratedKm = 200.0),
)
assertTrue(StateOfHealthEstimator().windows(entries).isEmpty())
}
@Test fun `a cold window is excluded and counted`() {
val warm = counterLedger(windows = 6, capacityKwh = 60.0)
val cold = counterLedger(windows = 2, capacityKwh = 48.0, startDay = 40, tempCelsius = -4f)
val ready = StateOfHealthEstimator().estimate(warm + cold, CAPACITY_WHEN_NEW)
as StateOfHealthResult.Ready
assertEquals(6, ready.estimate.windowCount)
assertEquals(2, ready.estimate.coldWindowsExcluded)
assertEquals(60.0, ready.estimate.usableCapacityKwh.value!!, 0.2)
}
@Test fun `a trend needs a history long enough to have one`() {
val short = StateOfHealthEstimator()
.estimate(counterLedger(windows = 6, capacityKwh = 60.0), CAPACITY_WHEN_NEW)
as StateOfHealthResult.Ready
assertNull(short.estimate.trendPercentPoints)
val fading = ArrayList<BatteryLedgerEntry>()
fading += counterLedger(windows = 6, capacityKwh = 60.0, startDay = 0)
fading += counterLedger(windows = 6, capacityKwh = 57.0, startDay = 120)
val long = StateOfHealthEstimator().estimate(fading, CAPACITY_WHEN_NEW)
as StateOfHealthResult.Ready
// Three kilowatt-hours lost out of a 61,7 kWh pack is a little under five points.
assertEquals(-4.9, long.estimate.trendPercentPoints!!, 0.6)
}
@Test fun `a capacity nobody declared is refused rather than divided by`() {
val result = StateOfHealthEstimator()
.estimate(counterLedger(windows = 6, capacityKwh = 60.0), 0.0)
assertTrue(result is StateOfHealthResult.Unavailable)
}
/** One window per cycle: full to 35 %, then a charge back up, exactly as a week looks. */
private fun counterLedger(
windows: Int,
capacityKwh: Double,
startDay: Int = 0,
tempCelsius: Float = 18f,
): List<BatteryLedgerEntry> {
val entries = ArrayList<BatteryLedgerEntry>()
repeat(windows) { index ->
val start = day(startDay + index * 2)
val drop = 60.0
entries += entry(start, 95f, consumed = 0f, temp = tempCelsius)
entries += entry(
start + DAY,
35f,
consumed = (capacityKwh * drop / 100.0).toFloat(),
temp = tempCelsius,
)
}
return entries
}
private fun packPairLedger(
windows: Int,
capacityKwh: Double,
startDay: Int = 0,
): List<BatteryLedgerEntry> {
val entries = ArrayList<BatteryLedgerEntry>()
var cumulativeKwh = 0.0
var cumulativeKm = 0.0
repeat(windows) { index ->
val start = day(startDay + index * 2)
entries += packEntry(start, 95f, cumulativeKwh, 10_000f + cumulativeKm.toFloat(), cumulativeKm)
cumulativeKwh += capacityKwh * 0.60
cumulativeKm += 300.0
entries += packEntry(
start + DAY, 35f, cumulativeKwh, 10_000f + cumulativeKm.toFloat(), cumulativeKm,
)
}
return entries
}
private fun entry(
atMs: Long,
soc: Float,
consumed: Float,
regenerated: Float = 0f,
temp: Float = 18f,
) = BatteryLedgerEntry(
atMs = atMs,
socPercent = soc,
outsideTempCelsius = temp,
vehicleConsumedKwh = consumed,
vehicleRegeneratedKwh = regenerated,
)
private fun packEntry(
atMs: Long,
soc: Float,
packKwh: Double,
odometerKm: Float,
integratedKm: Double,
) = BatteryLedgerEntry(
atMs = atMs,
socPercent = soc,
odometerKm = odometerKm,
outsideTempCelsius = 18f,
packEnergyKwh = packKwh,
integratedDistanceKm = integratedKm,
)
private fun day(index: Int): Long = index * DAY
private companion object {
const val DAY = 86_400_000L
const val HOUR = 3_600_000L
const val CAPACITY_WHEN_NEW = 61.7
}
}
@@ -0,0 +1,187 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TelemetryEvidenceTest {
@Test fun `a signal the vehicle never answers has nothing, not zero`() {
val recorder = TelemetryEvidenceRecorder()
repeat(10) { recorder.record(snapshot(at = it * 1_000L, power = null)) }
val power = recorder.evidence().single {
it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW
}
assertEquals(0, power.samples)
assertEquals(10, power.nulls)
assertNull(power.min)
assertNull(power.max)
assertNull(power.mean)
assertTrue(!power.available)
}
@Test fun `counts, range and mean come from the samples that existed`() {
val recorder = TelemetryEvidenceRecorder()
listOf(10f, null, 20f, 30f).forEachIndexed { index, value ->
recorder.record(snapshot(at = index * 1_000L, power = value))
}
val power = recorder.evidence().single {
it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW
}
assertEquals(3, power.samples)
assertEquals(1, power.nulls)
assertEquals(10.0, power.min!!, 1e-9)
assertEquals(30.0, power.max!!, 1e-9)
assertEquals(20.0, power.mean!!, 1e-9)
}
@Test fun `the sign split is what proves a power convention`() {
val recorder = TelemetryEvidenceRecorder()
listOf(42f, -28f, 0f, 15f).forEachIndexed { index, value ->
recorder.record(snapshot(at = index * 1_000L, power = value))
}
val power = recorder.evidence().single {
it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW
}
assertEquals(2, power.positive)
assertEquals(1, power.negative)
assertEquals(1, power.zero)
}
@Test fun `a flag carries no sign split`() {
val recorder = TelemetryEvidenceRecorder()
recorder.record(snapshot(at = 0L, power = null, parked = true))
val parked = recorder.evidence().single { it.signal == TelemetryEvidenceRecorder.PARKED }
assertEquals(SignalKind.BOOLEAN, parked.kind)
assertNull(parked.positive)
assertNull(parked.negative)
assertEquals(1, parked.samples)
}
@Test fun `the update period follows value changes, not sampling ticks`() {
val recorder = TelemetryEvidenceRecorder()
// One second between reads, five seconds between the values the vehicle publishes.
repeat(30) { tick ->
val value = (tick / 5) * 10f
recorder.record(snapshot(at = tick * 1_000L, power = value))
}
val power = recorder.evidence().single {
it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW
}
assertEquals(5, power.changes)
assertEquals(5_000L, power.updatePeriodMedianMs)
assertEquals(5_000L, power.updatePeriodMinMs)
assertEquals(5_000L, power.updatePeriodMaxMs)
}
@Test fun `a constant signal has no update period rather than a zero one`() {
val recorder = TelemetryEvidenceRecorder()
repeat(10) { recorder.record(snapshot(at = it * 1_000L, power = 12f)) }
val power = recorder.evidence().single {
it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW
}
assertEquals(0, power.changes)
assertNull(power.updatePeriodMedianMs)
}
@Test fun `an irregular sampler still reports the periods it observed`() {
val recorder = TelemetryEvidenceRecorder()
listOf(0L to 1f, 1_000L to 2f, 9_000L to 3f, 11_000L to 4f).forEach { (at, value) ->
recorder.record(snapshot(at = at, power = value))
}
val power = recorder.evidence().single {
it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW
}
assertEquals(3, power.changes)
assertEquals(1_000L, power.updatePeriodMinMs)
assertEquals(8_000L, power.updatePeriodMaxMs)
assertEquals(2_000L, power.updatePeriodMedianMs)
}
@Test fun `the capture round-trips through its schema`() {
val recorder = TelemetryEvidenceRecorder()
repeat(4) { recorder.record(snapshot(at = it * 1_000L, power = it.toFloat())) }
val capture = recorder.capture()
val restored = TelemetryEvidenceFormat.fromJson(TelemetryEvidenceFormat.toJson(capture))
assertNotNull(restored)
assertEquals(capture.snapshots, restored!!.snapshots)
assertEquals(capture.firmware, restored.firmware)
assertEquals(capture.signals.size, restored.signals.size)
val power = restored.signals.single {
it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW
}
assertEquals(4, power.samples)
assertEquals(3.0, power.max!!, 1e-9)
}
@Test fun `an absent statistic stays absent through the schema, never a zero`() {
val recorder = TelemetryEvidenceRecorder()
recorder.record(snapshot(at = 0L, power = null))
val json = TelemetryEvidenceFormat.toJson(recorder.capture())
val restored = TelemetryEvidenceFormat.fromJson(json)!!
val power = restored.signals.single {
it.signal == TelemetryEvidenceRecorder.BATTERY_POWER_KW
}
assertNull(power.min)
assertNull(power.mean)
}
@Test fun `a capture written by a different schema version is refused`() {
val json = TelemetryEvidenceFormat.toJson(
EvidenceCapture(
schemaVersion = 99, firmware = "SWI68", startedAtMs = 0L, endedAtMs = 1L,
snapshots = 1, signals = emptyList(),
)
)
assertNull(TelemetryEvidenceFormat.fromJson(json))
}
@Test fun `a probed candidate property is recorded beside the snapshot signals`() {
val recorder = TelemetryEvidenceRecorder()
val name = TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH
recorder.record(TelemetryEvidenceSample(
snapshot(at = 0L, power = 1f),
listOf(TelemetryEvidenceProbe(name, 61_700.0)),
))
recorder.record(TelemetryEvidenceSample(
snapshot(at = 1_000L, power = 2f),
listOf(TelemetryEvidenceProbe(name, null)),
))
val candidate = recorder.evidence().single { it.signal == name }
assertEquals(1, candidate.samples)
assertEquals(1, candidate.nulls)
assertEquals(2, recorder.capture().snapshots)
}
@Test fun `the markdown rendering names every signal and dashes what is missing`() {
val recorder = TelemetryEvidenceRecorder()
recorder.record(snapshot(at = 0L, power = null))
val markdown = TelemetryEvidenceFormat.toMarkdown(recorder.capture())
assertTrue(markdown.contains("`${TelemetryEvidenceRecorder.BATTERY_POWER_KW}`"))
assertTrue(markdown.contains("| — | — | — |"))
}
private fun snapshot(at: Long, power: Float?, parked: Boolean? = null) = EnergySnapshot(
timestampMs = at, firmware = FirmwareInfo.Gen.SWI68, socPercent = null,
rangeKm = null, speedKmh = null, batteryPowerKw = power,
outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null,
batteryEnergyKwh = null, batteryCapacityKwh = null, odometerKm = null,
chargePortConnected = null, chargingStatus = null, parked = parked,
climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null),
tirePressures = TirePressureSnapshot(null, null, null, null),
)
}
@@ -0,0 +1,183 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class TripDetectorTest {
@Test fun `sustained motion starts once after the debounce window`() {
val detector = TripDetector()
assertEquals(TripDetector.State.ARMED, detector.add(snapshot(0L, 10f)).state)
assertNull(detector.add(snapshot(4_999L, 10f)).event)
val started = detector.add(snapshot(5_000L, 10f))
assertEquals(TripDetector.State.RECORDING, started.state)
assertEquals(TripDetector.Event.START, started.event)
assertNull(detector.add(snapshot(6_000L, 10f)).event)
}
@Test fun `a ninety second traffic stop does not split a trip`() {
val detector = recordingDetector()
assertEquals(TripDetector.State.ENDING, detector.add(snapshot(6_000L, 0f, parked = false)).state)
val trafficStop = sustainStop(detector, 7_000L, 96_000L, parked = false)
assertNull(trafficStop.event)
val movingAgain = detector.add(snapshot(97_000L, 20f, parked = false))
assertEquals(TripDetector.State.RECORDING, movingAgain.state)
assertNull(movingAgain.event)
}
@Test fun `a genuine confirmed end closes the trip`() {
val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f, parked = false))
val stopped = sustainStop(detector, 7_000L, 126_000L, parked = true)
assertEquals(TripDetector.State.IDLE, stopped.state)
assertEquals(TripDetector.Event.STOP, stopped.event)
}
@Test fun `standstill alone closes when confirmation signals do not exist`() {
val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f))
val stopped = sustainStop(detector, 7_000L, 126_000L)
assertEquals(TripDetector.Event.STOP, stopped.event)
}
@Test fun `available confirmation must become true before ending`() {
val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f, parked = false, chargePortConnected = false))
val unconfirmed = sustainStop(
detector,
7_000L,
126_000L,
parked = false,
chargePortConnected = false,
)
val charging = detector.add(
snapshot(127_000L, 0f, parked = false, chargePortConnected = true)
)
assertEquals(TripDetector.State.ENDING, unconfirmed.state)
assertNull(unconfirmed.event)
assertEquals(TripDetector.Event.STOP, charging.event)
}
@Test fun `null speed mid trip discards stop evidence without ending`() {
val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f, parked = true))
detector.add(snapshot(100_000L, null, parked = true))
val afterOldDeadline = detector.add(snapshot(130_000L, 0f, parked = true))
assertEquals(TripDetector.State.ENDING, afterOldDeadline.state)
assertNull(afterOldDeadline.event)
}
@Test fun `forward telemetry gap discards stop evidence without ending`() {
val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f, parked = true))
val afterGap = detector.add(snapshot(126_000L, 0f, parked = true))
assertEquals(TripDetector.State.ENDING, afterGap.state)
assertNull(afterGap.event)
}
@Test fun `forward telemetry gap discards start evidence`() {
val detector = TripDetector()
detector.add(snapshot(0L, 10f))
val afterGap = detector.add(snapshot(60_000L, 10f))
assertEquals(TripDetector.State.ARMED, afterGap.state)
assertNull(afterGap.event)
}
@Test fun `bounce around start threshold never flaps into recording`() {
val detector = TripDetector()
detector.add(snapshot(0L, 5.1f))
assertEquals(TripDetector.State.IDLE, detector.add(snapshot(2_000L, 4.9f)).state)
detector.add(snapshot(3_000L, 5.1f))
assertEquals(TripDetector.State.IDLE, detector.add(snapshot(7_999L, 4.9f)).state)
}
@Test fun `missing speed firmware cannot auto start or auto stop`() {
val detector = TripDetector()
repeat(10) { index ->
val result = detector.add(snapshot(index * 10_000L, null, parked = true))
assertEquals(TripDetector.State.IDLE, result.state)
assertNull(result.event)
}
detector.markRecording()
val running = detector.add(snapshot(200_000L, null, parked = true))
assertEquals(TripDetector.State.RECORDING, running.state)
assertNull(running.event)
}
@Test fun `moving sample never ends even when parked is reported`() {
val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f, parked = true))
val moving = detector.add(snapshot(200_000L, 2f, parked = true))
assertEquals(TripDetector.State.RECORDING, moving.state)
assertNull(moving.event)
}
private fun recordingDetector() = TripDetector().also { detector ->
detector.add(snapshot(0L, 10f))
assertEquals(TripDetector.Event.START, detector.add(snapshot(5_000L, 10f)).event)
}
private fun sustainStop(
detector: TripDetector,
fromMs: Long,
throughMs: Long,
parked: Boolean? = null,
chargePortConnected: Boolean? = null,
): TripDetector.Result {
var result = TripDetector.Result(detector.state)
var timestampMs = fromMs
while (timestampMs <= throughMs) {
result = detector.add(
snapshot(timestampMs, 0f, parked, chargePortConnected)
)
timestampMs += 1_000L
}
return result
}
private fun snapshot(
atMs: Long,
speedKmh: Float?,
parked: Boolean? = null,
chargePortConnected: Boolean? = null,
) = EnergySnapshot(
timestampMs = atMs,
firmware = FirmwareInfo.Gen.SWI68,
socPercent = 80f,
rangeKm = null,
speedKmh = speedKmh,
batteryPowerKw = 10f,
outsideTempCelsius = null,
cabinTempCelsius = null,
batteryTempCelsius = null,
batteryEnergyKwh = null,
batteryCapacityKwh = null,
odometerKm = null,
chargePortConnected = chargePortConnected,
chargingStatus = null,
parked = parked,
climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null),
tirePressures = TirePressureSnapshot(null, null, null, null),
)
}
@@ -0,0 +1,240 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.BatteryPowerEvidence
import com.evsuite.hardware.FirmwareInfo
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class TripHistoryStoreTest {
@Test fun `a v1 file opens without losing a trip`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
// Exactly what the previous build wrote: a bare array of summaries, no envelope.
target.writeText(Gson().toJson(listOf(summary(2L), summary(1L))))
val store = EnergyTripHistoryStore(target)
assertEquals(listOf(2L, 1L), store.readSummaries().map { it.startedAtMs })
assertTrue(store.read().all { it.samples == null })
assertTrue(store.readSummaries().all { it.recordedDistanceKm == 1.0 })
}
@Test fun `distance availability expands v2 without breaking older records`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
val oldShape = Gson().toJson(summary(1L))
target.writeText("{\"schemaVersion\":2,\"trips\":[{\"summary\":$oldShape}]}")
val store = EnergyTripHistoryStore(target)
assertEquals(1.0, store.readSummaries().single().recordedDistanceKm!!, 0.0)
assertTrue(store.append(summary(2L).copy(distanceAvailable = false)))
assertNull(store.readSummaries().first().recordedDistanceKm)
assertTrue(target.readText().contains("\"distanceAvailable\":false"))
}
@Test fun `a v1 file is rewritten as v2 on the next append`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
target.writeText(Gson().toJson(listOf(summary(1L))))
val store = EnergyTripHistoryStore(target)
assertTrue(store.append(summary(2L), listOf(sample(0L))))
assertTrue(target.readText().contains("\"schemaVersion\":2"))
assertEquals(listOf(2L, 1L), store.readSummaries().map { it.startedAtMs })
}
@Test fun `a truncated file is quarantined, not deleted, and does not throw`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
target.writeText("{\"schemaVersion\":2,\"trips\":[{\"summ")
val store = EnergyTripHistoryStore(target)
assertEquals(emptyList<StoredTrip>(), store.read())
val quarantined = directory.listFiles()?.filter { it.name.contains("quarantine") }
assertEquals(1, quarantined?.size)
assertTrue(quarantined!!.single().readText().startsWith("{"))
}
@Test fun `a file from a newer schema is quarantined rather than misread`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
target.writeText("{\"schemaVersion\":99,\"trips\":[]}")
val store = EnergyTripHistoryStore(target)
assertEquals(emptyList<StoredTrip>(), store.read())
assertEquals(1, directory.listFiles()?.count { it.name.contains("quarantine") })
}
@Test fun `a null that was never measured comes back null, not zero`() {
val directory = tempDirectory()
val store = EnergyTripHistoryStore(File(directory, "trips.json"))
val bare = TripSample(
atMs = 10L, speedKmh = 42f, batteryPowerKw = null, socPercent = null,
outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null,
climatePowerOn = null, climateAcOn = null, climateFanLevel = null,
)
assertTrue(store.append(summary(1L), listOf(bare)))
val restored = store.read().single().samples!!.single()
assertEquals(42f, restored.speedKmh)
assertNull(restored.batteryPowerKw)
assertNull(restored.batteryTempCelsius)
assertNull(restored.climateAcOn)
assertNull(restored.climateFanLevel)
}
@Test fun `battery power evidence survives a history round trip`() {
val directory = tempDirectory()
val store = EnergyTripHistoryStore(File(directory, "trips.json"))
val evidence = BatteryPowerEvidence(
firmware = FirmwareInfo.Gen.SWI68,
conversionVersion = BatteryPowerEvidence.OUTPUT_POSITIVE_MW_V1,
)
assertTrue(store.append(summary(1L).copy(batteryPowerEvidence = evidence)))
assertEquals(evidence, store.readSummaries().single().batteryPowerEvidence)
}
@Test fun `tracks are evicted before trips are`() {
val directory = tempDirectory()
// Small enough that two full tracks cannot both fit, large enough for one and for
// every summary. One sample serialises to roughly 200 bytes.
val store = EnergyTripHistoryStore(File(directory, "trips.json"), maxBytes = 3_000)
val track = (0 until 10).map { sample(it * 5_000L) }
assertTrue(store.append(summary(1L), track))
assertTrue(store.append(summary(2L), track))
assertTrue(store.append(summary(3L), track))
val stored = store.read()
assertEquals(listOf(3L, 2L, 1L), stored.map { it.summary.startedAtMs })
// The newest trip keeps its track; the oldest lost theirs first.
assertNotNull(stored.first().samples)
assertNull(stored.last().samples)
}
@Test fun `deleting one trip preserves the other summaries and tracks`() {
val directory = tempDirectory()
val store = EnergyTripHistoryStore(File(directory, "trips.json"))
assertTrue(store.append(summary(1L), listOf(sample(1L))))
assertTrue(store.append(summary(2L), listOf(sample(2L))))
assertTrue(store.append(summary(3L), listOf(sample(3L))))
assertTrue(store.deleteTrip(2L))
val remaining = store.read()
assertEquals(listOf(3L, 1L), remaining.map { it.summary.startedAtMs })
assertEquals(listOf(3L, 1L), remaining.map { it.samples!!.single().atMs })
}
@Test fun `deleting an unknown trip leaves the file unchanged`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
val store = EnergyTripHistoryStore(target)
assertTrue(store.append(summary(1L)))
val before = target.readText()
assertTrue(!store.deleteTrip(99L))
assertEquals(before, target.readText())
}
@Test fun `clearing writes a valid empty v2 history`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
val store = EnergyTripHistoryStore(target)
assertTrue(store.append(summary(1L), listOf(sample(1L))))
assertTrue(store.clear())
assertEquals(emptyList<StoredTrip>(), store.read())
assertTrue(target.readText().contains("\"schemaVersion\":2"))
}
@Test fun `the file stays under its byte bound across a month of trips`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
val store = EnergyTripHistoryStore(target, maxBytes = 64 * 1024)
val track = (0 until 500).map { sample(it * 5_000L) }
repeat(30) { day -> assertTrue(store.append(summary(day.toLong()), track)) }
assertTrue("history grew to ${target.length()} bytes", target.length() <= 64 * 1024)
assertEquals(30, store.read().size)
}
@Test fun `the default history retains two hundred summaries newest first`() {
val directory = tempDirectory()
val store = EnergyTripHistoryStore(File(directory, "trips.json"))
repeat(201) { trip -> assertTrue(store.append(summary(trip.toLong()))) }
val starts = store.readSummaries().map { it.startedAtMs }
assertEquals(200, starts.size)
assertEquals(200L, starts.first())
assertEquals(1L, starts.last())
}
@Test fun `a four hour trip is decimated rather than truncated`() {
val track = TripSampleTrack(startIntervalMs = 5_000L, maxSamples = 64)
// Four hours at 1 Hz: 14 400 reads, of which the track would keep 2 880 at 5 s.
repeat(14_400) { second -> track.add(snapshot(at = second * 1_000L)) }
val samples = track.samples()
assertTrue("kept ${samples.size}", samples.size <= 64)
// The last hour is still represented: decimation halves everywhere, it does not cut a tail.
assertTrue(samples.last().atMs > 3 * 3_600_000L)
assertEquals(0L, samples.first().atMs)
}
@Test fun `the track keeps one sample per interval, not one per read`() {
val track = TripSampleTrack(startIntervalMs = 5_000L, maxSamples = 4_096)
repeat(60) { second -> track.add(snapshot(at = second * 1_000L)) }
// 0, 5, 10 … 55 seconds: twelve samples for sixty reads.
assertEquals(12, track.samples().size)
}
@Test fun `a recorded trip carries the track it was integrated from`() {
EnergyTripSession.stop(0L)
EnergyTripSession.start(snapshot(at = 0L))
repeat(20) { second -> EnergyTripSession.add(snapshot(at = second * 1_000L)) }
val recorded = EnergyTripSession.stop(20_000L)
assertNotNull(recorded)
assertTrue(recorded!!.samples.isNotEmpty())
assertEquals(0L, recorded.summary.startedAtMs)
}
private fun tempDirectory(): File =
Files.createTempDirectory("trip-history").toFile()
private fun summary(start: Long) = EnergyTripSummary(
startedAtMs = start, endedAtMs = start + 1_000L, durationMs = 1_000L,
distanceKm = 1.0, startSocPercent = 80f, endSocPercent = 79f,
consumedKwh = 0.2, regeneratedKwh = 0.05,
)
private fun sample(at: Long) = TripSample(
atMs = at, speedKmh = 90f, batteryPowerKw = 18f, socPercent = 70f,
outsideTempCelsius = 12f, cabinTempCelsius = 21f, batteryTempCelsius = 24f,
climatePowerOn = true, climateAcOn = false, climateFanLevel = 3,
)
private fun snapshot(at: Long) = EnergySnapshot(
timestampMs = at, firmware = FirmwareInfo.Gen.SWI68, socPercent = 70f,
rangeKm = null, speedKmh = 90f, batteryPowerKw = 18f,
outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null,
batteryEnergyKwh = null, batteryCapacityKwh = null, odometerKm = null,
chargePortConnected = null, chargingStatus = null, parked = null,
climate = ClimateSnapshot(null, null, null, null, null, null, null, null, null),
tirePressures = TirePressureSnapshot(null, null, null, null),
)
}
@@ -0,0 +1,106 @@
package com.evsuite.hardware.telemetry.model
import com.evsuite.hardware.BatteryPowerEvidence
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.telemetry.EnergyTripSummary
import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.TripSample
import com.evsuite.hardware.telemetry.UnavailableReason
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class EnergyAttributionTest {
private val evidence = BatteryPowerEvidence(
FirmwareInfo.Gen.SWI68,
BatteryPowerEvidence.OUTPUT_POSITIVE_MW_V1,
)
private val model = EnergyModel(
evidence = evidence,
rollingKwhPer100Km = 10.0,
aeroKwhPer100KmPerSpeedSquared = 0.0,
thermalKwhPer100KmPerDegree = 0.0,
residualRmseKwhPer100Km = 0.1,
sampleCount = 120,
envelope = EnergyModelEnvelope(90.0, 110.0, 5.0, 15.0),
)
@Test fun `known traction and active auxiliary load reconcile inside the band`() {
val result = ready(calculate(powerKw = 12f, climateOn = true, consumedKwh = 12.0))
val active = result.residuals.single()
assertEquals(10.0, result.modelledTraction.valueKwh, 1e-6)
assertEquals(2.0, active.estimate.valueKwh, 1e-6)
assertEquals(ResidualContext.CLIMATE_ACTIVE, active.context)
assertEquals(ResidualFinding.DISTINGUISHABLE, active.finding)
assertTrue(2.0 in active.estimate.bandLowKwh..active.estimate.bandHighKwh)
assertEquals(11.5, result.netBatteryEnergyKwh.value!!, 1e-9)
assertEquals(0.0, result.reconciliationErrorKwh, 1e-9)
}
@Test fun `residual inside model noise is not distinguishable from zero`() {
val result = ready(calculate(powerKw = 10.2f, climateOn = true, consumedKwh = 10.2))
assertEquals(
ResidualFinding.NOT_DISTINGUISHABLE_FROM_ZERO,
result.residuals.single().finding,
)
}
@Test fun `negative residual is surfaced as model error and never clamped`() {
val result = ready(calculate(powerKw = 9f, climateOn = false, consumedKwh = 9.0))
val inactive = result.residuals.single()
assertEquals(ResidualContext.CLIMATE_INACTIVE, inactive.context)
assertEquals(ResidualFinding.NEGATIVE_MODEL_ERROR, inactive.finding)
assertEquals(-1.0, inactive.estimate.valueKwh, 1e-6)
assertEquals(0.0, result.reconciliationErrorKwh, 1e-9)
}
@Test fun `missing model evidence stays unavailable`() {
val result = EnergyAttributionCalculator.calculate(trip(12f, true, 12.0), null)
assertEquals(
UnavailableReason.MODEL_NOT_TRAINED,
(result as EnergyAttributionResult.Unavailable).reason,
)
}
private fun calculate(powerKw: Float, climateOn: Boolean, consumedKwh: Double) =
EnergyAttributionCalculator.calculate(trip(powerKw, climateOn, consumedKwh), model)
private fun trip(powerKw: Float, climateOn: Boolean, consumedKwh: Double): StoredTrip {
val samples = (0..60).map { minute ->
TripSample(
atMs = minute * 60_000L,
speedKmh = 100f,
batteryPowerKw = powerKw,
socPercent = 80f,
outsideTempCelsius = 10f,
cabinTempCelsius = 20f,
batteryTempCelsius = null,
climatePowerOn = climateOn,
climateAcOn = climateOn,
climateFanLevel = if (climateOn) 4 else 0,
)
}
return StoredTrip(
summary = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = 3_600_000L,
durationMs = 3_600_000L,
distanceKm = 100.0,
startSocPercent = 80f,
endSocPercent = 60f,
consumedKwh = consumedKwh,
regeneratedKwh = 0.5,
distanceAvailable = true,
batteryPowerEvidence = evidence,
),
samples = samples,
)
}
private fun ready(result: EnergyAttributionResult): EnergyAttribution =
(result as EnergyAttributionResult.Ready).attribution
}
@@ -0,0 +1,168 @@
package com.evsuite.hardware.telemetry.model
import com.evsuite.hardware.BatteryPowerEvidence
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.telemetry.EnergyTripSummary
import com.evsuite.hardware.telemetry.Provenance
import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.TripSample
import com.evsuite.hardware.telemetry.UnavailableReason
import java.nio.file.Files
import kotlin.system.measureTimeMillis
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class EnergyModelTest {
private val evidence = BatteryPowerEvidence(
FirmwareInfo.Gen.SWI68,
BatteryPowerEvidence.OUTPUT_POSITIVE_MW_V1,
)
private val trainer = EnergyModelTrainer()
@Test fun `synthetic data recovers known coefficients`() {
val model = ready(trainer.fit(listOf(syntheticTrip()), evidence))
assertEquals(10.0, model.rollingKwhPer100Km, 1e-6)
assertEquals(0.0005, model.aeroKwhPer100KmPerSpeedSquared, 1e-9)
assertEquals(0.10, model.thermalKwhPer100KmPerDegree, 1e-6)
assertEquals(Provenance.ESTIMATED, model.predict(80.0, 10.0).provenance)
}
@Test fun `sparse data and a firmware with no power interpretation stay unavailable`() {
val sparse = syntheticTrip().copy(samples = syntheticTrip().samples?.take(10))
assertEquals(
UnavailableReason.INSUFFICIENT_SAMPLES,
(trainer.fit(listOf(sparse), evidence) as EnergyModelTrainingResult.Unavailable).reason,
)
// A generation whose battery power has neither a validated conversion nor a pack pair to
// derive one from: nothing to train on, and it says so rather than fitting a guess.
assertEquals(
UnavailableReason.UNVALIDATED_FIRMWARE,
(trainer.train(listOf(syntheticTrip()), FirmwareInfo.Gen.SWI69)
as EnergyModelTrainingResult.Unavailable).reason,
)
}
@Test fun `SWI68 trains on the pack pair rather than refusing`() {
// The car this project runs on has no validated power conversion and will not get one
// from a desk. Refusing to train left the speed comparison permanently empty; the model
// is stamped with the derived interpretation instead, so a validated one supersedes it.
val trip = storedTrip(
syntheticTrip().samples,
BatteryPowerEvidence(FirmwareInfo.Gen.SWI68, BatteryPowerEvidence.PACK_PAIR_DERIVED_V2),
)
val model = ready(trainer.train(listOf(trip), FirmwareInfo.Gen.SWI68))
assertEquals(BatteryPowerEvidence.PACK_PAIR_DERIVED_V2, model.evidence.conversionVersion)
assertEquals(Provenance.ESTIMATED, model.predict(80.0, 10.0).provenance)
}
@Test fun `prediction band covers held-out truth and extrapolation is refused`() {
val model = ready(trainer.fit(listOf(syntheticTrip()), evidence))
val prediction = model.predict(85.0, 12.0)
val truth = consumption(85.0, 12.0)
assertTrue(truth in prediction.bandLow!!..prediction.bandHigh!!)
val outside = model.predict(model.envelope.maxSpeedKmh + 1.0, 12.0)
assertEquals(Provenance.UNAVAILABLE, outside.provenance)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, outside.reason)
}
@Test fun `model persistence round trips one bounded atomic file`() {
val directory = Files.createTempDirectory("energy-model").toFile()
try {
val target = directory.resolve("model.json")
val store = EnergyModelStore(target)
val model = ready(trainer.fit(listOf(syntheticTrip()), evidence))
assertTrue(store.write(model))
assertEquals(model, store.read())
assertTrue(target.length() in 1..EnergyModelStore.MAX_MODEL_BYTES.toLong())
assertEquals(1, directory.listFiles().orEmpty().size)
target.writeBytes(ByteArray(EnergyModelStore.MAX_MODEL_BYTES + 1))
assertEquals(null, store.read())
} finally {
directory.deleteRecursively()
}
}
@Test fun `bounded month-sized fit completes inside host budget`() {
val base = syntheticTrip().samples.orEmpty()
val samples = List(EnergyModelTrainer.MAX_TRAINING_SAMPLES) { base[it % base.size] }
val trip = storedTrip(samples)
lateinit var result: EnergyModelTrainingResult
val elapsedMs = measureTimeMillis { result = trainer.fit(listOf(trip), evidence) }
assertNotNull((result as EnergyModelTrainingResult.Ready).model)
assertEquals(
EnergyModelTrainer.MAX_TRAINING_SAMPLES,
(result as EnergyModelTrainingResult.Ready).model.sampleCount,
)
assertTrue("fit took ${elapsedMs}ms", elapsedMs < HOST_TRAINING_BUDGET_MS)
}
private fun syntheticTrip(): StoredTrip {
val samples = buildList {
repeat(2) { repetition ->
for (temp in listOf(-5.0, 5.0, 15.0, 25.0, 35.0)) {
for (speed in 30..130 step 10) {
val shiftedSpeed = speed + repetition * 0.1
add(sample(shiftedSpeed, temp))
}
}
}
}
return storedTrip(samples)
}
private fun storedTrip(
samples: List<TripSample>?,
evidence: BatteryPowerEvidence = this.evidence,
) = StoredTrip(
summary = EnergyTripSummary(
startedAtMs = 1L,
endedAtMs = 2L,
durationMs = 1L,
distanceKm = 1.0,
startSocPercent = 80f,
endSocPercent = 79f,
consumedKwh = 1.0,
regeneratedKwh = 0.0,
distanceAvailable = true,
batteryPowerEvidence = evidence,
),
samples = samples,
)
private fun sample(speedKmh: Double, tempCelsius: Double): TripSample {
val powerKw = consumption(speedKmh, tempCelsius) * speedKmh / 100.0
return TripSample(
atMs = speedKmh.toLong(),
speedKmh = speedKmh.toFloat(),
batteryPowerKw = powerKw.toFloat(),
socPercent = 80f,
outsideTempCelsius = tempCelsius.toFloat(),
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
private fun consumption(speedKmh: Double, tempCelsius: Double): Double =
10.0 + 0.0005 * speedKmh * speedKmh +
0.10 * kotlin.math.abs(tempCelsius - EnergyModel.COMFORT_TEMP_CELSIUS)
private fun ready(result: EnergyModelTrainingResult): EnergyModel =
(result as EnergyModelTrainingResult.Ready).model
companion object {
private const val HOST_TRAINING_BUDGET_MS = 2_000L
}
}
@@ -0,0 +1,224 @@
package com.evsuite.hardware.telemetry.model
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.VehicleSpeedEvidence
import com.evsuite.hardware.telemetry.EnergyTripSummary
import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.TripSample
import com.evsuite.hardware.telemetry.UnavailableReason
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SocConsumptionModelTest {
private val generation = FirmwareInfo.Gen.SWI68
private val current = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
private val stale = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
/**
* A drive at one speed and one temperature, consuming [percentPer100Km] of charge.
*
* Sampled every 5 s like the real track, and long enough to cross the 2 % step several
* times, which is what produces more than one training point.
*/
private fun drive(
speedKmh: Double,
tempCelsius: Double,
percentPer100Km: Double,
distanceKm: Double = 60.0,
startSoc: Double = 90.0,
startAtMs: Long = 0L,
intervalMs: Long = 5_000L,
): List<TripSample> {
val hours = distanceKm / speedKmh
val steps = (hours * 3_600_000.0 / intervalMs).toInt()
return (0..steps).map { index ->
val travelled = distanceKm * index / steps
TripSample(
atMs = startAtMs + index * intervalMs,
speedKmh = speedKmh.toFloat(),
batteryPowerKw = null,
socPercent = (startSoc - travelled * percentPer100Km / 100.0).toFloat(),
outsideTempCelsius = tempCelsius.toFloat(),
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
}
private fun trip(
samples: List<TripSample>,
evidence: VehicleSpeedEvidence? = current,
) = StoredTrip(
summary = EnergyTripSummary(
startedAtMs = samples.first().atMs,
endedAtMs = samples.last().atMs,
durationMs = samples.last().atMs - samples.first().atMs,
distanceKm = 60.0,
startSocPercent = samples.first().socPercent,
endSocPercent = samples.last().socPercent,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
batteryPowerEvidence = null,
speedEvidence = evidence,
),
samples = samples,
)
/** Four drives, two speeds and two temperatures — the smallest history that determines a fit. */
private fun history() = listOf(
trip(drive(90.0, 20.0, 16.0, startAtMs = 0L)),
trip(drive(130.0, 20.0, 26.0, startAtMs = 100_000_000L)),
trip(drive(90.0, 0.0, 21.0, startAtMs = 200_000_000L)),
trip(drive(130.0, 0.0, 31.0, startAtMs = 300_000_000L)),
)
@Test
fun `a car that never publishes battery power still gets a consumption model`() {
val result = SocConsumptionFitter().fit(history(), generation)
assertTrue("$result", result is SocConsumptionFitResult.Ready)
val model = (result as SocConsumptionFitResult.Ready).model
val at90 = model.predict(90.0, 20.0)
val at130 = model.predict(130.0, 20.0)
assertNotNull(at90.value)
assertNotNull(at130.value)
// The whole point of the fit: going faster costs more, and by roughly what was driven.
assertTrue("130 costs more than 90", at130.value!! > at90.value!!)
assertEquals(16.0, at90.value!!, 1.5)
assertEquals(26.0, at130.value!!, 1.5)
assertNotNull("every prediction carries a band", at130.uncertainty)
}
@Test
fun `no capacity is asked for anywhere, because none is used`() {
// The unit is percent of charge. If this ever needs a pack size to answer, the
// specification sheet has crept back into the model.
val model = (SocConsumptionFitter().fit(history(), generation)
as SocConsumptionFitResult.Ready).model
assertEquals(current, model.speedEvidence)
assertTrue(model.predict(110.0, 20.0).value!! > 0.0)
}
@Test
fun `a distance recorded by a conversion no longer believed contributes nothing`() {
val result = SocConsumptionFitter().fit(
history().map { trip(it.samples!!, evidence = stale) },
generation,
)
assertEquals(
SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES),
result,
)
}
@Test
fun `one speed is not a speed dependence`() {
val result = SocConsumptionFitter().fit(
listOf(
trip(drive(90.0, 20.0, 16.0, startAtMs = 0L)),
trip(drive(90.0, 0.0, 21.0, startAtMs = 100_000_000L)),
),
generation,
)
assertEquals(
SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES),
result,
)
}
@Test
fun `one temperature fits two coefficients and refuses the cold it never saw`() {
val result = SocConsumptionFitter().fit(
listOf(
trip(drive(90.0, 20.0, 16.0, distanceKm = 120.0, startAtMs = 0L)),
trip(drive(130.0, 20.0, 26.0, distanceKm = 120.0, startAtMs = 100_000_000L)),
),
generation,
)
val model = (result as SocConsumptionFitResult.Ready).model
assertEquals(0.0, model.thermalPercentPer100KmPerDegree, 1e-12)
assertNotNull(model.predict(110.0, 20.0).value)
// -5 °C is outside everything this fit has seen, and it says so rather than guessing.
assertNull(model.predict(110.0, -5.0).value)
}
@Test
fun `a hole in the track is not driven through`() {
// Two minutes of missing samples across which the car covered kilometres nobody
// recorded. Bridged, the charge they cost would land on the kilometres either side.
val first = drive(110.0, 20.0, 20.0, distanceKm = 30.0, startAtMs = 0L)
val second = drive(
110.0, 20.0, 20.0,
distanceKm = 30.0,
startSoc = 80.0,
startAtMs = first.last().atMs + 600_000L,
)
val fitter = SocConsumptionFitter()
val segments = ArrayList<SocConsumptionFitter.Segment>()
fitter.collect(first + second, segments)
assertTrue("segments exist on both sides", segments.size >= 2)
// 20 %/100 km on both sides; a bridged segment would report a far larger number.
assertTrue(segments.all { it.percentPer100Km < 25.0 })
}
@Test
fun `charging is not driving`() {
val charging = (0..40).map { index ->
TripSample(
atMs = index * 5_000L,
speedKmh = 0f,
batteryPowerKw = null,
socPercent = (40.0 + index).toFloat(),
outsideTempCelsius = 20f,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
val segments = ArrayList<SocConsumptionFitter.Segment>()
SocConsumptionFitter().collect(charging, segments)
assertTrue(segments.isEmpty())
}
@Test
fun `describe tells the two INSUFFICIENT_SAMPLES gates apart`() {
// The reason code is the same word for both, and the two ask the driver for opposite
// things. Two validation bundles reported that word without saying which.
val fitter = SocConsumptionFitter()
// Segments enough, one speed band. The answer is "drive a motorway leg".
val oneSpeed = listOf(
trip(drive(90.0, 20.0, 16.0, startAtMs = 0L)),
trip(drive(90.0, 0.0, 21.0, startAtMs = 100_000_000L)),
)
assertEquals(
SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES),
fitter.fit(oneSpeed, generation),
)
val narrow = fitter.describe(oneSpeed, generation)
assertTrue(narrow, narrow.contains("used=2/2"))
assertTrue(narrow, narrow.contains("span=0/20 km/h"))
// Every trip skipped for a conversion no longer believed: no segments at all, and
// nothing about speed to say. The answer is "these kilometres do not count".
val staleHistory = oneSpeed.map { trip(it.samples!!, evidence = stale) }
assertEquals(
SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES),
fitter.fit(staleHistory, generation),
)
val skipped = fitter.describe(staleHistory, generation)
assertTrue(skipped, skipped.contains("used=0/2"))
assertTrue(skipped, skipped.contains("segments=0/"))
assertTrue(skipped, !skipped.contains("span="))
}
}