App 2.19.0: EVHardware updated from upstream (malys/EVHardware e4e1158)

Three-way merge from b595ccb, the commit Fatih copied, to upstream's
current state; Fatih's vendor reads (tyres, mileage, charge status,
SWI69) stay. LICENSE.md records the base for the next update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-19 09:44:00 +02:00
co-authored by Claude Opus 5.5
parent c6e6b3e88e
commit f0f4f57fee
71 changed files with 4646 additions and 191 deletions
@@ -127,13 +127,28 @@ class ReadFailureLogTest {
class NeverPublishedCatalogueTest {
@Test fun `SWI68 never publishes battery power or battery temperature`() {
CarPropertyEvidence.Signal.entries.forEach { signal ->
assertTrue(
"$signal is proven absent on SWI68",
CarPropertyEvidence.isNeverPublished(signal, FirmwareInfo.Gen.SWI68),
@Test fun `SWI68 never publishes battery temperature`() {
assertTrue(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
}
)
}
@Test fun `SWI68 battery power publishes through the vendor pack and is not validated yet`() {
assertFalse(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
assertFalse(
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
}
@Test fun `absent is not the same claim as validated`() {
@@ -141,13 +156,13 @@ class NeverPublishedCatalogueTest {
// never be validated. Both must be false-for-validated and true-for-absent at once.
assertFalse(
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
)
assertTrue(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
)
@@ -0,0 +1,61 @@
package com.evsuite.hardware
import com.evsuite.hardware.CustomDrive.Family
import com.evsuite.hardware.CustomDrive.Setting
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CustomDriveTest {
private fun codes(setting: Setting, family: Family) =
CustomDrive.INDICES.map { CustomDrive.code(setting, it, family) }
@Test fun `VSM scale matches the RI-006 read and write codes`() {
assertEquals(listOf(1, 2, 3), codes(Setting.POWER, Family.VSM))
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.VSM))
assertEquals(listOf(1, 0, 2), codes(Setting.PEDAL, Family.VSM))
}
@Test fun `pedal Normal is zero on every family`() {
Family.values().forEach { assertEquals(0, CustomDrive.code(Setting.PEDAL, 1, it)) }
}
@Test fun `A9 powertrain reuses the drive-mode scale`() {
assertEquals(listOf(2, 3, 4), codes(Setting.POWER, Family.A9))
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.A9))
}
@Test fun `every code reads back to its index`() {
Family.values().forEach { family ->
Setting.values().forEach { setting ->
CustomDrive.INDICES.forEach { i ->
assertEquals(i, CustomDrive.index(setting, CustomDrive.code(setting, i, family)!!, family))
}
}
}
}
@Test fun `out of range is refused both ways`() {
assertNull(CustomDrive.code(Setting.POWER, 3, Family.VSM))
assertNull(CustomDrive.code(Setting.PEDAL, -1, Family.A9))
assertNull(CustomDrive.index(Setting.POWER, 0, Family.VSM))
assertNull(CustomDrive.index(Setting.POWER, 1, Family.A9))
assertNull(CustomDrive.index(Setting.STEERING, -1, Family.VSM))
}
@Test fun `only VSM generations have a route`() {
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI68))
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI165))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI69))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI131))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI132))
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.SWI133))
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.UNKNOWN))
}
@Test fun `method names follow the family`() {
assertEquals("setBrakePedalLevel", CustomDrive.methodName(Setting.PEDAL, Family.VSM, write = true))
assertEquals("getDrivingPowerTrainMode", CustomDrive.methodName(Setting.POWER, Family.A9, write = false))
}
}
@@ -0,0 +1,97 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.ActionType
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.probe.VendorSurfaceRules
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
/**
* CR-046. Owner decisions pinned here, off the car: a rule never switches the low beams OFF,
* never forces the high beam ON, and touches no light on a generation nobody has proven.
*/
class ExteriorLightsTest {
private val gen = FirmwareInfo.Gen.SWI68
private val proven = setOf(gen)
@Test
fun `low beams can be set ON or AUTOMATIC, never OFF`() {
val allowed = ExteriorLights.HEADLIGHT_SWITCH_ALLOWED
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
}
@Test
fun `high beam can be set OFF or AUTOMATIC, never forced ON`() {
val allowed = ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
}
@Test
fun `daytime running is never a value a rule can write`() {
listOf(
ExteriorLights.HEADLIGHT_SWITCH_ALLOWED,
ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED,
ExteriorLights.FOG_SWITCH_ALLOWED,
).forEach { assertFalse(ExteriorLights.SWITCH_DAYTIME_RUNNING in it) }
}
@Test
fun `an unproven generation refuses every light write`() {
assertNotNull(
ExteriorLights.writeRefusal(
FirmwareInfo.Gen.SWI69, ExteriorLights.FOG_SWITCH_ALLOWED, ExteriorLights.SWITCH_ON, proven
)
)
// Until a capture proves one, no generation is proven at all.
assertTrue(ExteriorLights.PROVEN.isEmpty())
FirmwareInfo.Gen.entries.forEach { assertFalse(ExteriorLights.isProven(it)) }
}
@Test
fun `daytime running lights are not headlights on`() {
assertEquals(true, ExteriorLights.isOn(ExteriorLights.STATE_ON))
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_DAYTIME_RUNNING))
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_OFF))
assertNull(ExteriorLights.isOn(null))
}
@Test
fun `the editor cannot even express a forbidden light value`() {
val lowBeam = ActionType.SET_HEADLIGHTS.spec.options.map { it.value }.toSet()
val highBeam = ActionType.SET_HIGH_BEAM.spec.options.map { it.value }.toSet()
assertEquals(ExteriorLights.HEADLIGHT_SWITCH_ALLOWED, lowBeam)
assertEquals(ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED, highBeam)
}
@Test
fun `light actions are gated and unproven, light entries supported nowhere yet`() {
val actions = listOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
actions.forEach {
assertTrue("${it.name} must be gated", it.gated)
assertFalse("${it.name} must stay unproven until a capture", it.writeProven)
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
}
listOf(ConditionType.HEADLIGHTS, ConditionType.HIGH_BEAM_ON, ConditionType.FOG_LIGHTS_ON).forEach {
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
}
}
@Test
fun `a light property is named in the capture diff`() {
assertEquals(
"CPM 0x11400E00/0 (HEADLIGHTS_STATE)",
VendorSurfaceRules.label("CPM 0x11400E00/0"),
)
assertEquals("CPM 0x2160F404/0", VendorSurfaceRules.label("CPM 0x2160F404/0"))
assertEquals("VehicleSetting.getX", VendorSurfaceRules.label("VehicleSetting.getX"))
}
}
@@ -8,12 +8,6 @@ class PhysicalButtonEventDecoderTest {
@Test fun `every R69 hardkey code decodes`() {
val expected = mapOf(
5 to PhysicalButtonEventDecoder.Button.PHONE,
6 to PhysicalButtonEventDecoder.Button.UP,
7 to PhysicalButtonEventDecoder.Button.DOWN,
8 to PhysicalButtonEventDecoder.Button.OK,
9 to PhysicalButtonEventDecoder.Button.LEFT,
10 to PhysicalButtonEventDecoder.Button.RIGHT,
16 to PhysicalButtonEventDecoder.Button.PHONE,
23 to PhysicalButtonEventDecoder.Button.CENTER,
24 to PhysicalButtonEventDecoder.Button.VOLUME_UP,
25 to PhysicalButtonEventDecoder.Button.VOLUME_DOWN,
@@ -32,6 +26,72 @@ class PhysicalButtonEventDecoderTest {
assertEquals(button, decoder.accept(code, false, false)?.button)
}
}
@Test fun `the SystemUI broadcast adds only the cluster pad`() {
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
val expected = mapOf(
6 to PhysicalButtonEventDecoder.Button.UP,
7 to PhysicalButtonEventDecoder.Button.DOWN,
8 to PhysicalButtonEventDecoder.Button.OK,
9 to PhysicalButtonEventDecoder.Button.LEFT,
10 to PhysicalButtonEventDecoder.Button.RIGHT
)
expected.forEach { (code, button) ->
val decoder = PhysicalButtonEventDecoder()
decoder.accept(code, true, false, source = systemUi)
assertEquals(button, decoder.accept(code, false, false, source = systemUi)?.button)
}
}
@Test fun `a press on both broadcasts is one press`() {
// Next track: 87 on the report, 5 on SystemUI. Read as one space, SystemUI's 5 was a
// phone press; and the phone itself (5 and 16) arrived twice, i.e. as a double.
val decoder = PhysicalButtonEventDecoder()
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
val events = listOfNotNull(
decoder.accept(87, true, false, atMillis = 0),
decoder.accept(5, true, false, atMillis = 0, source = systemUi),
decoder.accept(87, false, false, atMillis = 50),
decoder.accept(5, false, false, atMillis = 50, source = systemUi),
decoder.accept(5, true, false, atMillis = 1_000),
decoder.accept(16, true, false, atMillis = 1_000, source = systemUi),
decoder.accept(5, false, false, atMillis = 1_050),
decoder.accept(16, false, false, atMillis = 1_050, source = systemUi)
)
assertEquals(listOf("MEDIA_NEXT:SHORT", "PHONE:SHORT"), events.map { it.value })
}
@Test fun `an unlisted key is reported with an id of its own`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(3, true, false)
val event = decoder.accept(3, false, false)!!
assertNull(event.button)
assertEquals(
PhysicalButtonEventDecoder.Source.HARDKEY_REPORT to 3,
PhysicalButtonEventDecoder.unknownKey(event.keyId)
)
// Same code, other broadcast, other key.
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
decoder.accept(30, true, false, source = systemUi)
val other = decoder.accept(30, false, false, source = systemUi)!!
assertEquals(PhysicalButtonEventDecoder.Source.SYSTEM_UI to 30, PhysicalButtonEventDecoder.unknownKey(other.keyId))
assertEquals("KEY_${event.keyId}:SHORT", event.value)
}
@Test fun `stored button ids are unchanged`() {
// Rules on disk hold these numbers; changing one silently rebinds a rule.
val ids = PhysicalButtonEventDecoder.Button.entries.associate { it.name to it.id }
assertEquals(
mapOf(
"PHONE" to 5, "UP" to 6, "DOWN" to 7, "OK" to 8, "LEFT" to 9, "RIGHT" to 10,
"SOURCE" to 110, "CENTER" to 23, "VOLUME_UP" to 24, "VOLUME_DOWN" to 25,
"MEDIA_NEXT" to 87, "MEDIA_PREVIOUS" to 88, "MUTE" to 164, "STAR_LEFT" to 17,
"STAR_RIGHT" to 286, "ASSISTANT" to 287
),
ids
)
}
@Test fun `two releases inside the window make one double`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(17, true, false, atMillis = 0)
@@ -108,7 +108,8 @@ class CatalogConsistencyTest {
ActionType.SET_ELK_MODE, ActionType.SET_ELK_SENSITIVITY,
ActionType.SET_ACC_TJA_MODE, ActionType.SET_LIMITER_MODE,
ActionType.SET_TSR, ActionType.SET_OVERSPEED_ALARM,
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING,
ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS
)
mustBeGated.forEach { type ->
@@ -325,7 +326,15 @@ class CatalogConsistencyTest {
}
// Exactly these: an unproven write is invisible to the user, so adding one elsewhere
// silently removes an action from every app that carries this catalogue.
assertEquals(glass.toSet(), ActionType.entries.filter { !it.writeProven }.toSet())
// CR-046: the exterior-light actions are unproven too, on purpose, until a capture
// proves a generation (see ExteriorLightsTest).
val lights = setOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
// CP-088: the charge limit reads back as a code on SWI68 while the write sends a percent.
val chargeLimit = setOf(ActionType.SET_CHARGE_LIMIT)
assertEquals(
glass.toSet() + lights + chargeLimit,
ActionType.entries.filter { !it.writeProven }.toSet()
)
}
@Test
@@ -0,0 +1,46 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonParser
import java.util.Base64
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 DiagnosticInboxTest {
private val artifact = DiagnosticInbox.Artifact(
app = "EVTasker", version = "2.17.0", name = "evtasker-diagnostic.txt",
createdAtMs = 1_792_323_000_000L, content = "report ✓".toByteArray(),
)
@Test fun `only a plain https address is an inbox`() {
assertNull(DiagnosticInbox.refuse("https://nodered.example/evsuite"))
assertNotNull(DiagnosticInbox.refuse("http://192.0.2.10:1880/evsuite"))
assertNotNull(DiagnosticInbox.refuse("https://user:pw@host/evsuite"))
assertNotNull(DiagnosticInbox.refuse("https://host/evsuite?token=1"))
assertNotNull(DiagnosticInbox.refuse("evsuite"))
}
@Test fun `the body carries the artifact byte for byte`() {
var sent: String? = null
val outcome = DiagnosticInbox.send("https://host/evsuite", artifact) { _, body -> sent = body; 200 }
assertEquals(DiagnosticInbox.Outcome.Sent(200), outcome)
val json = JsonParser.parseString(sent).asJsonObject
assertEquals(1, json["evsuite"].asInt)
assertEquals("EVTasker", json["app"].asString)
assertEquals("report ✓", String(Base64.getDecoder().decode(json["contentBase64"].asString)))
}
@Test fun `a refused address never reaches the network, a server error is a failure`() {
var called = false
val refused = DiagnosticInbox.send("http://host/evsuite", artifact) { _, _ -> called = true; 200 }
assertTrue(refused is DiagnosticInbox.Outcome.Failed && !called)
assertEquals(
DiagnosticInbox.Outcome.Failed("HTTP 500"),
DiagnosticInbox.send("https://host/evsuite", artifact) { _, _ -> 500 },
)
}
}
@@ -91,4 +91,25 @@ class RuntimeInterfaceProbeTest {
val calls = RuntimeInterfaceProbe.CONFIG_CALLS
assertEquals(calls.size, calls.values.map { it.code }.toSet().size)
}
@Test
fun `the component map keeps the manifest's words and says when it stopped listing`() {
val rows = (0 until RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 3).map {
RuntimeInterfaceProbe.ServiceRow("com.saicmotor.x.Service$it", it == 0, null)
}
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.x", rows)
assertEquals("componentMap:com.saicmotor.x", entry.name)
assertEquals(BindVerdict.ANSWERED, entry.verdict)
assertEquals((rows.size).toString(), entry.values["services"])
assertEquals("exported=true permission=null", entry.values["com.saicmotor.x.Service0"])
assertEquals("3", entry.values["truncated"])
assertEquals(RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 2, entry.values.size)
}
@Test
fun `a package that declares no service was still read`() {
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.y", emptyList())
assertEquals(BindVerdict.ANSWERED, entry.verdict)
assertEquals(mapOf("services" to "0"), entry.values)
}
}
@@ -0,0 +1,106 @@
package com.evsuite.hardware.probe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The before/after capture and the SOC survey call getters by reflection on vendor objects, in a
* car with a driver in it. The one thing that must never happen is a call that changes something,
* so the selection is pinned here, off the car, where a wrong answer is a failing test rather
* than a setting that moved.
*/
class VendorSurfaceRulesTest {
@Suppress("unused")
class FakeVendorClient {
fun getAutoHighBeamSetting(): Int = 1
fun isAutoHighBeamEnabled(): Boolean = true
fun getBatterySoc(): Float = 63f
fun hasRainSensor(): Boolean = false
fun getVersionName(): String = "x"
fun getStatusBoxed(): Int? = 2
fun setAutoHighBeamSetting(value: Int) = Unit
fun getAndResetCounter(): Int = 0
fun getInstance(): FakeVendorClient = this
fun getService(): Any = Any()
fun getListenerCount(): Int = 0
fun getLevel(area: Int): Int = area
fun requestState(): Int = 0
fun getter(): Int = 0
fun gettable(): Int = 0
fun getValues(): IntArray = intArrayOf()
fun startCharging(): Boolean = true
companion object {
@JvmStatic fun getStaticValue(): Int = 0
}
}
private val names = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java).map { it.name }
@Test
fun `value getters are read, including Setting and Enabled names`() {
assertEquals(
listOf(
"getAutoHighBeamSetting", "getBatterySoc", "getStatusBoxed", "getVersionName",
"hasRainSensor", "isAutoHighBeamEnabled",
),
names,
)
}
@Test
fun `nothing that acts, takes an argument or returns an object is called`() {
listOf(
"setAutoHighBeamSetting", "getAndResetCounter", "getInstance", "getService",
"getListenerCount", "getLevel", "requestState", "getter", "gettable", "getValues",
"startCharging", "getStaticValue", "getClass", "hashCode", "toString",
).forEach { assertFalse(it, it in names) }
}
@Test
fun `keywords narrow the survey, case-insensitively`() {
val soc = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java, listOf("SOC", "battery"))
assertEquals(listOf("getBatterySoc"), soc.map { it.name })
}
@Test
fun `camel-case words split on case and underscore`() {
assertEquals(listOf("and", "reset", "counter"), VendorSurfaceRules.words("AndResetCounter"))
assertEquals(listOf("high", "beam", "setting"), VendorSurfaceRules.words("High_BeamSetting"))
}
@Test
fun `the diff lists changed, appeared and vanished values, and nothing else`() {
val before = mapOf("a" to "1", "b" to "2", "gone" to "x")
val after = mapOf("a" to "1", "b" to "3", "new" to "y")
assertEquals(
listOf(
VendorSurfaceRules.Change("b", "2", "3"),
VendorSurfaceRules.Change("gone", "x", null),
VendorSurfaceRules.Change("new", null, "y"),
),
VendorSurfaceRules.diff(before, after),
)
assertTrue(VendorSurfaceRules.diff(before, before).isEmpty())
}
@Test
fun `an empty diff says why it may be empty`() {
val text = VendorSurfaceRules.formatDiff(emptyList(), 10, 10)
assertTrue(text.contains("0 changed"))
assertTrue(text.contains("nothing changed"))
val one = VendorSurfaceRules.formatDiff(listOf(VendorSurfaceRules.Change("k", null, "1")), 1, 2)
assertTrue(one.contains("k: — → 1"))
}
@Test
fun `arrays print their content`() {
assertEquals("[1, 2]", VendorSurfaceRules.render(intArrayOf(1, 2)))
assertEquals("[0.5]", VendorSurfaceRules.render(floatArrayOf(0.5f)))
assertEquals("null", VendorSurfaceRules.render(null))
}
}
@@ -0,0 +1,25 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The charge limit read refuses what the car could never have been set to. */
class SaicChargeLimitTest {
@Test fun `the code SWI68 answers is not a percent`() {
assertNull(SaicCharging.chargeLimitOrNull(5))
}
@Test fun `the range the car accepts reads through, ends included`() {
assertEquals(40, SaicCharging.chargeLimitOrNull(40))
assertEquals(80, SaicCharging.chargeLimitOrNull(80))
assertEquals(100, SaicCharging.chargeLimitOrNull(100))
}
@Test fun `outside it, and unread, is unknown`() {
assertNull(SaicCharging.chargeLimitOrNull(39))
assertNull(SaicCharging.chargeLimitOrNull(101))
assertNull(SaicCharging.chargeLimitOrNull(null))
}
}
@@ -0,0 +1,84 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class BatteryDigestTest {
private fun day(
day: String,
health: Double? = 94.0,
band: Double? = 3.0,
calibration: CalibrationVerdict? = CalibrationVerdict.SETTLED,
) = BatteryDigest(
day = day,
healthPercent = health,
healthBandPercent = band,
healthWindows = if (health == null) 0 else 5,
calibration = calibration,
daysSinceFullCharge = 4.0,
hoursAboveHighSoc = 10.0,
equivalentFullCycles = 12.0,
chargeCount = 7,
)
@Test
fun `movement inside the band is not a change`() {
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 92.0)).isEmpty())
}
@Test
fun `movement past the band is one change`() {
val change = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 90.0)).single()
assertEquals(DigestChange.HealthMoved(94.0, 90.0, 3.0), change)
}
@Test
fun `a band that narrows by a point is said, less is rounding`() {
val narrowed = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.0))
assertEquals(listOf(DigestChange.HealthBandNarrowed(3.0, 2.0, 94.0)), narrowed)
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.5)).isEmpty())
}
@Test
fun `the first estimate is news, and the first day has nothing to compare`() {
val first = BatteryDigest.changes(day("2026-09-28", health = null, band = null), day("2026-09-29"))
assertEquals(listOf(DigestChange.HealthBandNarrowed(null, 3.0, 94.0)), first)
assertTrue(BatteryDigest.changes(null, day("2026-09-29")).isEmpty())
}
@Test
fun `a calibration verdict that changes is a change`() {
val change = BatteryDigest.changes(
day("2026-09-28"),
day("2026-09-29", calibration = CalibrationVerdict.WATCH),
).single() as DigestChange.CalibrationChanged
assertEquals(CalibrationVerdict.WATCH, change.to)
}
@Test
fun `the store keeps one record a day, a bounded year, and returns the day before`() {
val dir = Files.createTempDirectory("digest").toFile()
val store = BatteryDigestStore(File(dir, BatteryDigestStore.FILE_NAME), maxDays = 2)
assertNull(store.upsert(day("2026-09-27")))
assertEquals("2026-09-27", store.upsert(day("2026-09-28"))?.day)
assertEquals("2026-09-27", store.upsert(day("2026-09-28", health = 80.0))?.day)
store.upsert(day("2026-09-29"))
assertEquals(listOf("2026-09-28", "2026-09-29"), store.read().map { it.day })
assertEquals(80.0, store.read().first().healthPercent!!, 1e-9)
dir.deleteRecursively()
}
@Test
fun `an unreadable file is set aside, not trusted`() {
val dir = Files.createTempDirectory("digest").toFile()
val file = File(dir, BatteryDigestStore.FILE_NAME).apply { writeText("{not json") }
assertTrue(BatteryDigestStore(file).read().isEmpty())
assertTrue(dir.listFiles()!!.any { it.name.contains("quarantine") })
dir.deleteRecursively()
}
}
@@ -72,6 +72,16 @@ class BatteryLedgerTest {
assertTrue(recorder.observe(snapshot(atMs = 1_000L, soc = 50f, chargingStatus = 1)))
}
@Test fun `a reading taken on the factory clock earns nothing`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
val factoryClock = 1_546_214_400_000L - NOW_MS // 2018-12-31, as the woken head unit reads
assertFalse(recorder.observe(snapshot(atMs = factoryClock, soc = 80f)))
assertTrue(recorder.observe(snapshot(atMs = 0L, soc = 80f)))
assertEquals(listOf(NOW_MS), store.read().map { it.atMs })
}
@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)
@@ -134,7 +144,7 @@ class BatteryLedgerTest {
consumedKwh: Float? = null,
regeneratedKwh: Float? = null,
) = EnergySnapshot(
timestampMs = atMs,
timestampMs = NOW_MS + atMs,
firmware = FirmwareInfo.Gen.SWI68,
socPercent = soc,
rangeKm = null,
@@ -156,4 +166,9 @@ class BatteryLedgerTest {
)
private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile()
private companion object {
/** 2026-10-11, past the recorder's clock floor. */
const val NOW_MS = 1_791_728_806_645L
}
}
@@ -0,0 +1,95 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeDurationTest {
/** A watched charge from [from] to [to] %, one step per point, [minutesOf] minutes for each. */
private fun charge(from: Int, to: Int, outside: Double? = 15.0, minutesOf: (Int) -> Double) = ChargeEnergy(
session = BatteryChargeSession(0L, 1L, from.toDouble(), to.toDouble(), 0.0, 0.0, outside),
watched = true,
maxEntryGapMs = 0L,
packDeltaKwh = null,
meanPowerKw = null,
counterBehaviour = ChargeCounterBehaviour.RESET,
consumedDeltaKwh = null,
regeneratedDeltaKwh = null,
movedKm = 0.0,
chargingStatuses = emptyList(),
steps = (from until to).map {
ChargePowerStep(it.toDouble(), it + 1.0, (minutesOf(it) * 60_000).toLong(), 7.0)
},
minOutsideTempCelsius = outside,
maxOutsideTempCelsius = outside,
)
/** Three minutes a point below 80 %, six above: the taper. */
private val tapered = { soc: Int -> if (soc < 80) 3.0 else 6.0 }
@Test
fun `duration sums the bands on the path, and the last stretch is its own figure`() {
val history = listOf(charge(20, 100, minutesOf = tapered))
val estimate = (ChargeDuration.estimate(history, 50.0, 100, null) as ChargeDurationResult.Ready).estimate
assertEquals(30 * 3.0 + 20 * 6.0, estimate.minutes, 1e-6)
assertEquals(120.0, estimate.lastStretchMinutes!!, 1e-6)
assertFalse(estimate.similarTemperature)
}
@Test
fun `the range is the fastest and the slowest past charge`() {
val history = listOf(
charge(40, 80) { 2.0 },
charge(40, 80) { 3.0 },
charge(40, 80) { 5.0 },
)
val estimate = (ChargeDuration.estimate(history, 60.0, 80, null) as ChargeDurationResult.Ready).estimate
assertEquals(60.0, estimate.minutes, 1e-6)
assertEquals(40.0, estimate.lowMinutes, 1e-6)
assertEquals(100.0, estimate.highMinutes, 1e-6)
assertNull(estimate.lastStretchMinutes)
}
@Test
fun `a band nobody watched is refused, never extrapolated`() {
val refused = ChargeDuration.estimate(listOf(charge(20, 80, minutesOf = tapered)), 50.0, 100, null)
assertEquals(ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, 80), refused)
assertEquals(
ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE),
ChargeDuration.estimate(emptyList(), 50.0, 100, null),
)
assertEquals(
ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET),
ChargeDuration.estimate(emptyList(), 80.0, 80, null),
)
}
@Test
fun `a similar outside temperature is preferred, and all charges are the fallback`() {
val history = listOf(
charge(40, 80, outside = 0.0) { 5.0 },
charge(40, 80, outside = 20.0) { 3.0 },
)
val cold = (ChargeDuration.estimate(history, 60.0, 80, 2.0) as ChargeDurationResult.Ready).estimate
assertTrue(cold.similarTemperature)
assertEquals(100.0, cold.minutes, 1e-6)
val hot = (ChargeDuration.estimate(history, 60.0, 80, 35.0) as ChargeDurationResult.Ready).estimate
assertFalse(hot.similarTemperature)
assertEquals(20 * 5.0, hot.minutes, 1e-6) // the median of two is the upper one
}
@Test
fun `the curve check needs three past charges and flags only slower than all of them`() {
val two = listOf(charge(80, 90) { 6.0 }, charge(80, 90) { 7.0 })
assertNull(ChargeDuration.curveCheck(two, 80, 20.0))
val three = two + charge(80, 90) { 8.0 }
assertTrue(ChargeDuration.curveCheck(three, 80, 9.0)!!.slower)
val usual = ChargeDuration.curveCheck(three, 80, 7.5)!!
assertFalse(usual.slower)
assertEquals(6.0, usual.usualLowMinutesPerPoint, 1e-9)
assertEquals(3, usual.pastCharges)
}
}
@@ -0,0 +1,237 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeEnergyTest {
@Test fun `a watched charge carries its power and the sign the integral moved`() {
// Three quarters of an hour at sixteen points an hour, with the pack pair integral
// falling by 8,25 kWh: the declared convention — positive leaves the battery — holding
// across a charge, and 11 kW of it.
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
entry(15 * MINUTE, 44f, pack = 97.25, odometer = 10_000f),
entry(30 * MINUTE, 48f, pack = 94.5, odometer = 10_000f),
entry(45 * MINUTE, 52f, pack = 91.75, odometer = 10_000f),
)
val report = analyse(entries)
assertEquals(1, report.charges.size)
val charge = report.charges.single()
assertTrue(charge.watched)
assertTrue(charge.plugged)
assertEquals(-8.25, charge.packDeltaKwh!!, 1e-6)
assertEquals(11.0, charge.meanPowerKw!!, 1e-6)
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
assertEquals(charge, report.lastPluggedCharge)
}
@Test fun `an integral that rises during a charge is reported as the other convention`() {
val entries = listOf(
entry(0L, 40f, pack = 10.0),
entry(15 * MINUTE, 45f, pack = 17.0),
entry(30 * MINUTE, 50f, pack = 24.0),
)
assertEquals(ChargePackSign.ENERGY_ROSE, analyse(entries).packSign)
}
@Test fun `two charges that disagree settle nothing`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0),
entry(15 * MINUTE, 45f, pack = 96.5),
entry(30 * MINUTE, 50f, pack = 93.0),
// A drive in between, so what follows is a second session rather than the same one.
entry(45 * MINUTE, 30f, pack = 99.0),
entry(60 * MINUTE, 35f, pack = 102.5),
entry(75 * MINUTE, 40f, pack = 106.0),
)
assertEquals(ChargePackSign.CONTRADICTORY, analyse(entries).packSign)
}
@Test fun `a charge the app slept through carries no power and no sign`() {
// The recorder earns an entry every quarter of an hour while it is running, so a
// six-hour silence is the head unit having been off — and the integral did not move
// during it, which would otherwise read as a charge that took no energy at all.
val entries = listOf(
entry(0L, 30f, pack = 100.0),
entry(6 * HOUR, 90f, pack = 100.0),
)
val report = analyse(entries)
val charge = report.charges.single()
assertFalse(charge.watched)
assertNull(charge.meanPowerKw)
assertEquals(ChargePackSign.UNREADABLE, charge.sign)
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
}
@Test fun `a counter that goes backwards across a charge is the car resetting it`() {
val entries = listOf(
entry(0L, 40f, consumed = 12f, regenerated = 3f),
entry(15 * MINUTE, 55f, consumed = 12f, regenerated = 3f),
entry(30 * MINUTE, 70f, consumed = 0f, regenerated = 0f),
)
val report = analyse(entries)
assertEquals(ChargeCounterBehaviour.RESET, report.counterBehaviour)
assertEquals(-12.0, report.charges.single().consumedDeltaKwh!!, 1e-6)
}
@Test fun `counters read at both ends and unchanged are static, never absent`() {
val entries = listOf(
entry(0L, 40f, consumed = 12f, regenerated = 3f),
entry(20 * MINUTE, 70f, consumed = 12f, regenerated = 3f),
)
assertEquals(ChargeCounterBehaviour.STATIC, analyse(entries).counterBehaviour)
}
@Test fun `a car with no counters at all says absent rather than static`() {
val entries = listOf(entry(0L, 40f), entry(20 * MINUTE, 70f))
assertEquals(ChargeCounterBehaviour.ABSENT, analyse(entries).counterBehaviour)
}
@Test fun `a reset seen once outranks a charge that ended before the reset`() {
val entries = listOf(
entry(0L, 40f, consumed = 9f),
entry(15 * MINUTE, 60f, consumed = 9f),
entry(30 * MINUTE, 30f, consumed = 14f),
entry(45 * MINUTE, 70f, consumed = 0f),
)
assertEquals(ChargeCounterBehaviour.RESET, analyse(entries).counterBehaviour)
}
@Test fun `a rise while the odometer moves is regeneration, never the last charge`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
entry(20 * MINUTE, 48f, pack = 96.0, odometer = 10_020f),
)
val report = analyse(entries)
val charge = report.charges.single()
assertEquals(20.0, charge.movedKm!!, 1e-6)
assertFalse(charge.plugged)
assertNull(report.lastPluggedCharge)
// The sign is still evidence: energy entering the pack is energy entering the pack.
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
}
@Test fun `a night the head unit slept through is a charge even when the drive home was not recorded`() {
// The reported case: plugged in overnight, battery page empty the next morning. The last
// entry before sleep was written before the unrecorded drive home, so the odometer moved
// between the two ends and the old rule read fifty points as a descent.
val entries = listOf(
entry(0L, 38f, pack = 100.0, odometer = 10_000f, temp = 12f),
entry(11 * HOUR, 88f, pack = 100.0, odometer = 10_014f, temp = 7f),
)
val charge = analyse(entries).lastPluggedCharge!!
assertFalse(charge.watched)
assertTrue(charge.plugged)
assertNull(charge.meanPowerKw)
assertTrue(charge.steps.isEmpty())
assertEquals(7.0, charge.minOutsideTempCelsius!!, 1e-6)
assertEquals(12.0, charge.maxOutsideTempCelsius!!, 1e-6)
}
@Test fun `the car saying it charges is enough without an odometer`() {
val entries = listOf(
entry(0L, 40f, status = 1),
entry(10 * MINUTE, 43f, status = 1),
)
assertTrue(analyse(entries).charges.single().plugged)
}
@Test fun `a small rise across a gap while the car moved stays unclaimed`() {
val entries = listOf(
entry(0L, 40f, odometer = 10_000f),
entry(3 * HOUR, 46f, odometer = 10_030f),
)
assertNull(analyse(entries).lastPluggedCharge)
}
@Test fun `a watched charge carries its curve, its peak and its taper by band`() {
// 7 kW up to 80 %, then the pack takes 3,5 kW above it.
val entries = listOf(
entry(0L, 77f, pack = 100.0, odometer = 10_000f),
entry(6 * MINUTE, 78f, pack = 99.3, odometer = 10_000f),
entry(12 * MINUTE, 79f, pack = 98.6, odometer = 10_000f),
entry(18 * MINUTE, 80f, pack = 97.9, odometer = 10_000f),
entry(30 * MINUTE, 81f, pack = 97.2, odometer = 10_000f),
)
val report = analyse(entries)
val charge = report.charges.single()
assertEquals(4, charge.steps.size)
assertEquals(7.0, charge.peakPowerKw!!, 1e-6)
val bands = report.powerBySocBand()
assertEquals(listOf(70, 80), bands.map { it.fromPercent })
assertEquals(7.0, bands[0].meanPowerKw, 1e-6)
assertEquals(3.5, bands[1].meanPowerKw, 1e-6)
assertTrue(report.describe().any { it.startsWith("soc_band 80-90 mean_power_kw=3.50") })
}
@Test fun `the description names every question and stays one line per charge`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f, status = 2),
entry(20 * MINUTE, 55f, pack = 86.0, odometer = 10_000f, status = 2),
)
val lines = analyse(entries).describe()
assertTrue(lines.any { it == "pack_pair_sign=ENERGY_FELL" })
assertTrue(lines.any { it == "vehicle_counters=ABSENT" })
assertTrue(lines.any { it == "charging_status_values=2" })
assertEquals(1, lines.count { it.startsWith("charge ") })
}
@Test fun `an empty ledger describes itself without inventing a verdict`() {
val report = ChargeEnergyAnalyzer().analyse(emptyList(), emptyList())
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
assertEquals(ChargeCounterBehaviour.ABSENT, report.counterBehaviour)
assertEquals(listOf("charges=0"), report.describe())
}
/** The sessions are the exposure report's, never re-segmented — the analyzer's own rule. */
private fun analyse(entries: List<BatteryLedgerEntry>): ChargeEnergyReport {
val sessions = BatteryExposure().analyse(entries)?.sessions.orEmpty()
return ChargeEnergyAnalyzer().analyse(entries, sessions)
}
private fun entry(
atMs: Long,
soc: Float,
pack: Double? = null,
consumed: Float? = null,
regenerated: Float? = null,
odometer: Float? = null,
status: Int? = null,
temp: Float? = null,
) = BatteryLedgerEntry(
atMs = atMs,
socPercent = soc,
odometerKm = odometer,
vehicleConsumedKwh = consumed,
vehicleRegeneratedKwh = regenerated,
packEnergyKwh = pack,
chargingStatus = status,
outsideTempCelsius = temp,
)
private companion object {
const val MINUTE = 60_000L
const val HOUR = 3_600_000L
}
}
@@ -0,0 +1,72 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DrivingStressTest {
/**
* Ten minutes at one sample every ten seconds, the charge falling two points. Minutes 0, 3
* and 6 pull 60 kW, minute 9 regenerates 10 kW, the rest cruise at 20 kW.
*/
private fun track(
soc: Float?,
outside: Float?,
sign: Float = 1f,
power: (Int) -> Float? = { i ->
when (i / 6) {
0, 3, 6 -> 60f
9 -> -10f
else -> 20f
}
},
) = (0 until 60).map { i ->
TripSample(
atMs = i * 10_000L,
speedKmh = 50f,
batteryPowerKw = power(i)?.times(sign),
socPercent = soc?.minus(i / 30f),
outsideTempCelsius = outside,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
@Test
fun `hard power in the cold is counted, and cold regeneration summed`() {
val stress = DrivingStress.of(track(soc = 60f, outside = -5f))!!
assertEquals(3.0, stress.stressedMinutes, 1e-9)
assertEquals(50 * 10.0 / 3600.0, stress.coldRegeneratedKwh!!, 1e-9)
}
@Test
fun `the trip orients its own power sign`() {
assertEquals(DrivingStress.of(track(soc = 60f, outside = -5f)), DrivingStress.of(track(60f, -5f, sign = -1f)))
}
@Test
fun `a warm drive at a good charge is a measured zero`() {
assertEquals(DrivingStress(0.0, 0.0), DrivingStress.of(track(soc = 60f, outside = 15f)))
}
@Test
fun `low charge alone is enough, and an unread temperature leaves the regeneration unknown`() {
val stress = DrivingStress.of(track(soc = 19f, outside = null))!!
assertEquals(3.0, stress.stressedMinutes, 1e-9)
assertNull(stress.coldRegeneratedKwh)
}
@Test
fun `unknown is never zero`() {
// Hard moments at a good charge with no temperature: they cannot be decided.
assertNull(DrivingStress.of(track(soc = 60f, outside = null)))
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { null }))
assertNull(DrivingStress.of(track(soc = null, outside = -5f)))
// A third of the power unread.
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { i -> if (i % 3 == 0) null else 20f }))
}
}
@@ -0,0 +1,289 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.VehicleSpeedEvidence
import com.evsuite.hardware.telemetry.model.EnergyModelEnvelope
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
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 EcoDrivingTest {
private val generation = FirmwareInfo.Gen.SWI68
private val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
/** Predicts 18.0 % per 100 km at 100 km/h and 20 °C, with a band of 0.98. */
private val model = SocConsumptionModel(
speedEvidence = evidence,
rollingPercentPer100Km = 10.0,
aeroPercentPer100KmPerSpeedSquared = 0.0008,
thermalPercentPer100KmPerDegree = 0.2,
residualRmsePercentPer100Km = 0.5,
segmentCount = SocConsumptionFitter.MIN_SEGMENTS,
envelope = EnergyModelEnvelope(20.0, 160.0, -10.0, 35.0),
)
/** 50 km in half an hour, so the mean speed the model is asked about is 100 km/h. */
private fun trip(
startSoc: Float? = 90f,
endSoc: Float? = 81f,
distanceKm: Double = 50.0,
durationMs: Long = 1_800_000L,
speedEvidence: VehicleSpeedEvidence? = evidence,
) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = durationMs,
durationMs = durationMs,
distanceKm = distanceKm,
startSocPercent = startSoc,
endSocPercent = endSoc,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
speedEvidence = speedEvidence,
)
private fun verdict(
monitor: EcoDrivingMonitor = EcoDrivingMonitor(),
trip: EnergyTripSummary? = trip(),
model: SocConsumptionModel? = this.model,
tempCelsius: Float? = 20f,
) = monitor.verdict(model, trip, tempCelsius, generation)
/** A steady cruise, one sample a second, from which no lever can fire. */
private fun cruise(speedKmh: Float, seconds: Int, fromMs: Long = 0L): EcoDrivingMonitor {
val monitor = EcoDrivingMonitor()
(0..seconds).forEach { monitor.add(fromMs + it * 1_000L, speedKmh) }
return monitor
}
@Test
fun `a drive below the expectation is better than it`() {
// 7 % over 50 km is 14 per 100 km, below the 18.0 the fit expects by more than its band.
val result = verdict(trip = trip(endSoc = 83f))
assertEquals(EcoBand.BETTER, result.band.value)
assertEquals(14.0, result.observedPercentPer100Km!!, 1e-9)
assertEquals(18.0, result.expectedPercentPer100Km!!, 1e-9)
assertTrue(result.deltaPercent!! < 0.0)
assertEquals(100.0, result.meanSpeedKmh!!, 1e-9)
}
@Test
fun `a drive inside the fit's own band is typical`() {
assertEquals(EcoBand.TYPICAL, verdict().band.value)
}
@Test
fun `a drive above the expectation is worse than it`() {
val result = verdict(trip = trip(endSoc = 78f))
assertEquals(EcoBand.WORSE, result.band.value)
assertTrue(result.deltaPercent!! > 0.0)
}
@Test
fun `the band widens with the fit's residual rather than a threshold of our own`() {
// The same 24 per 100 km that is WORSE against a tight fit is TYPICAL against a loose
// one: a model that knows little must not be allowed to accuse the driver.
val loose = model.copy(residualRmsePercentPer100Km = 7.0)
assertEquals(EcoBand.TYPICAL, verdict(trip = trip(endSoc = 78f), model = loose).band.value)
}
@Test
fun `a sixth over the expectation is worse, not typical, against a two-point residual`() {
// 21 per 100 km against 18: the 95 % band (±3.9) called this TYPICAL, one residual does not.
val fit = model.copy(residualRmsePercentPer100Km = 2.0)
assertEquals(EcoBand.WORSE, verdict(trip = trip(endSoc = 79.5f), model = fit).band.value)
}
@Test
fun `no model is a refusal, not a verdict`() {
val result = verdict(model = null)
assertNull(result.band.value)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
}
@Test
fun `a speed outside the fitted envelope is the model's refusal, carried through`() {
// 200 km/h over the 160 the envelope saw.
val result = verdict(trip = trip(distanceKm = 100.0))
assertNull(result.band.value)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
assertEquals(200.0, result.meanSpeedKmh!!, 1e-9)
}
@Test
fun `a distance too short to divide by is refused`() {
val result = verdict(trip = trip(distanceKm = 1.0, durationMs = 36_000L))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `a charge drop below the gauge's own step is refused`() {
val result = verdict(trip = trip(endSoc = 89f))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `a distance from the superseded speed conversion is refused`() {
val stale = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
val result = verdict(trip = trip(speedEvidence = stale))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `no outside temperature is a missing signal, not a comfortable assumption`() {
val result = verdict(tempCelsius = null)
assertEquals(UnavailableReason.SIGNAL_ABSENT, result.band.reason)
}
@Test
fun `the cruise lever quotes the fit rather than a rule of thumb`() {
val advice = verdict(monitor = cruise(110f, 120)).advice
assertEquals(EcoLever.CRUISE_SPEED, advice?.lever)
assertEquals(110.0, advice!!.fromSpeedKmh!!, 1e-9)
assertEquals(100.0, advice.toSpeedKmh!!, 1e-9)
// predict(110) is 19.68 and predict(100) is 18.0: 8.5 % less.
assertEquals(8.54, advice.savingPercent!!, 0.01)
assertNull(advice.harshSharePercent)
}
@Test
fun `a town speed leaves the cruise lever alone`() {
assertNull(verdict(monitor = cruise(50f, 120)).advice)
}
@Test
fun `the steadiness lever fires from the speed channel alone`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 0f
// Five stop-and-go cycles: 6 s of hard acceleration, then 18 s of cruising.
repeat(5) {
repeat(6) { speed += 10f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(18) { monitor.add(atMs, speed); atMs += 1_000L }
speed = 0f
}
val advice = verdict(monitor = monitor, model = null).advice
assertEquals(EcoLever.STEADINESS, advice?.lever)
assertTrue(advice!!.harshSharePercent!! >= EcoDrivingMonitor.MIN_HARSH_SHARE_PERCENT)
assertNull(advice.savingPercent)
}
@Test
fun `a calm drive is measured, not waiting`() {
val monitor = EcoDrivingMonitor()
(0..30).forEach { monitor.add(it * 1_000L, 50f) }
assertNull(monitor.harshSharePercent())
(31..90).forEach { monitor.add(it * 1_000L, 50f) }
assertEquals(0.0, monitor.harshSharePercent()!!, 1e-9)
assertNull(monitor.steadiness())
}
@Test
fun `hard braking counts like hard acceleration`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 0f
// Gentle launches (0.69 m/s²) and abrupt stops (2.8 m/s²): only the braking is harsh.
repeat(5) {
repeat(24) { speed += 2.5f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(6) { speed -= 10f; monitor.add(atMs, speed); atMs += 1_000L }
}
// 6 hard seconds in every 30: the trip review's `DrivingStyle` reads the same 20 %.
assertEquals(20.0, monitor.harshSharePercent()!!, 0.5)
}
@Test
fun `a sampling gap is a gap, not an acceleration`() {
val monitor = EcoDrivingMonitor()
(0..70).forEach { monitor.add(it * 1_000L, 50f) }
// The app was asleep for half a minute and the car is now at 120: unknown motion, and
// reading it as one step would invent 3.9 m/s² out of the process having been paused.
monitor.add(100_000L, 120f)
assertNull(verdict(monitor = monitor, model = null).advice)
}
@Test
fun `replay accepts the stored track cadence without relaxing live gaps`() {
val samples = (0..24).map { index ->
TripSample(
atMs = index * 5_500L,
speedKmh = if (index % 2 == 0) 0f else 60f,
batteryPowerKw = null,
socPercent = null,
outsideTempCelsius = null,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
assertEquals(EcoLever.STEADINESS, EcoDrivingMonitor.forReplay().replay(samples).steadiness()?.lever)
assertNull(EcoDrivingMonitor().replay(samples).steadiness())
}
@Test
fun `a window too short to judge says nothing`() {
assertNull(verdict(monitor = cruise(110f, 30)).advice)
}
@Test
fun `the cruise lever wins when both are measurable`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 100f
// Motorway, and driven unevenly: both levers have something to say and only one is said.
repeat(5) {
repeat(4) { speed += 8f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(20) { monitor.add(atMs, speed); atMs += 1_000L }
speed = 100f
}
assertEquals(EcoLever.CRUISE_SPEED, verdict(monitor = monitor).advice?.lever)
}
@Test
fun `replaying a stored track is the same monitor the dashboard fed`() {
val samples = (0..120).map { index ->
TripSample(
atMs = index * 1_000L,
speedKmh = 110f,
batteryPowerKw = null,
socPercent = null,
outsideTempCelsius = 20f,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
val replayed = EcoDrivingMonitor().replay(samples)
assertEquals(
verdict(monitor = cruise(110f, 120)).advice,
verdict(monitor = replayed).advice,
)
}
@Test
fun `describe names the refusal it made`() {
val lines = verdict(model = null).describe()
assertTrue(lines.any { it == "band=unavailable(MODEL_NOT_TRAINED)" })
assertTrue(lines.any { it == "observed_percent_per_100km=unavailable" })
assertTrue(lines.any { it == "advice=none" })
}
@Test
fun `describe states the verdict and its inputs`() {
val lines = verdict(monitor = cruise(110f, 120)).describe()
assertTrue(lines.any { it == "band=TYPICAL" })
assertTrue(lines.any { it == "observed_percent_per_100km=18.00" })
assertTrue(lines.any { it == "expected_percent_per_100km=18.00" })
assertNotNull(lines.firstOrNull { it.startsWith("advice=CRUISE_SPEED") })
}
}
@@ -0,0 +1,61 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class EcoScoreTest {
private fun trip(
distanceKm: Double = 10.0,
brakings: Int? = 3,
harshMs: Long? = 60_000L,
movingMs: Long? = 1_200_000L,
) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = 1_200_000L,
durationMs = 1_200_000L,
distanceKm = distanceKm,
startSocPercent = 80f,
endSocPercent = 78f,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
movingMs = movingMs,
harshMs = harshMs,
harshAccelerations = 2,
harshBrakings = brakings,
)
private fun verdict(delta: Double) = EcoVerdict(
band = com.evsuite.hardware.telemetry.Provenanced.estimated(EcoBand.TYPICAL, 1.0),
deltaPercent = delta,
)
@Test fun `a calm town drive at the usual consumption sits in the sixties, not near 100`() {
// 3 hard stops per 10 km, 5 % of moving time hard, consumption exactly as expected.
val score = EcoScore.of(trip(), verdict(0.0))!!
assertEquals(50, score.anticipation)
assertEquals(80, score.smoothness)
assertEquals(63, score.efficiency)
assertEquals(63, score.total)
}
@Test fun `a part that cannot be measured drops out instead of scoring zero or full`() {
val score = EcoScore.of(trip(), verdict = null)!!
assertNull(score.efficiency)
// (50 × 0.35 + 80 × 0.25) / 0.60
assertEquals(63, score.total)
}
@Test fun `a trip recorded before the counters existed has no score at all`() {
assertNull(EcoScore.of(trip(brakings = null, harshMs = null, movingMs = null), null))
}
@Test fun `scales clamp at both ends`() {
val best = EcoScore.of(trip(brakings = 0, harshMs = 0L), verdict(-40.0))!!
assertEquals(100, best.total)
val worst = EcoScore.of(trip(brakings = 20, harshMs = 600_000L), verdict(60.0))!!
assertEquals(0, worst.total)
}
}
@@ -31,6 +31,17 @@ class EnergyTelemetryTest {
assertEquals(18f, snapshot.batteryPowerKw)
}
@Test fun `one pedal reaches the track as the car's own code, and an unknown code stays unknown`() {
val signals = FakeSignals(80f, null, null).apply { mode = 9; regen = 6 }
val sample = TripSample.of(EnergyTelemetryReader(signals).read(1_000L))
assertEquals(6, sample.regenLevelRaw)
assertEquals(com.evsuite.hardware.model.RegenLevel.ONE_PEDAL,
com.evsuite.hardware.model.RegenLevel.ofRaw(sample.regenLevelRaw))
assertEquals(9, sample.driveModeRaw)
assertNull(com.evsuite.hardware.model.DriveMode.ofRaw(sample.driveModeRaw))
}
@Test fun `candidate probes exist only in the explicit evidence read`() {
val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
source.probes = listOf(
@@ -70,6 +81,50 @@ class EnergyTelemetryTest {
assertEquals(0.001666, result.regeneratedKwh!!, 0.000001)
}
@Test fun `a trip started on the factory clock is dated from the corrected one`() {
val factory = 1_546_300_800_000L
val synced = 1_792_323_000_000L
val trip = EnergyTripAccumulator(factory, 80f)
trip.add(snapshot(factory, 30f, 5f))
trip.add(snapshot(factory + 1_000L, 30f, 5f))
trip.add(snapshot(synced, 30f, 5f))
trip.add(snapshot(synced + 1_000L, 30f, 5f))
val result = trip.snapshot(synced + 1_000L)
assertEquals(2_000L, result.durationMs)
assertEquals(synced - 1_000L, result.startedAtMs)
}
@Test fun `lifting off into regeneration is not a hard stop`() {
val trip = EnergyTripAccumulator(0L, 80f)
// 7 km/h a second is about 1.9 m/s², what regeneration on High does with no brake pedal.
listOf(50f, 43f, 36f, 29f, 22f, 15f).forEachIndexed { second, speed ->
trip.add(snapshot(second * 1_000L, speed, -10f))
}
val result = trip.snapshot(5_000L)
assertEquals(0, result.harshBrakings)
assertEquals(0L, result.harshMs)
}
@Test fun `a hard stretch counts once, braking and accelerating apart, at the live rate`() {
val trip = EnergyTripAccumulator(0L, 80f)
listOf(0f, 30f, 60f, 60f, 40f, 20f, 20f, 20f, 0f).forEachIndexed { second, speed ->
trip.add(
snapshot(second * 1_000L, speed, 5f)
.copy(driveModeRaw = if (second < 6) 4 else 2, regenLevelRaw = 6)
)
}
val result = trip.snapshot(8_000L)
assertEquals(1, result.harshAccelerations)
assertEquals(2, result.harshBrakings)
assertEquals(8_000L, result.movingMs)
assertEquals(5_000L, result.harshMs)
assertEquals(4, result.driveModeRaw)
assertEquals(6, result.regenLevelRaw)
}
@Test fun `trip keeps distance unknown without a usable speed interval`() {
val trip = EnergyTripAccumulator(0L, 80f)
trip.add(snapshot(0L, null, 18f))
@@ -124,6 +179,10 @@ class EnergyTelemetryTest {
private val power: Float?,
) : EnergySignalSource {
var probes: List<TelemetryEvidenceProbe> = emptyList()
var mode: Int? = null
var regen: Int? = null
override fun driveModeRaw() = mode
override fun regenLevelRaw() = regen
override fun firmware() = FirmwareInfo.Gen.SWI68
override fun socPercent() = soc
override fun rangeKm() = range
@@ -65,6 +65,20 @@ class TelemetryEvidenceTest {
assertEquals(1, parked.samples)
}
@Test fun `the widest window is a quantity, recorded as the snapshot carries it`() {
val recorder = TelemetryEvidenceRecorder()
recorder.record(snapshot(at = 0L, power = null).copy(widestWindowPercent = 40))
recorder.record(snapshot(at = 1_000L, power = null))
val windows = recorder.evidence()
.single { it.signal == TelemetryEvidenceRecorder.WINDOWS_WIDEST_PERCENT }
assertEquals("windows.widestPercent", windows.signal)
assertEquals(SignalKind.NUMERIC, windows.kind)
assertEquals(1, windows.samples)
assertEquals(1, windows.nulls)
assertEquals(40.0, windows.max!!, 0.0)
}
@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.
@@ -30,11 +30,12 @@ class TripDetectorTest {
assertNull(movingAgain.event)
}
@Test fun `a genuine confirmed end closes the trip`() {
@Test fun `a genuine confirmed end closes the trip before the head unit powers off`() {
val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f, parked = false))
val stopped = sustainStop(detector, 7_000L, 126_000L, parked = true)
assertNull(detector.add(snapshot(7_000L, 0f, parked = true)).event)
val stopped = detector.add(snapshot(8_000L, 0f, parked = true))
assertEquals(TripDetector.State.IDLE, stopped.state)
assertEquals(TripDetector.Event.STOP, stopped.event)
@@ -88,6 +88,25 @@ class TripHistoryStoreTest {
assertNull(restored.batteryTempCelsius)
assertNull(restored.climateAcOn)
assertNull(restored.climateFanLevel)
assertNull(restored.widestWindowPercent)
}
@Test fun `a sample stored before the window field reads it as unknown`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
val store = EnergyTripHistoryStore(target)
val sample = TripSample(
atMs = 10L, speedKmh = 42f, batteryPowerKw = null, socPercent = null,
outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null,
climatePowerOn = null, climateAcOn = null, climateFanLevel = null,
widestWindowPercent = 30,
)
assertTrue(store.append(summary(1L), listOf(sample)))
target.writeText(target.readText().replace(",\"widestWindowPercent\":30", ""))
val restored = EnergyTripHistoryStore(target).read().single().samples!!.single()
assertEquals(42f, restored.speedKmh)
assertNull(restored.widestWindowPercent)
}
@Test fun `battery power evidence survives a history round trip`() {
@@ -57,6 +57,26 @@ class EnergyAttributionTest {
assertEquals(0.0, result.reconciliationErrorKwh, 1e-9)
}
@Test fun `window-open intervals keep their own residual, out of the climate one`() {
val result = ready(
calculate(powerKw = 12f, climateOn = false, consumedKwh = 12.0, windowPercent = 40),
)
val windows = result.residuals.single()
assertEquals(ResidualContext.WINDOWS_OPEN, windows.context)
assertEquals(ResidualFinding.DISTINGUISHABLE, windows.finding)
assertEquals(2.0, windows.estimate.valueKwh, 1e-6)
assertEquals(0.0, result.reconciliationErrorKwh, 1e-9)
}
@Test fun `a closed window leaves the climate grouping as it was`() {
val result = ready(
calculate(powerKw = 12f, climateOn = true, consumedKwh = 12.0, windowPercent = 0),
)
assertEquals(ResidualContext.CLIMATE_ACTIVE, result.residuals.single().context)
}
@Test fun `missing model evidence stays unavailable`() {
val result = EnergyAttributionCalculator.calculate(trip(12f, true, 12.0), null)
@@ -66,10 +86,22 @@ class EnergyAttributionTest {
)
}
private fun calculate(powerKw: Float, climateOn: Boolean, consumedKwh: Double) =
EnergyAttributionCalculator.calculate(trip(powerKw, climateOn, consumedKwh), model)
private fun calculate(
powerKw: Float,
climateOn: Boolean,
consumedKwh: Double,
windowPercent: Int? = null,
) = EnergyAttributionCalculator.calculate(
trip(powerKw, climateOn, consumedKwh, windowPercent),
model,
)
private fun trip(powerKw: Float, climateOn: Boolean, consumedKwh: Double): StoredTrip {
private fun trip(
powerKw: Float,
climateOn: Boolean,
consumedKwh: Double,
windowPercent: Int? = null,
): StoredTrip {
val samples = (0..60).map { minute ->
TripSample(
atMs = minute * 60_000L,
@@ -82,6 +114,7 @@ class EnergyAttributionTest {
climatePowerOn = climateOn,
climateAcOn = climateOn,
climateFanLevel = if (climateOn) 4 else 0,
widestWindowPercent = windowPercent,
)
}
return StoredTrip(
@@ -91,12 +91,11 @@ class EnergyModelTest {
}
@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)
val trip = syntheticTrip()
val trips = List(EnergyModelTrainer.MAX_TRAINING_SAMPLES / SYNTHETIC_WINDOWS + 1) { trip }
lateinit var result: EnergyModelTrainingResult
val elapsedMs = measureTimeMillis { result = trainer.fit(listOf(trip), evidence) }
val elapsedMs = measureTimeMillis { result = trainer.fit(trips, evidence) }
assertNotNull((result as EnergyModelTrainingResult.Ready).model)
assertEquals(
@@ -106,13 +105,74 @@ class EnergyModelTest {
assertTrue("fit took ${elapsedMs}ms", elapsedMs < HOST_TRAINING_BUDGET_MS)
}
@Test fun `stop-and-go town driving trains once a dozen kilometres are recorded`() {
// Every sample of an acceleration costs several times what a cruise does, and a fit
// made per sample judged that spread as model error and refused the town for good.
// Two afternoons two degrees apart: no evidence about weather, which must not stop it.
val trips = listOf(24.0, 26.0).map { temp ->
storedTrip(urbanTrack(listOf(25.0, 35.0, 45.0, 55.0), temp))
}
val model = ready(trainer.fit(trips, evidence))
assertEquals(0.0, model.thermalKwhPer100KmPerDegree, 0.0)
assertTrue(model.envelope.minSpeedKmh < 30.0 && model.envelope.maxSpeedKmh > 50.0)
// Town consumption carries the accelerations: above the cruise-only truth, still sane.
val town = model.predict(40.0, 25.0).value!!
assertTrue("predicted $town", town in consumption(40.0, 25.0)..40.0)
}
/**
* Stop, accelerate for 15 s, cruise for a minute, brake for 15 s, repeated at each speed,
* sampled every 5 s as the recorder does. Acceleration pays the kinetic energy of a
* 1700 kg car at 90 % efficiency; braking returns 60 % of it.
*/
private fun urbanTrack(speeds: List<Double>, tempCelsius: Double): List<TripSample> {
val out = ArrayList<TripSample>()
var atMs = 0L
fun add(speedKmh: Double, powerKw: Double) {
out += TripSample(
atMs, speedKmh.toFloat(), powerKw.toFloat(), 80f, tempCelsius.toFloat(),
null, null, null, null, null,
)
atMs += 5_000L
}
for (cruise in speeds) {
val cruiseMs = cruise / 3.6
val accel = cruiseMs / 15.0
val cruisePower = consumption(cruise, tempCelsius) * cruise / 100.0
// Enough cycles for five kilometres at this speed.
val cycleKm = cruise * (60.0 + 15.0) / 3600.0
repeat(kotlin.math.ceil(5.0 / cycleKm).toInt()) {
repeat(4) { add(0.0, 1.0) }
for (step in 1..3) {
val v = cruiseMs * step / 3.0
add(v * 3.6, cruisePower * step / 3.0 + MASS_KG * accel * v / 900.0)
}
repeat(12) { add(cruise, cruisePower) }
for (step in 2 downTo 0) {
val v = cruiseMs * step / 3.0
add(v * 3.6, -0.6 * MASS_KG * accel * v / 1000.0)
}
}
}
return out
}
/** One steady kilometre per speed and temperature, each after a hole that ends a window. */
private fun syntheticTrip(): StoredTrip {
var atMs = 0L
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))
val intervals = kotlin.math.ceil(3_600.0 / (5.0 * shiftedSpeed)).toInt()
// One interval spare, so float rounding cannot leave the window short.
repeat(intervals + 2) {
add(sample(atMs, shiftedSpeed, temp))
atMs += 5_000L
}
atMs += 600_000L
}
}
}
@@ -139,10 +199,10 @@ class EnergyModelTest {
samples = samples,
)
private fun sample(speedKmh: Double, tempCelsius: Double): TripSample {
private fun sample(atMs: Long, speedKmh: Double, tempCelsius: Double): TripSample {
val powerKw = consumption(speedKmh, tempCelsius) * speedKmh / 100.0
return TripSample(
atMs = speedKmh.toLong(),
atMs = atMs,
speedKmh = speedKmh.toFloat(),
batteryPowerKw = powerKw.toFloat(),
socPercent = 80f,
@@ -164,5 +224,9 @@ class EnergyModelTest {
companion object {
private const val HOST_TRAINING_BUDGET_MS = 2_000L
private const val MASS_KG = 1_700.0
/** Two passes over five temperatures and eleven speeds. */
private const val SYNTHETIC_WINDOWS = 110
}
}
@@ -150,6 +150,24 @@ class SocConsumptionModelTest {
assertNull(model.predict(110.0, -5.0).value)
}
@Test
fun `weather that costs nothing falls back to two coefficients instead of refusing`() {
// The 10 °C drives cost less than the 20 °C ones, so a thermal term comes out negative.
val result = SocConsumptionFitter().fit(
listOf(
trip(drive(90.0, 20.0, 21.0, startAtMs = 0L)),
trip(drive(130.0, 20.0, 31.0, startAtMs = 100_000_000L)),
trip(drive(90.0, 10.0, 16.0, startAtMs = 200_000_000L)),
trip(drive(130.0, 10.0, 26.0, startAtMs = 300_000_000L)),
),
generation,
)
val model = (result as SocConsumptionFitResult.Ready).model
assertEquals(0.0, model.thermalPercentPer100KmPerDegree, 1e-12)
assertTrue(model.predict(130.0, 15.0).value!! > model.predict(90.0, 15.0).value!!)
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
@@ -190,6 +208,24 @@ class SocConsumptionModelTest {
assertTrue(segments.isEmpty())
}
@Test
fun `a gauge in tenths trains on a town drive that a whole-percent gauge cannot`() {
// Nine kilometres at 40 km/h for 15 %/100 km: 1.35 % of charge, under two whole points.
val town = drive(40.0, 20.0, 15.0, distanceKm = 9.0)
fun gauge(step: Float) = town.map {
it.copy(socPercent = kotlin.math.round(it.socPercent!! / step) * step)
}
val tenths = ArrayList<SocConsumptionFitter.Segment>()
SocConsumptionFitter().collect(gauge(0.1f), tenths)
val whole = ArrayList<SocConsumptionFitter.Segment>()
SocConsumptionFitter().collect(gauge(1f), whole)
// Cut at two kilometres, the shortest segment a stretch may be, and still near 15.
assertEquals(4, tenths.size)
assertTrue("$tenths", tenths.all { it.distanceKm >= 2.0 && it.percentPer100Km in 10.0..20.0 })
assertTrue(whole.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
@@ -221,4 +257,28 @@ class SocConsumptionModelTest {
assertTrue(skipped, skipped.contains("segments=0/"))
assertTrue(skipped, !skipped.contains("span="))
}
@Test
fun `describe says whether the solve past the gates produced a model`() {
// Past both gates the line used to stop, so a bundle could not tell a trained model
// from a solve that refused one.
val ready = SocConsumptionFitter().describe(history(), generation)
assertTrue(ready, ready.startsWith("used=4/4 segments="))
assertTrue(ready, Regex(""" fit=ready rmse=\d+\.\d{2}$""").containsMatchIn(ready))
// Faster costs less: a negative aero term, which the fit refuses by name.
val backwards = listOf(
trip(drive(90.0, 20.0, 26.0, distanceKm = 120.0, startAtMs = 0L)),
trip(drive(130.0, 20.0, 16.0, distanceKm = 120.0, startAtMs = 100_000_000L)),
)
assertEquals(
SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED),
SocConsumptionFitter().fit(backwards, generation),
)
val refused = SocConsumptionFitter().describe(backwards, generation)
assertTrue(refused, refused.endsWith("span=40/20 km/h fit=refused(aero)"))
val strict = SocConsumptionFitter(maxResidualRmse = -1.0).describe(history(), generation)
assertTrue(strict, Regex(""" fit=refused\(rmse=\d+\.\d{2}\)$""").containsMatchIn(strict))
}
}