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,168 @@
package com.evsuite.hardware
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* A property the firmware declares and never publishes answers with a zero that looks exactly
* like a measurement. The status is the only thing that tells the two apart, so what counts as
* evidence is pinned here rather than left to the reflection that cannot be unit-tested.
*/
class CarPropertyEvidenceTest {
@Test
fun `an available value is the measurement`() {
assertEquals(21.5f, CarPropertyEvidence.accept(21.5f, CarPropertyEvidence.STATUS_AVAILABLE))
}
@Test
fun `zero is a measurement when the vehicle stands behind it`() {
assertEquals(0f, CarPropertyEvidence.accept(0f, CarPropertyEvidence.STATUS_AVAILABLE))
}
@Test
fun `a declared but unpublished property is not a zero reading`() {
assertNull(CarPropertyEvidence.accept(0f, CarPropertyEvidence.STATUS_UNAVAILABLE))
}
@Test
fun `a faulty signal is not a reading either`() {
assertNull(CarPropertyEvidence.accept(42f, CarPropertyEvidence.STATUS_ERROR))
}
@Test
fun `an unknown status is refused rather than guessed`() {
assertNull(CarPropertyEvidence.accept(42f, 7))
}
@Test
fun `a null value stays null whatever the status claims`() {
assertNull(CarPropertyEvidence.accept(null, CarPropertyEvidence.STATUS_AVAILABLE))
}
@Test
fun `every status is named so the diagnostics report reads`() {
assertEquals("available", CarPropertyEvidence.describe(CarPropertyEvidence.STATUS_AVAILABLE))
assertEquals("unavailable", CarPropertyEvidence.describe(CarPropertyEvidence.STATUS_UNAVAILABLE))
assertEquals("error", CarPropertyEvidence.describe(CarPropertyEvidence.STATUS_ERROR))
assertEquals("status 7", CarPropertyEvidence.describe(7))
}
@Test
fun `battery power remains unvalidated until vehicle evidence names a generation`() {
FirmwareInfo.Gen.values().forEach { firmware ->
assertFalse(
firmware.name,
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
firmware,
),
)
assertNull(CarPropertyEvidence.batteryPowerEvidence(firmware))
}
}
@Test
fun `battery temperature remains unvalidated until vehicle evidence names a generation`() {
FirmwareInfo.Gen.values().forEach { firmware ->
assertFalse(
firmware.name,
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
firmware,
),
)
}
}
}
/**
* One unreadable property sampled every second filled the whole log buffer with the same line
* and pushed every other entry out of it, so the throttle is part of the diagnostics being
* usable at all.
*/
class ReadFailureLogTest {
@Before
fun reset() = ReadFailureLog.reset()
@Test
fun `the same failure is worth one line`() {
assertTrue(ReadFailureLog.isNew("0x11600207", "unavailable"))
assertFalse(ReadFailureLog.isNew("0x11600207", "unavailable"))
assertFalse(ReadFailureLog.isNew("0x11600207", "unavailable"))
}
@Test
fun `a failure that changes its story is worth another`() {
assertTrue(ReadFailureLog.isNew("0x11600207", "unavailable"))
assertTrue(ReadFailureLog.isNew("0x11600207", "PropertyNotAvailableException"))
assertFalse(ReadFailureLog.isNew("0x11600207", "PropertyNotAvailableException"))
}
@Test
fun `properties are throttled apart`() {
assertTrue(ReadFailureLog.isNew("0x11600207", "unavailable"))
assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable"))
}
@Test
fun `a property that recovers reports its next failure`() {
assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable"))
ReadFailureLog.clear("0x1160030e")
assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable"))
}
@Test
fun `a new vehicle session starts from silence`() {
assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable"))
ReadFailureLog.reset()
assertTrue(ReadFailureLog.isNew("0x1160030e", "unavailable"))
}
}
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 `absent is not the same claim as validated`() {
// The catalogue says the vehicle does not publish it, which is precisely why it can
// never be validated. Both must be false-for-validated and true-for-absent at once.
assertFalse(
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
assertTrue(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
}
@Test fun `a generation nobody captured claims nothing either way`() {
listOf(FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI133)
.forEach { generation ->
CarPropertyEvidence.Signal.entries.forEach { signal ->
assertFalse(
"$signal on $generation was never captured",
CarPropertyEvidence.isNeverPublished(signal, generation),
)
assertFalse(CarPropertyEvidence.isValidated(signal, generation))
}
}
}
}
@@ -0,0 +1,148 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.catalog.ActionType
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.io.File
/**
* Guards the firmware annotations and regenerates the matrix.
*
* The annotations are the single source of truth: if a vehicle entry loses its
* `@SupportedOn`, the runtime filter would silently show it on every firmware, and the
* README matrix would go stale. These tests catch both.
*/
class FirmwareSupportTest {
/** Catalogue entries that are firmware-independent — no annotation expected. */
private val firmwareIndependentConditions = setOf(
ConditionType.BT_DEVICE_CONNECTED,
ConditionType.ANY_BT_CONNECTED,
ConditionType.BT_DEVICE_ONBOARD,
ConditionType.BT_DEVICE_HANDSFREE,
ConditionType.TIME_OF_DAY,
ConditionType.DAY_OF_WEEK,
ConditionType.DATE,
ConditionType.FIRMWARE_GEN,
ConditionType.LOCATION_WITHIN,
ConditionType.PHYSICAL_BUTTON,
// Lus dans Android, pas dans la voiture : rien à router par génération.
ConditionType.MEDIA_PLAYING,
ConditionType.WIFI_SSID,
ConditionType.IN_CALL,
ConditionType.DRIVE_DURATION,
ConditionType.RANDOM_CHANCE,
// Services des applications du poste (adaptateur de navigation, météo) : ce ne sont
// pas des services du SDK véhicule, rien ne les route par génération. Ils répondent
// ou non, comme le SMS.
ConditionType.ODOMETER,
ConditionType.WEATHER_NOW,
// La connexion du poste, comptée par Android : elle dépend d'une permission, pas
// d'une génération de firmware.
ConditionType.DATA_USED_TODAY,
ConditionType.DATA_USED_MONTH
)
private val firmwareIndependentActions = setOf(
ActionType.LAUNCH_APP,
ActionType.SHOW_NOTIFICATION,
ActionType.SPEAK_TEXT,
ActionType.NAVIGATE_TO,
ActionType.WEBHOOK,
ActionType.ASK_CONFIRM,
ActionType.DELAY,
// Le SMS part par le profil Bluetooth MAP, pas par un service SAIC : rien à router
// par génération de firmware. Sa disponibilité se lit au bind, pas dans la matrice.
ActionType.SEND_SMS,
// Règles, touche média et radios du poste : rien de tout cela ne passe par la voiture.
ActionType.ENABLE_RULE,
ActionType.DISABLE_RULE,
ActionType.MEDIA_CONTROL,
ActionType.SET_BLUETOOTH,
ActionType.SET_WIFI
)
@Test
fun `every vehicle condition declares firmware support`() {
ConditionType.entries
.filterNot { it in firmwareIndependentConditions || it.eventDriven }
.forEach {
assertTrue("${it.name} is missing @SupportedOn", FirmwareSupport.gensOf(it) != null)
}
}
@Test
fun `every vehicle action declares firmware support`() {
ActionType.entries
.filterNot { it in firmwareIndependentActions }
.forEach {
assertTrue("${it.name} is missing @SupportedOn", FirmwareSupport.gensOf(it) != null)
}
}
@Test
fun `context entries stay firmware-independent`() {
firmwareIndependentConditions.forEach {
assertNull("${it.name} should not be firmware-specific", FirmwareSupport.gensOf(it))
}
ConditionType.entries.filter { it.eventDriven }.forEach {
assertNull("${it.name} should not be firmware-specific", FirmwareSupport.gensOf(it))
}
firmwareIndependentActions.forEach {
assertNull("${it.name} should not be firmware-specific", FirmwareSupport.gensOf(it))
}
}
@Test
fun `seat heating is limited to the generations that have it`() {
// EVProfile FirmwareInfo.hasHeatFeatures(): SWI133, SWI68, SWI165 only.
assertEquals(
setOf(FirmwareGen.SWI133, FirmwareGen.SWI68, FirmwareGen.SWI165),
FirmwareSupport.gensOf(ConditionType.STEERING_HEAT)
)
}
@Test
fun `ACC TJA is unavailable on SWI133`() {
// SWI133 uses getMixedIntelligentDrive, not the getAccTjaState path.
assertFalse(FirmwareSupport.isSupported(ConditionType.ACC_TJA_MODE, FirmwareGen.SWI133))
assertTrue(FirmwareSupport.isSupported(ConditionType.ACC_TJA_MODE, FirmwareGen.SWI68))
}
@Test
fun `unknown firmware hides nothing`() {
// No bridge / no reported firmware: filtering on a guess would be worse than
// offering an entry that later refuses.
ConditionType.entries.forEach {
assertTrue("${it.name} hidden with unknown firmware", FirmwareSupport.isSupported(it, null))
}
}
@Test
fun `snapshot firmware strings parse to enum`() {
assertEquals(FirmwareGen.SWI68, FirmwareSupport.parse("SWI68"))
assertEquals(FirmwareGen.SWI133, FirmwareSupport.parse("swi133"))
assertNull(FirmwareSupport.parse("SWI999"))
assertNull(FirmwareSupport.parse(null))
}
/**
* Regenerates the matrix and fails if the committed copy is stale. Regenerate with:
* `./gradlew testDebugUnitTest` then commit `docs/firmware-matrix.md`.
*/
@Test
fun `firmware matrix is up to date`() {
val generated = FirmwareMatrix.render()
val file = File("../docs/firmware-matrix.md")
if (!file.exists() || file.readText().trim() != generated.trim()) {
file.parentFile?.mkdirs()
file.writeText(generated)
}
// Re-read: after the write the file must match, so a stale commit is the only
// way this differs — and the write above fixes it locally for the next run.
assertEquals(generated.trim(), file.readText().trim())
}
}
@@ -0,0 +1,23 @@
package com.evsuite.hardware
import org.junit.Assert.assertEquals
import org.junit.Test
class FirmwareWritePolicyTest {
@Test
fun `known generations are recognized`() {
FirmwareInfo.Gen.values()
.filterNot { it == FirmwareInfo.Gen.UNKNOWN }
.forEach { generation ->
assertEquals(generation, FirmwareInfo.generationOf("${generation.name}-build"))
}
}
@Test
fun `missing and unrecognized firmware stay unknown`() {
assertEquals(FirmwareInfo.Gen.UNKNOWN, FirmwareInfo.generationOf(null))
assertEquals(FirmwareInfo.Gen.UNKNOWN, FirmwareInfo.generationOf(""))
assertEquals(FirmwareInfo.Gen.UNKNOWN, FirmwareInfo.generationOf("SWI999-test"))
}
}
@@ -0,0 +1,57 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.ActionType
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Ce que la preuve relevée sur une voiture change — et ce qu'elle ne change pas.
*
* L'enjeu : `writeProven` parle du projet, `effectProven` parle de la voiture. Confondre les
* deux rendrait soit les actions vitres définitivement inaccessibles, soit accessibles sur des
* voitures où rien n'a été constaté.
*/
class GlassEvidenceTest {
private val generation get() = FirmwareInfo.getGeneration().name
@After
fun tearDown() = GlassEvidence.setForTest(null)
@Test
fun `sans preuve, effectProven suit writeProven`() {
GlassEvidence.setForTest(null)
ActionType.entries.forEach {
assertTrue("${it.name}", it.writeProven == it.effectProven)
}
}
@Test
fun `la preuve debloque les actions vitres, et elles seules`() {
GlassEvidence.setForTest(GlassEvidence.Proof(5, 2, generation, 0L))
GlassEvidence.ACTIONS.forEach {
assertFalse("${it.name} reste non prouvée dans le catalogue", it.writeProven)
assertTrue("${it.name} doit être débloquée par la preuve", it.effectProven)
}
// Une preuve sur les vitres ne dit rien d'une autre action : le catalogue reste seul
// juge partout ailleurs.
ActionType.entries.filterNot { it in GlassEvidence.ACTIONS }.forEach {
assertTrue("${it.name}", it.writeProven == it.effectProven)
}
}
@Test
fun `une preuve d'un autre firmware ne vaut rien`() {
// Les commandes sont propres au SDK de la génération : ce qui ouvre sur l'une n'a
// aucune raison d'ouvrir sur l'autre, et une mise à jour ne doit pas laisser derrière
// elle une autorisation qui n'a plus de fondement.
GlassEvidence.setForTest(GlassEvidence.Proof(5, 2, "SOME_OTHER_GEN", 0L))
GlassEvidence.ACTIONS.forEach {
assertFalse("${it.name} ne doit pas hériter d'une preuve étrangère", it.effectProven)
}
}
}
@@ -0,0 +1,134 @@
package com.evsuite.hardware
import com.evsuite.hardware.saic.SaicVehicleControl.Window
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Ce que la sonde doit établir sans voiture : l'enchaînement des deux passes et les refus.
*
* La vitre simulée reproduit le comportement qui a rendu la sonde nécessaire : le service
* accepte n'importe quelle commande et n'en applique que deux. Une passe unique ne peut donc
* pas voir les deux sens — c'est exactement ce que ces tests vérifient.
*/
class GlassProbeTest {
/** Vitre où [openCommand] ouvre en grand et [closeCommand] referme ; le reste est inerte. */
private class FakeGlass(
private val openCommand: Int,
private val closeCommand: Int,
var percent: Int = 0,
) : GlassProbe.Glass {
val sent = mutableListOf<Int>()
var readable = true
override fun position(window: Window): Int? = percent.takeIf { readable }
override fun send(window: Window, command: Int): Boolean {
sent += command
when (command) {
openCommand -> percent = 100
closeCommand -> percent = 0
}
return true
}
}
private fun probe(
glass: GlassProbe.Glass,
stopped: () -> Boolean = { true },
ignitionRun: () -> Boolean = { true },
) = GlassProbe.run(
window = Window.DRIVER,
glass = glass,
stopped = stopped,
ignitionRun = ignitionRun,
sleep = {},
)
@Test
fun `les deux sens sont trouves depuis une vitre fermee`() {
// Depuis 0 %, la commande qui ferme ne bouge rien : sans seconde passe elle passerait
// pour inerte, et l'action « fermer » resterait à jamais non prouvée.
val glass = FakeGlass(openCommand = 5, closeCommand = 2)
val result = probe(glass)
assertEquals(5, result.openCommand)
assertEquals(2, result.closeCommand)
assertTrue(result.proven)
assertNull(result.refusal)
// Repartie de 0 %, la vitre doit y revenir : une sonde qui laisse la vitre ouverte
// est un défaut, pas un résultat.
assertTrue(result.restored)
assertEquals(0, glass.percent)
}
@Test
fun `une commande deja classee n'est pas renvoyee`() {
val glass = FakeGlass(openCommand = 0, closeCommand = 1)
val result = probe(glass)
assertEquals(0, result.openCommand)
assertEquals(1, result.closeCommand)
// 0 ouvre, 1 referme, les deux sens sont connus : la sonde s'arrête là, plus une
// remise en place. Rien au-delà de la commande 1 ne doit avoir été envoyé.
assertEquals(listOf(0, 1), glass.sent.take(2))
assertTrue(glass.sent.none { it > 1 })
}
@Test
fun `une vitre inerte ne prouve rien`() {
val glass = FakeGlass(openCommand = 99, closeCommand = 98)
val result = probe(glass)
assertNull(result.openCommand)
assertNull(result.closeCommand)
assertEquals(GlassProbe.Refusal.NOTHING_MOVED, result.refusal)
assertFalse(result.proven)
// Les huit commandes, deux fois : la seconde passe ne peut être sautée sur la foi de
// la première, qui ne voit pas les fermetures.
assertEquals(16, glass.sent.size)
}
@Test
fun `un seul sens trouve n'est pas une preuve`() {
val glass = FakeGlass(openCommand = 3, closeCommand = 99)
val result = probe(glass)
assertEquals(3, result.openCommand)
assertNull(result.closeCommand)
assertEquals(GlassProbe.Refusal.ONE_DIRECTION_ONLY, result.refusal)
// Rien ne peut refermer la vitre : il faut le dire, pas laisser croire au contraire.
assertFalse(result.restored)
}
@Test
fun `la voiture qui repart interrompt la sonde`() {
val glass = FakeGlass(openCommand = 5, closeCommand = 2)
var checks = 0
// Arrêtée pour la lecture initiale et la première commande, puis en mouvement.
val result = probe(glass, stopped = { checks++ < 2 })
assertEquals(GlassProbe.Refusal.NOT_STOPPED, result.refusal)
assertEquals(1, glass.sent.size)
}
@Test
fun `contact coupe, aucune commande n'est envoyee`() {
val glass = FakeGlass(openCommand = 5, closeCommand = 2)
val result = probe(glass, ignitionRun = { false })
assertEquals(GlassProbe.Refusal.IGNITION_NOT_RUN, result.refusal)
assertTrue(glass.sent.isEmpty())
}
@Test
fun `une position illisible arrete tout`() {
val glass = FakeGlass(openCommand = 5, closeCommand = 2).apply { readable = false }
val result = probe(glass)
assertEquals(GlassProbe.Refusal.UNREADABLE, result.refusal)
assertTrue(glass.sent.isEmpty())
}
}
@@ -0,0 +1,89 @@
package com.evsuite.hardware
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
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,
87 to PhysicalButtonEventDecoder.Button.MEDIA_NEXT,
88 to PhysicalButtonEventDecoder.Button.MEDIA_PREVIOUS,
110 to PhysicalButtonEventDecoder.Button.SOURCE,
164 to PhysicalButtonEventDecoder.Button.MUTE,
17 to PhysicalButtonEventDecoder.Button.STAR_LEFT,
18 to PhysicalButtonEventDecoder.Button.STAR_RIGHT,
286 to PhysicalButtonEventDecoder.Button.STAR_RIGHT,
287 to PhysicalButtonEventDecoder.Button.ASSISTANT
)
expected.forEach { (code, button) ->
val decoder = PhysicalButtonEventDecoder()
decoder.accept(code, true, false)
assertEquals(button, decoder.accept(code, false, false)?.button)
}
}
@Test fun `two releases inside the window make one double`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(17, true, false, atMillis = 0)
assertEquals(PhysicalButtonEventDecoder.Press.SHORT, decoder.accept(17, false, false, atMillis = 10)?.press)
decoder.accept(17, true, false, atMillis = 100)
assertEquals(PhysicalButtonEventDecoder.Press.DOUBLE, decoder.accept(17, false, false, atMillis = 200)?.press)
}
@Test fun `a second press after the window is another single`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(17, true, false, atMillis = 0)
decoder.accept(17, false, false, atMillis = 0)
decoder.accept(17, true, false, atMillis = 1_000)
assertEquals(PhysicalButtonEventDecoder.Press.SHORT, decoder.accept(17, false, false, atMillis = 1_000)?.press)
}
@Test fun `three presses give one double and then a single`() {
// Sinon un troisième appui rendrait un second DOUBLE, et la règle partirait deux fois.
val decoder = PhysicalButtonEventDecoder()
val presses = listOf(0L, 100L, 200L).map {
decoder.accept(17, true, false, atMillis = it)
decoder.accept(17, false, false, atMillis = it)?.press
}
assertEquals(
listOf(
PhysicalButtonEventDecoder.Press.SHORT,
PhysicalButtonEventDecoder.Press.DOUBLE,
PhysicalButtonEventDecoder.Press.SHORT
),
presses
)
}
@Test fun `a long press does not pair with the release that follows`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(17, true, false, atMillis = 0)
decoder.accept(17, false, false, atMillis = 0) // SHORT
decoder.accept(17, true, false, atMillis = 100)
decoder.accept(17, true, true, atMillis = 100) // LONG
decoder.accept(17, false, false, atMillis = 150) // release, suppressed
decoder.accept(17, true, false, atMillis = 200)
assertEquals(
PhysicalButtonEventDecoder.Press.SHORT,
decoder.accept(17, false, false, atMillis = 250)?.press
)
}
@Test fun `long press fires once and suppresses release`() {
val decoder = PhysicalButtonEventDecoder()
assertNull(decoder.accept(17, true, false))
assertEquals(PhysicalButtonEventDecoder.Press.LONG, decoder.accept(17, true, true)?.press)
assertNull(decoder.accept(17, true, true))
assertNull(decoder.accept(17, false, false))
}
}
@@ -0,0 +1,52 @@
package com.evsuite.hardware
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class StateChangeLogTest {
@Test fun `the first value always passes`() {
assertTrue(StateChangeLog<Int>().accept(2))
}
@Test fun `a repeat is suppressed`() {
val gate = StateChangeLog<Int>()
assertTrue(gate.accept(2))
assertFalse(gate.accept(2))
assertFalse(gate.accept(2))
}
@Test fun `a transition passes and the repeats after it do not`() {
val gate = StateChangeLog<Int>()
gate.accept(2)
assertTrue(gate.accept(4))
assertFalse(gate.accept(4))
assertTrue(gate.accept(2))
}
@Test fun `the ignition flood collapses to its transitions`() {
// 10 Hz of "RUN/READY" either side of one real change, as the vehicle actually reports it.
val gate = StateChangeLog<Int>()
val stream = List(100) { 2 } + List(100) { 0 }
assertEquals(2, stream.count { gate.accept(it) })
}
@Test fun `a first null still counts as a change`() {
val gate = StateChangeLog<Int?>()
assertTrue(gate.accept(null))
assertFalse(gate.accept(null))
assertTrue(gate.accept(1))
}
@Test fun `reset makes the next value log again`() {
val gate = StateChangeLog<Int>()
gate.accept(2)
assertFalse(gate.accept(2))
gate.reset()
assertTrue(gate.accept(2))
}
private fun assertEquals(expected: Int, actual: Int) =
org.junit.Assert.assertEquals(expected.toLong(), actual.toLong())
}
@@ -0,0 +1,45 @@
package com.evsuite.hardware
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class VehicleSpeedEvidenceTest {
@Test fun `the two conversions are distinct versions`() {
assertEquals(1, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
assertEquals(2, VehicleSpeedEvidence.KMH_DIRECT_V2)
assertEquals(VehicleSpeedEvidence.KMH_DIRECT_V2, VehicleSpeedEvidence.CURRENT)
}
@Test fun `a distance from the old conversion never matches the current one`() {
val old = VehicleSpeedEvidence(FirmwareInfo.Gen.SWI68, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
assertFalse(old.matchesCurrent(FirmwareInfo.Gen.SWI68))
}
@Test fun `evidence naming an unknown firmware is refused`() {
assertThrows(IllegalArgumentException::class.java) {
VehicleSpeedEvidence(FirmwareInfo.Gen.UNKNOWN, VehicleSpeedEvidence.CURRENT)
}
}
@Test fun `a conversion version must be positive`() {
assertThrows(IllegalArgumentException::class.java) {
VehicleSpeedEvidence(FirmwareInfo.Gen.SWI68, 0)
}
}
@Test fun `an unknown firmware makes no claim rather than a wrong one`() {
// No vehicle in a JVM test, so the generation is UNKNOWN and current() must stay silent.
assertNull(VehicleSpeedEvidence.current())
}
@Test fun `evidence for a different firmware does not match this one`() {
val other = VehicleSpeedEvidence(FirmwareInfo.Gen.SWI69, VehicleSpeedEvidence.CURRENT)
assertFalse(other.matchesCurrent(FirmwareInfo.Gen.SWI68))
assertTrue(other.matchesCurrent(FirmwareInfo.Gen.SWI69))
}
}
@@ -0,0 +1,46 @@
package com.evsuite.hardware
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class VehicleSpeedScaleTest {
@Test fun `SWI68 reports km per hour and is not converted again`() {
assertTrue(VehicleSpeedScale.reportsKmh(FirmwareInfo.Gen.SWI68))
assertEquals(34.4f, VehicleSpeedScale.toKmh(34.4f, FirmwareInfo.Gen.SWI68), 0.001f)
}
@Test fun `an unproven generation keeps the specified conversion`() {
FirmwareInfo.Gen.entries
.filterNot { it == FirmwareInfo.Gen.SWI68 }
.forEach { generation ->
assertFalse("$generation is not proven", VehicleSpeedScale.reportsKmh(generation))
assertEquals(
"$generation must keep the AAOS conversion",
36.0f,
VehicleSpeedScale.toKmh(10.0f, generation),
0.001f,
)
}
}
@Test fun `reverse is a speed, not a negative one`() {
assertEquals(12.0f, VehicleSpeedScale.toKmh(-12.0f, FirmwareInfo.Gen.SWI68), 0.001f)
assertEquals(36.0f, VehicleSpeedScale.toKmh(-10.0f, FirmwareInfo.Gen.SWI69), 0.001f)
}
@Test fun `the drive that proved it now integrates to the route it followed`() {
// The recorded trip: 245836 ms at a steady 34.4 km/h reads as 2.35 km, not 8.46 km.
val hours = 245_836L / 3_600_000.0
val kmh = VehicleSpeedScale.toKmh(34.4f, FirmwareInfo.Gen.SWI68)
assertEquals(2.35, kmh * hours, 0.02)
}
@Test fun `standstill stays standstill in every generation`() {
FirmwareInfo.Gen.entries.forEach {
assertEquals(0.0f, VehicleSpeedScale.toKmh(0.0f, it), 0.0f)
}
}
}
@@ -0,0 +1,34 @@
package com.evsuite.hardware
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The gate decides whether a rule may change a car that is moving, so its rule is spelled out
* here rather than trusted to reading.
*/
class VehicleWriteGateTest {
@Test
fun `standstill is the default and the only speed allowed at zero`() {
assertEquals(VehicleWriteGate.Decision.ALLOWED, VehicleWriteGate.decide(0f))
assertEquals(VehicleWriteGate.Decision.REFUSED_MOVING, VehicleWriteGate.decide(1f))
assertEquals(VehicleWriteGate.Decision.REFUSED_MOVING, VehicleWriteGate.decide(90f))
}
@Test
fun `every positive speed is refused`() {
assertEquals(VehicleWriteGate.Decision.REFUSED_MOVING, VehicleWriteGate.decide(0.01f))
assertEquals(VehicleWriteGate.Decision.REFUSED_MOVING, VehicleWriteGate.decide(20f))
}
@Test
fun `an unreadable speed fails closed`() {
assertEquals(VehicleWriteGate.Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(null))
assertEquals(VehicleWriteGate.Decision.REFUSED_UNKNOWN_SPEED, VehicleWriteGate.decide(-1f))
assertEquals(
VehicleWriteGate.Decision.REFUSED_UNKNOWN_SPEED,
VehicleWriteGate.decide(Float.NaN)
)
}
}
@@ -0,0 +1,79 @@
package com.evsuite.hardware
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class VsmGlassTest {
@Test fun `hold stops pulsing and releases glass when gate closes`() {
var now = 0L
var checks = 0
val writes = mutableListOf<Pair<Int, Int>>()
val result = VsmGlass.runHold(
targets = listOf(0, 1),
command = VsmGlass.Command.UP,
durationMs = 1_000L,
nowMs = { now },
allowed = { ++checks == 1 },
write = { area, command -> writes += area to command; true },
sleep = { now += it },
)
assertFalse(result)
assertEquals(2, checks)
assertEquals(
listOf(
0 to VsmGlass.Command.UP,
1 to VsmGlass.Command.UP,
0 to VsmGlass.Command.STOP,
1 to VsmGlass.Command.STOP,
),
writes,
)
}
@Test fun `hold releases glass when first pulse is refused`() {
val writes = mutableListOf<Pair<Int, Int>>()
val result = VsmGlass.runHold(
targets = listOf(2),
command = VsmGlass.Command.UP,
durationMs = 1_000L,
nowMs = { 0L },
allowed = { false },
write = { area, command -> writes += area to command; true },
sleep = {},
)
assertFalse(result)
assertEquals(listOf(2 to VsmGlass.Command.STOP), writes)
}
@Test fun `hold completes while gate remains open`() {
var now = 0L
val writes = mutableListOf<Pair<Int, Int>>()
val result = VsmGlass.runHold(
targets = listOf(3),
command = VsmGlass.Command.UP,
durationMs = VsmGlass.PULSE_MS * 2,
nowMs = { now },
allowed = { true },
write = { area, command -> writes += area to command; true },
sleep = { now += it },
)
assertTrue(result)
assertEquals(
listOf(
3 to VsmGlass.Command.UP,
3 to VsmGlass.Command.UP,
3 to VsmGlass.Command.STOP,
),
writes,
)
}
}
@@ -0,0 +1,386 @@
package com.evsuite.hardware.catalog
import com.evsuite.hardware.catalog.ActionType
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.catalog.ValueKind
import com.evsuite.hardware.saic.SaicRadio
import com.evsuite.hardware.saic.SaicVehicleControl
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Garde-fous sur le catalogue. Les erreurs visées ne cassent pas la compilation : elles
* produisent une entrée d'interface qui ne fait rien, ou pire, une écriture véhicule qui
* contourne le verrou de vitesse.
*/
class CatalogConsistencyTest {
@Suppress("DEPRECATION")
@Test
fun `call is one public action accepting numbers or contacts`() {
val publicActions = ActionType.byGroup().values.flatten()
assertEquals(ValueKind.CONTACT, ActionType.CALL_NUMBER.spec.kind)
assertTrue(ActionType.CALL_NUMBER in publicActions)
assertFalse(ActionType.CALL_CONTACT in publicActions)
}
@Test
fun `la destination et le point utilisent des controles distincts`() {
// Un point a un rayon, une destination n'en a pas : deux ValueKind, sinon l'éditeur
// demanderait un rayon pour une adresse.
assertEquals(ValueKind.DESTINATION, ActionType.NAVIGATE_TO.spec.kind)
assertEquals(ValueKind.LOCATION, ConditionType.LOCATION_WITHIN.spec.kind)
assertEquals(0, ActionType.NAVIGATE_TO.spec.max)
}
@Test
fun `le sms porte un destinataire et un message`() {
// Le destinataire est un numéro (ou un contact du répertoire PBAP) et le message est
// du texte libre : un seul contrôle ne pourrait pas porter les deux.
assertEquals(ValueKind.SMS, ActionType.SEND_SMS.spec.kind)
assertTrue(ActionType.SEND_SMS.spec.hintRes != 0)
assertFalse("le SMS n'écrit rien dans le véhicule", ActionType.SEND_SMS.gated)
}
@Test
fun `la demande de confirmation porte sa question et son delai`() {
// Le texte EST la question posée au conducteur ; un exemple est indispensable, la
// formulation par défaut d'un champ vide n'apprend rien. Le nombre est l'attente en
// secondes, bornée pour qu'une question reste lisible sans bloquer le cycle.
assertEquals(ValueKind.CONFIRM, ActionType.ASK_CONFIRM.spec.kind)
assertTrue(ActionType.ASK_CONFIRM.spec.hintRes != 0)
assertTrue(ActionType.ASK_CONFIRM.spec.min > 0)
assertTrue(ActionType.ASK_CONFIRM.spec.max > ActionType.ASK_CONFIRM.spec.min)
// Le défaut des règles enregistrées sans délai doit rester dans les bornes offertes.
assertTrue(ActionType.ASK_CONFIRM_DEFAULT_SECONDS in
ActionType.ASK_CONFIRM.spec.min..ActionType.ASK_CONFIRM.spec.max)
assertFalse("la question ne touche pas le véhicule", ActionType.ASK_CONFIRM.gated)
}
@Test
fun `toute action vehicule cible une action du pont`() {
// bridgeAction=null est réservé aux actions locales (lancer une app, notifier).
// Une action véhicule sans cible serait silencieusement ignorée à l'exécution.
val localOnly = setOf(
ActionType.LAUNCH_APP, ActionType.SHOW_NOTIFICATION, ActionType.SPEAK_TEXT,
ActionType.NAVIGATE_TO, ActionType.WEBHOOK, ActionType.ASK_CONFIRM,
ActionType.DELAY, ActionType.SEND_SMS,
ActionType.ENABLE_RULE, ActionType.DISABLE_RULE, ActionType.MEDIA_CONTROL,
ActionType.SET_BLUETOOTH, ActionType.SET_WIFI
)
ActionType.entries.filterNot { it in localOnly }.forEach { type ->
assertNotNull("${type.name} n'a pas de bridgeAction", type.bridgeAction)
}
}
@Test
fun `les actions locales ne passent pas par le pont`() {
listOf(
ActionType.LAUNCH_APP, ActionType.SHOW_NOTIFICATION, ActionType.SPEAK_TEXT,
ActionType.NAVIGATE_TO, ActionType.WEBHOOK, ActionType.ASK_CONFIRM,
ActionType.DELAY, ActionType.SEND_SMS,
ActionType.ENABLE_RULE, ActionType.DISABLE_RULE, ActionType.MEDIA_CONTROL,
ActionType.SET_BLUETOOTH, ActionType.SET_WIFI
).forEach { type ->
assertTrue(
"${type.name} ne touche pas le véhicule et ne doit pas avoir de bridgeAction",
type.bridgeAction == null
)
}
}
@Test
fun `les ecritures de comportement routier sont marquees gated`() {
// Le marquage ne remplace PAS le verrou côté EVProfile, qui reste l'autorité.
// Il conditionne l'avertissement affiché à l'utilisateur au moment du choix :
// une action gated non marquée se présenterait comme applicable en roulant.
val mustBeGated = setOf(
ActionType.APPLY_PROFILE,
ActionType.SET_DRIVE_MODE, ActionType.SET_REGEN_LEVEL, ActionType.SET_ONE_PEDAL,
ActionType.SET_ENERGY_SAVING,
ActionType.SET_AEB_ENABLED, ActionType.SET_AEB_MODE, ActionType.SET_AEB_SENSITIVITY,
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
)
mustBeGated.forEach { type ->
assertTrue("${type.name} modifie le comportement routier et doit être gated", type.gated)
}
}
@Test
fun `couper le vehicule n est pas automatisable`() {
// Une règle ne doit jamais pouvoir éteindre la voiture : c'est irréversible pour
// le conducteur, et ça reste réservé à un geste humain dans EVProfile.
assertFalse(
"aucune action du catalogue ne doit couper le véhicule",
ActionType.entries.any {
it.bridgeAction?.contains("POWER_OFF", ignoreCase = true) == true
}
)
}
@Test
fun `toute condition vehicule lit une cle de l instantane`() {
// Les conditions calculées localement (heure, jour, Bluetooth) n'ont pas de clé ;
// toutes les autres en ont besoin, sinon elles seraient toujours indisponibles.
val localOnly = setOf(
ConditionType.BT_DEVICE_CONNECTED,
ConditionType.ANY_BT_CONNECTED,
ConditionType.BT_DEVICE_ONBOARD,
ConditionType.BT_DEVICE_HANDSFREE,
ConditionType.TIME_OF_DAY,
ConditionType.DAY_OF_WEEK,
ConditionType.DATE,
ConditionType.LOCATION_WITHIN,
// Tiré au sort à l'évaluation : il n'y a rien à lire.
ConditionType.RANDOM_CHANCE
)
ConditionType.entries.filterNot { it in localOnly }.forEach { type ->
assertNotNull("${type.name} n'a pas de clé d'instantané", type.snapshotKey)
}
}
@Test
fun `every catalogue entry carries a string label`() {
// A zero labelRes would crash getString at display time on a device, invisible
// to these JVM tests — so assert the id is set rather than resolve it.
ConditionType.entries.forEach { assertTrue("${it.name} has no label", it.labelRes != 0) }
ActionType.entries.forEach { assertTrue("${it.name} has no label", it.labelRes != 0) }
}
@Test
fun `charging state and the charging flag answer different questions`() {
// The boolean cannot name "plugged in but not charging", which is the state a rule
// warning a departing driver is about — so the two must not share a snapshot key.
assertEquals(ValueKind.ENUM, ConditionType.CHARGING_STATUS.spec.kind)
assertFalse(
"the state and the flag must stay separate readings",
ConditionType.CHARGING_STATUS.snapshotKey == ConditionType.CHARGING.snapshotKey
)
}
@Test
fun `only the flowing states count as charging`() {
// `status != 0` was the old derivation, and it made a finished charge, a stopped one
// and a fault all read as "charging". Whatever else the set gains, those three stay out.
listOf(
VehicleEnums.CHARGING_UNPLUGGED,
VehicleEnums.CHARGING_DONE,
VehicleEnums.CHARGING_FAULT,
VehicleEnums.CHARGING_PLUGGED_IDLE
).forEach {
assertFalse("$it must not read as charging", it in VehicleEnums.CHARGING_ACTIVE_STATES)
}
assertTrue(VehicleEnums.CHARGING_AC in VehicleEnums.CHARGING_ACTIVE_STATES)
assertTrue(VehicleEnums.CHARGING_DC in VehicleEnums.CHARGING_ACTIVE_STATES)
// Every named state must be offered in the picker, or a rule cannot select it.
val offered = VehicleEnums.CHARGING_STATUSES.map { it.value }.toSet()
assertTrue(VehicleEnums.CHARGING_ACTIVE_STATES.all { it in offered })
}
@Test
fun `the charge window ends are clock times`() {
// Minutes since midnight behind a 0…1439 slider is unanswerable at the wheel; the
// window's two ends are clock times and get the time control.
listOf(ConditionType.CHARGE_WINDOW_START, ConditionType.CHARGE_WINDOW_STOP).forEach {
assertEquals(ValueKind.TIME, it.spec.kind)
assertTrue("${it.name} must offer before/after", it.comparable)
}
// The write moves both ends at once — a half-set window is one nobody intended.
assertEquals(ValueKind.TIME_RANGE, ActionType.SET_CHARGE_WINDOW.spec.kind)
}
@Test
fun `the two cabin zones are separate entries`() {
// Adding a zone argument to the driver's target would have made every rule saved
// before it carry a zone it never chose.
assertFalse(
ActionType.SET_CABIN_TEMP.currentKey == ActionType.SET_PASSENGER_TEMP.currentKey
)
assertEquals(SnapshotKeys.KEY_PASSENGER_TEMP, ConditionType.PASSENGER_TEMP.snapshotKey)
assertEquals(
ActionType.SET_PASSENGER_TEMP.spec.min, ConditionType.PASSENGER_TEMP.spec.min
)
assertEquals(
ActionType.SET_PASSENGER_TEMP.spec.max, ConditionType.PASSENGER_TEMP.spec.max
)
}
@Test
fun `a settable climate switch is readable back`() {
// The editor opens a control on the value the car reports. A switch the catalogue can
// set but never read leaves the rule author guessing at the present state.
mapOf(
ActionType.SET_ECON to ConditionType.ECON_MODE,
ActionType.SET_FRONT_DEFROST to ConditionType.FRONT_DEFROST,
ActionType.SET_REAR_DEFROST to ConditionType.REAR_DEFROST,
ActionType.SET_PASSENGER_TEMP to ConditionType.PASSENGER_TEMP
).forEach { (action, condition) ->
assertEquals(
"${action.name} must open on what ${condition.name} reads",
condition.snapshotKey, action.currentKey
)
}
}
@Test
fun `the media commands are the platform key codes`() {
// The runner passes them straight to the media key dispatch: a value invented here
// would send a different key, and the mistake would look like an app bug.
assertEquals(android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, MediaCommand.PLAY_PAUSE)
assertEquals(android.view.KeyEvent.KEYCODE_MEDIA_NEXT, MediaCommand.NEXT)
assertEquals(android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS, MediaCommand.PREVIOUS)
assertEquals(MediaCommand.OPTIONS.size, MediaCommand.OPTIONS.map { it.value }.toSet().size)
}
@Test
fun `rule chaining names a rule and touches nothing else`() {
// The pair has to stay symmetric: an editor offering "enable" with a rule chooser and
// "disable" with a text field would store two different things in the same field.
listOf(ActionType.ENABLE_RULE, ActionType.DISABLE_RULE).forEach {
assertEquals(ValueKind.RULE, it.spec.kind)
assertFalse("${it.name} writes nothing to the vehicle", it.gated)
}
}
@Test
fun `each window is readable and settable, and the group read stays`() {
// The all-windows action is not replaced by the four: closing the glass is one
// gesture, and splitting it would let three succeed and one be forgotten.
assertNotNull(ActionType.SET_WINDOWS.bridgeAction)
assertEquals(SnapshotKeys.KEY_WINDOW_PERCENT, ConditionType.WINDOW_POSITION.snapshotKey)
// One action per window, one condition per window: the write is a command and the
// read is a position, so they are not two ends of the same scale — see the glass test.
listOf(
ActionType.SET_WINDOW_DRIVER, ActionType.SET_WINDOW_PASSENGER,
ActionType.SET_WINDOW_REAR_LEFT, ActionType.SET_WINDOW_REAR_RIGHT
).forEach { assertNotNull("${it.name} must reach the bridge", it.bridgeAction) }
listOf(
ConditionType.WINDOW_DRIVER, ConditionType.WINDOW_PASSENGER,
ConditionType.WINDOW_REAR_LEFT, ConditionType.WINDOW_REAR_RIGHT
).forEach {
assertEquals("${it.name} reads a position in percent", 100, it.spec.max)
}
// Four windows, four distinct keys — a copy-paste that reused one would make two
// windows report each other's position.
val keys = listOf(
ConditionType.WINDOW_DRIVER, ConditionType.WINDOW_PASSENGER,
ConditionType.WINDOW_REAR_LEFT, ConditionType.WINDOW_REAR_RIGHT
).map { it.snapshotKey }
assertEquals(4, keys.toSet().size)
assertFalse(SnapshotKeys.KEY_WINDOW_PERCENT in keys)
}
@Test
fun `the head-unit app services are read-only and unrouted`() {
// They belong to the head unit's own apps, not to the vehicle SDK, so there is no
// generation to route them by and nothing to write back.
listOf(ConditionType.ODOMETER, ConditionType.WEATHER_NOW).forEach {
assertNotNull("${it.name} must read a snapshot key", it.snapshotKey)
}
// A list, not free text: the provider's phrase is the one value nobody can type, so
// the rule stores a state the catalogue owns and the reader does the classifying.
assertEquals(ValueKind.ENUM, ConditionType.WEATHER_NOW.spec.kind)
assertEquals(WeatherConditions.OPTIONS, ConditionType.WEATHER_NOW.spec.options)
assertTrue("a rule asks for more or fewer kilometres", ConditionType.ODOMETER.comparable)
}
@Test
fun `every glass write is gated, and asks for a state rather than a raw command`() {
// The service takes a command in 0..7 on the way in and answers a percentage on the
// way out, so neither a raw command nor a position belongs in a saved rule. A command
// number is worse than useless in the editor — it is what let a rule ask for "7",
// which the service accepts and drops — and a percentage would offer a hundred and one
// values of which ninety-nine the glass cannot reach. Open and closed are the two
// states that exist.
val glass = listOf(
ActionType.SET_WINDOWS, ActionType.SET_WINDOW_DRIVER, ActionType.SET_WINDOW_PASSENGER,
ActionType.SET_WINDOW_REAR_LEFT, ActionType.SET_WINDOW_REAR_RIGHT
)
glass.forEach {
assertTrue("${it.name} must take the standstill gate", it.gated)
assertEquals("${it.name} asks for a state", ValueKind.ENUM, it.spec.kind)
assertEquals(
"${it.name} offers exactly open and closed",
VehicleEnums.WINDOW_COMMANDS, it.spec.options
)
// No current value to open the editor on: the reading is a position and the
// control is a state, so seeding one with the other would be a false start.
assertNull("${it.name} has no position to preselect", it.currentKey)
// The catalogue still cannot claim the write moves anything. GlassEvidence lifts
// this per car, once a probe has watched a command move the glass.
assertFalse("${it.name} effect is not established", it.writeProven)
}
// 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())
}
@Test
fun `the window states carry no car-specific command number`() {
// Which raw command opens and which closes is a property of the car, established by
// GlassProbe and stored in GlassEvidence — never of the catalogue. A rule exported
// from one car and imported on another must not carry the first car's command codes.
assertEquals(0, VehicleEnums.WINDOW_CLOSE)
assertEquals(1, VehicleEnums.WINDOW_OPEN)
assertEquals(2, VehicleEnums.WINDOW_COMMANDS.size)
assertEquals(
setOf(VehicleEnums.WINDOW_CLOSE, VehicleEnums.WINDOW_OPEN),
VehicleEnums.WINDOW_COMMANDS.map { it.value }.toSet()
)
}
@Test
fun `the radio bands are the vendor RadioType values, and include DAB`() {
// Passed straight to tune() as its band argument: a value invented here tunes the
// wrong band, or none.
val byValue = VehicleEnums.RADIO_BANDS.associateBy { it.value }
assertTrue("AM missing", SaicRadio.BAND_AM in byValue)
assertTrue("FM missing", SaicRadio.BAND_FM in byValue)
assertTrue("DAB missing — it is the band the tune action cannot reach", SaicRadio.BAND_DAB in byValue)
assertEquals(3, VehicleEnums.RADIO_BANDS.size)
}
@Test
fun `les enumerations ont des options`() {
// Une ENUM sans option produit une liste déroulante vide : l'utilisateur ne peut
// rien choisir et la règle reste inutilisable.
ConditionType.entries
.filter { it.spec.kind == ValueKind.ENUM && it != ConditionType.FIRMWARE_GEN }
.forEach { assertTrue("${it.name} : ENUM sans option", it.spec.options.isNotEmpty()) }
// RADIO dessine aussi une liste déroulante — celle des bandes — donc la même règle
// s'applique : sans option, l'action ne peut nommer aucune bande.
ActionType.entries
.filter { it.spec.kind == ValueKind.ENUM || it.spec.kind == ValueKind.RADIO }
.forEach { assertTrue("${it.name} : ENUM sans option", it.spec.options.isNotEmpty()) }
}
@Test
fun `les bornes numeriques sont coherentes`() {
val specs = ConditionType.entries.map { it.name to it.spec } +
ActionType.entries.map { it.name to it.spec }
specs.filter { it.second.kind == ValueKind.NUMBER }.forEach { (name, spec) ->
if (spec.max >= 0) {
assertTrue("$name : max doit dépasser min", spec.max > spec.min)
} else {
// max = -1 : borne résolue à l'exécution. Le repli doit rester utilisable
// même si le véhicule ne répond pas.
assertTrue("$name : fallbackMax doit dépasser min", spec.fallbackMax > spec.min)
}
}
}
}
@@ -0,0 +1,184 @@
package com.evsuite.hardware.catalog
import com.evsuite.hardware.FirmwareGen
import com.evsuite.hardware.FirmwareSupport
import com.evsuite.hardware.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* Writes `docs/catalogue.md`: every condition and every action the apps actually carry.
*
* The list is generated for the same reason the firmware matrix is. A hand-written catalogue
* drifts the first time an entry is added — and it drifted: the published one still promised
* an "AM or FM" tune action months after DAB was reachable, and named none of the twenty
* entries added after it was written. Rendering it from the enums makes that impossible.
*
* Labels are resource ids, which the JVM has no resource table for, so the names come back
* through `R.string` by reflection and the English text out of the `values/` file. That is
* the same text the car shows on its default locale, which is what a reader of the docs is
* trying to match against their screen.
*/
class CatalogueDocTest {
/** Column order, and the wording, of the firmware column. */
private val columns = listOf(
FirmwareGen.SWI133, FirmwareGen.SWI68, FirmwareGen.SWI165,
FirmwareGen.SWI69, FirmwareGen.SWI131, FirmwareGen.SWI132
)
/** Resource id → resource name, from the generated `R.string` class. */
private val resourceNames: Map<Int, String> = R.string::class.java.fields
.filter { it.type == Int::class.javaPrimitiveType }
.associate { (it.get(null) as Int) to it.name }
/** Resource name → English text, from the file the library ships as its default locale. */
private val strings: Map<String, String> = File("src/main/res/values/strings.xml")
.readText()
.let { Regex("""<string name="([^"]+)"[^>]*>(.*?)</string>""", RegexOption.DOT_MATCHES_ALL).findAll(it) }
.associate { it.groupValues[1] to it.groupValues[2].unescape() }
private fun String.unescape(): String = replace("&amp;", "&")
.replace("&lt;", "<").replace("&gt;", ">")
.replace("&#8230;", "…").replace("\\'", "'")
.replace(Regex("\\s+"), " ").trim()
private fun label(res: Int): String = strings[resourceNames[res]] ?: "?"
/** What the user is asked for, in words rather than in the enum's own vocabulary. */
private fun value(spec: ValueSpec): String {
val unit = spec.unitRes.takeIf { it != 0 }?.let { " " + label(it) }.orEmpty()
return when (spec.kind) {
ValueKind.NONE -> "—"
ValueKind.BOOL -> "on / off"
ValueKind.NUMBER -> if (spec.max >= 0) "${spec.min}…${spec.max}$unit" else "0…max$unit"
// FIRMWARE_GEN is the one list the catalogue does not hold: the editor fills it
// from the generations the library knows, so there is nothing to print here.
ValueKind.ENUM -> spec.options.joinToString(", ") { label(it.labelRes) }
.ifEmpty { "a list the app fills in" }
ValueKind.RADIO ->
"band (" + spec.options.joinToString(", ") { label(it.labelRes) } + "), frequency, play"
ValueKind.TIME -> "a time of day"
ValueKind.TIME_RANGE -> "a start and an end time"
ValueKind.DAYS -> "weekdays"
ValueKind.DATE -> "a calendar date"
ValueKind.BT_DEVICE -> "a paired phone"
ValueKind.PROFILE -> "an EVProfile profile"
ValueKind.RULE -> "one of your rules"
ValueKind.APP -> "an installed app"
ValueKind.CONTACT -> "a number or a contact"
ValueKind.SMS -> "a number or a contact, and a message"
ValueKind.TEXT -> "free text"
ValueKind.CONFIRM -> "a question, and how long to wait"
ValueKind.DESTINATION -> "an address or coordinates"
ValueKind.LOCATION -> "a point and a radius" + unit.trim().let { if (it.isEmpty()) "" else " ($it)" }
ValueKind.WEBHOOK -> "a URL, and an optional body"
ValueKind.PHYSICAL_BUTTON -> "a button, short or long press"
}
}
/** The generations an entry is annotated for; "any" when it depends on no firmware. */
private fun firmware(gens: Set<FirmwareGen>?): String = when (gens) {
null -> "any"
else -> columns.filter { it in gens }.joinToString(", ") { it.name }.ifEmpty { "none" }
}
private fun render(): String = buildString {
appendLine("<!-- GENERATED by CatalogueDocTest; do not edit by hand. -->")
appendLine()
appendLine("# The EVTasker catalogue")
appendLine()
appendLine(
"Every condition a rule can test and every action it can run, straight out of " +
"`ConditionType` and `ActionType`. *Firmware* is what the entry is annotated " +
"for; \"any\" means it depends on no vehicle firmware at all, not that it needs " +
"nothing — a notification still needs the permission, a call still needs a " +
"paired phone. The car's own Diagnostic screen stays the only answer for the " +
"car in front of you."
)
appendLine()
appendLine("## Conditions")
ConditionGroup.entries.forEach { group ->
val entries = ConditionType.entries.filter { it.group == group }
if (entries.isEmpty()) return@forEach
appendLine()
appendLine("### ${label(group.labelRes)}")
appendLine()
appendLine("| Condition | Entry | Tested against | Firmware |")
appendLine("|---|---|---|---|")
entries.forEach {
val compare = if (it.comparable) " (=, <, >)" else ""
appendLine(
"| ${label(it.labelRes)} | `${it.name}` | ${value(it.spec)}$compare | " +
"${firmware(FirmwareSupport.gensOf(it))} |"
)
}
}
appendLine()
appendLine(
"A reading the car does not answer makes the rule **not evaluable**: it does not " +
"fire, and it does not count as false."
)
appendLine()
appendLine("## Actions")
ActionGroup.entries.forEach { group ->
val entries = ActionType.entries.filter { it.group == group }
if (entries.isEmpty()) return@forEach
appendLine()
appendLine("### ${label(group.labelRes)}")
appendLine()
appendLine("| Action | Entry | Asks for | When stopped only | Firmware |")
appendLine("|---|---|---|:--:|---|")
entries.forEach {
val unproven = if (it.writeProven) "" else " ⚠️"
appendLine(
"| ${label(it.labelRes)}$unproven | `${it.name}` | ${value(it.spec)} | " +
"${if (it.gated) "yes" else "—"} | ${firmware(FirmwareSupport.gensOf(it))} |"
)
}
}
appendLine()
appendLine(
"⚠️ — the firmware carries the property, but nothing establishes that writing it " +
"does anything (`ActionType.writeProven`). Those actions are hidden from the " +
"editor and refused by the executor until one is observed to work."
)
appendLine()
appendLine(
"The per-generation grid is in [firmware-matrix.md](firmware-matrix.md), generated " +
"from the same annotations."
)
}
/**
* Rewrites the catalogue from the enums — the same contract the firmware matrix has, so
* one test run repairs both.
*
* It **repairs** rather than fails: a stale copy is written back and the assertion then
* passes, which is why running the tests is what keeps the file honest and why the file
* has to be committed with the change that moved it. The assertion is there for the write
* itself — a read-only checkout, or a path that moved, must not pass silently.
*/
@Test
fun `the catalogue document is up to date`() {
val generated = render()
val file = File("../docs/catalogue.md")
if (!file.exists() || file.readText().trim() != generated.trim()) {
file.parentFile?.mkdirs()
file.writeText(generated)
}
assertEquals(generated.trim(), file.readText().trim())
}
@Test
fun `every catalogue entry has a label the document can print`() {
// A missing string would render as "?" and publish a nameless row. The resource
// exists at build time either way, so only this catches it.
(ConditionType.entries.map { it.name to it.labelRes } +
ActionType.entries.map { it.name to it.labelRes }).forEach { (name, res) ->
assertTrue("$name has no English label", label(res) != "?" && label(res).isNotBlank())
}
}
}
@@ -0,0 +1,62 @@
package com.evsuite.hardware.catalog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The classifier is what makes the weather condition writable, so what it must get right is
* the phrase the head unit actually answers — in whichever of the shipped languages the car
* is set to, and with the qualifiers the provider likes to add.
*/
class WeatherConditionsTest {
@Test
fun `a phrase is read whatever the language the head unit answers in`() {
listOf(
"Light rain" to WeatherConditions.RAIN,
"Pluie faible" to WeatherConditions.RAIN,
"Leichter Regen" to WeatherConditions.RAIN,
"Lluvia débil" to WeatherConditions.RAIN,
"Pioggia leggera" to WeatherConditions.RAIN,
"Chuva fraca" to WeatherConditions.RAIN,
"Sunny" to WeatherConditions.CLEAR,
"Ensoleillé" to WeatherConditions.CLEAR,
"Bewölkt" to WeatherConditions.CLOUDY,
"Ciel couvert" to WeatherConditions.CLOUDY,
"Brouillard" to WeatherConditions.FOG,
"Nebbia" to WeatherConditions.FOG,
"Vent fort" to WeatherConditions.WIND,
"Neige" to WeatherConditions.SNOW
).forEach { (phrase, expected) ->
assertEquals(phrase, expected, WeatherConditions.classify(phrase))
}
}
@Test
fun `a phrase naming two things is read as the one a rule is about`() {
// Order is the design: the first match wins, so the specific weather has to be tried
// before the shower or the sky it comes with.
assertEquals(WeatherConditions.STORM, WeatherConditions.classify("Thunderstorm with rain"))
assertEquals(WeatherConditions.SNOW, WeatherConditions.classify("Snow showers"))
assertEquals(WeatherConditions.SNOW, WeatherConditions.classify("Schneeschauer"))
assertEquals(WeatherConditions.RAIN, WeatherConditions.classify("Rain and cloudy"))
}
@Test
fun `an unrecognised phrase is not classified`() {
// Never CLEAR by default: an unknown phrase turned into fine weather is the one
// wrong answer, because it reads as a positive statement about the sky.
assertNull(WeatherConditions.classify(""))
assertNull(WeatherConditions.classify(" "))
assertNull(WeatherConditions.classify("Unavailable"))
}
@Test
fun `every state the picker offers can come out of the classifier`() {
val reachable = WeatherConditions.OPTIONS.map { it.value }.toSet()
assertEquals(reachable, listOf(
"Clear", "Cloudy", "Rain", "Snow", "Thunderstorm", "Fog", "Windy"
).mapNotNull { WeatherConditions.classify(it) }.toSet())
}
}
@@ -0,0 +1,106 @@
package com.evsuite.hardware.diag
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.evsuite.hardware.AppLogger
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* An oversized crash report must lose its TAIL, not its head: the exception and its stack
* trace sit at the top of the file, and they are the only lines that explain the crash.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class CrashLoggerTest {
private val maxBytes = 64 * 1024
private fun context(): Context = ApplicationProvider.getApplicationContext()
@Test
fun `an oversized report keeps the exception and the stack trace`() {
val context = context()
val boom = IllegalStateException("BOOM unique marker")
// The volume comes from the AppLogger section, which is at the END of the report:
// that is what must be sacrificed, not the exception preceding it.
AppLogger.clear()
repeat(100) { AppLogger.i("NOISE", "x".repeat(2_000)) }
CrashLogger.write(context, "EVTest", Thread.currentThread(), boom)
val written = CrashLogger.read(context) ?: error("no report written")
assertTrue("the header was truncated", written.contains("EVTest crash report"))
assertTrue("the exception was truncated", written.contains("BOOM unique marker"))
assertTrue("the stack trace was truncated", written.contains("CrashLoggerTest"))
assertTrue("the truncation is not reported", written.contains("report truncated"))
}
@Test
fun `the cause chain is spelled out`() {
val context = context()
val root = IllegalArgumentException("ROOT unique marker")
AppLogger.clear()
CrashLogger.write(context, "EVTest", Thread.currentThread(), RuntimeException("wrapper", root))
val written = CrashLogger.read(context) ?: error("no report written")
assertTrue(written.contains("-- Caused by --"))
assertTrue(written.contains("ROOT unique marker"))
}
@Test
fun `read and clear track whether a report exists`() {
val context = context()
AppLogger.clear()
CrashLogger.write(context, "EVTest", Thread.currentThread(), IllegalStateException("boom"))
assertTrue(CrashLogger.hasReport(context))
CrashLogger.clear(context)
assertFalse(CrashLogger.hasReport(context))
assertEquals(null, CrashLogger.read(context))
}
@Test
fun `a normally sized report is left untouched`() {
val content = "small report"
assertEquals(content, String(CrashLogger.truncate(content), Charsets.UTF_8))
}
@Test
fun `truncation respects the byte ceiling`() {
// Accented characters are two bytes each in UTF-8. Counting characters instead
// would write almost twice the announced ceiling.
val content = "é".repeat(maxBytes)
val truncated = CrashLogger.truncate(content)
assertTrue("ceiling exceeded: ${truncated.size}", truncated.size <= maxBytes)
}
@Test
fun `truncation keeps the beginning and not the end`() {
val content = "HEAD_MARKER" + "x".repeat(maxBytes * 2) + "TAIL_MARKER"
val truncated = String(CrashLogger.truncate(content), Charsets.UTF_8)
assertTrue(truncated.startsWith("HEAD_MARKER"))
assertFalse("the tail should have been cut", truncated.contains("TAIL_MARKER"))
}
@Test
fun `truncation never cuts a multi-byte character in half`() {
// Two and three bytes per character. Where the cut lands relative to a character
// boundary depends on the marker's own length, so a single width can happen to
// land cleanly and never exercise the backup loop; two widths cannot both.
for (glyph in listOf("é", "€")) {
val truncated = CrashLogger.truncate(glyph.repeat(maxBytes))
assertFalse(
"character cut in half for $glyph",
String(truncated, Charsets.UTF_8).contains('�')
)
assertTrue("ceiling exceeded for $glyph: ${truncated.size}", truncated.size <= maxBytes)
}
}
}
@@ -0,0 +1,180 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonParser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.security.SecureRandom
import java.util.Base64
import java.util.zip.Inflater
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* The paste format is the contract with a server this project does not own, and the failure
* mode is silent: a wrong `adata`, a zlib header where raw DEFLATE was expected, or a
* reordered field all upload cleanly and produce a paste nobody can open. So the wire body
* is decrypted here with primitives spelled out independently of [PrivateBin] — if the two
* ever drift, this fails instead of the person holding the link.
*/
class PrivateBinTest {
private val config = PrivateBin.Config(
baseUrl = "https://paste.example.org/",
password = "correct horse",
expire = "1hour",
formatter = "markdown",
)
/** Deterministic bytes, so the test knows the key it must reproduce the fragment from. */
private fun fixedRandom(): SecureRandom = object : SecureRandom() {
private val source = java.util.Random(1234)
override fun nextBytes(bytes: ByteArray) = source.nextBytes(bytes)
}
private fun keysOf(): Triple<ByteArray, ByteArray, ByteArray> {
val random = fixedRandom()
val key = ByteArray(32).also(random::nextBytes)
val iv = ByteArray(16).also(random::nextBytes)
val salt = ByteArray(8).also(random::nextBytes)
return Triple(key, iv, salt)
}
@Test
fun `the body sent is PrivateBin v2 markdown expiring in one hour`() {
var sent = ""
PrivateBin.paste("hello", config, fixedRandom()) { _, body ->
sent = body
"""{"status":0,"id":"abc","url":"/?abc"}"""
}
val json = JsonParser.parseString(sent).asJsonObject
assertEquals(2, json.get("v").asInt)
assertEquals("1hour", json.getAsJsonObject("meta").get("expire").asString)
val adata = json.getAsJsonArray("adata")
val spec = adata[0].asJsonArray
assertEquals(100_000, spec[2].asInt)
assertEquals(256, spec[3].asInt)
assertEquals(128, spec[4].asInt)
assertEquals("aes", spec[5].asString)
assertEquals("gcm", spec[6].asString)
assertEquals("zlib", spec[7].asString)
assertEquals("markdown", adata[1].asString)
}
@Test
fun `the rendered link carries the key as a base58 fragment`() {
val outcome = PrivateBin.paste("hello", config, fixedRandom()) { _, _ ->
"""{"status":0,"id":"abc","url":"/?abc"}"""
}
val (key, _, _) = keysOf()
assertEquals(
PrivateBin.Outcome.Ok("https://paste.example.org/?abc#" + base58(key)),
outcome
)
}
@Test
fun `the text decrypts with the fragment key and the password`() {
var sent = ""
PrivateBin.paste("# title\n\nbody", config, fixedRandom()) { _, body ->
sent = body
"""{"status":0,"id":"abc","url":"/?abc"}"""
}
val json = JsonParser.parseString(sent).asJsonObject
val (key, iv, salt) = keysOf()
// The AAD is the exact JSON text of adata as it was sent — not a re-serialisation.
// That is where compatibility with the reader's browser is won or lost.
val aad = sent.substringAfter("\"adata\":").substringBefore(",\"ct\":")
val derived = pbkdf2(key + config.password.toByteArray(), salt)
val plain = Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.DECRYPT_MODE, SecretKeySpec(derived, "AES"), GCMParameterSpec(128, iv))
updateAAD(aad.toByteArray())
doFinal(Base64.getDecoder().decode(json.get("ct").asString))
}
val payload = JsonParser.parseString(String(rawInflate(plain))).asJsonObject
assertEquals("# title\n\nbody", payload.get("paste").asString)
}
@Test
fun `a server refusal reports its reason`() {
val outcome = PrivateBin.paste("hello", config, fixedRandom()) { _, _ ->
"""{"status":1,"message":"paste too large"}"""
}
assertEquals(PrivateBin.Outcome.Failed("paste too large"), outcome)
}
@Test
fun `a network failure does not surface as an exception`() {
val outcome = PrivateBin.paste("hello", config, fixedRandom()) { _, _ ->
throw java.net.UnknownHostException("paste.example.org")
}
assertTrue(outcome is PrivateBin.Outcome.Failed)
}
/**
* Live check against the real instance. Skipped unless `EV_LIVE_PASTE=1`, because CI
* has no business uploading anything — run it by hand after touching the format.
*/
@Test
fun `a real upload to paste chapril org is accepted`() {
assumeTrue(System.getenv("EV_LIVE_PASTE") == "1")
val outcome = PrivateBin.paste(
"# EVHardware\n\nlive format check\n",
PrivateBin.Config("https://paste.chapril.org/", "evtaskerR0ck\$", "1hour", "markdown")
)
assertTrue("upload refused: $outcome", outcome is PrivateBin.Outcome.Ok)
println("live paste: " + (outcome as PrivateBin.Outcome.Ok).url)
}
// -------------------------------------------------------------------------
private fun pbkdf2(secret: ByteArray, salt: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret, "HmacSHA256")) }
var u = mac.doFinal(salt + byteArrayOf(0, 0, 0, 1))
val acc = u.copyOf()
repeat(99_999) {
u = mac.doFinal(u)
for (i in acc.indices) acc[i] = (acc[i].toInt() xor u[i].toInt()).toByte()
}
return acc
}
private fun rawInflate(data: ByteArray): ByteArray {
val inflater = Inflater(true)
return try {
inflater.setInput(data)
val out = ByteArrayOutputStream()
val buffer = ByteArray(8 * 1024)
while (!inflater.finished()) out.write(buffer, 0, inflater.inflate(buffer))
out.toByteArray()
} finally {
inflater.end()
}
}
private fun base58(data: ByteArray): String {
val alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
var value = java.math.BigInteger(1, data)
val out = StringBuilder()
while (value.signum() > 0) {
val (q, r) = value.divideAndRemainder(java.math.BigInteger.valueOf(58))
out.append(alphabet[r.toInt()])
value = q
}
for (byte in data) {
if (byte.toInt() != 0) break
out.append(alphabet[0])
}
return out.reverse().toString()
}
}
@@ -0,0 +1,94 @@
package com.evsuite.hardware.probe
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* What the survey has to get right off the car: the three verdicts stay distinct, and the
* harvested transaction table has no collisions.
*
* And the one thing that must never be true: no transaction the survey calls may be one that
* changes something. The power interface carries a shutdown request two codes away from a
* getter, and a survey is run on a car with a driver in it.
*
* None of this can be checked on the vehicle, because on the vehicle a wrong answer looks like a
* finding. A silent binder reported as ANSWERED would put an empty row in the survey that reads
* like "this interface has nothing to say", and two option getters sharing a code would put a
* value under the wrong name — both would survive the drive and mislead whoever reads the
* capture afterwards.
*/
class RuntimeInterfaceProbeTest {
@Test
fun `no binder is absent, not denied`() {
assertEquals(
BindVerdict.ABSENT,
RuntimeInterfaceProbe.verdictFor(reached = false, answers = listOf(null)),
)
}
@Test
fun `a binder that answers nothing is denied, not answered empty`() {
assertEquals(
BindVerdict.DENIED,
RuntimeInterfaceProbe.verdictFor(reached = true, answers = listOf(null, null, null)),
)
}
@Test
fun `one answer among many is enough to call it alive`() {
assertEquals(
BindVerdict.ANSWERED,
RuntimeInterfaceProbe.verdictFor(reached = true, answers = listOf(null, "0", null)),
)
}
@Test
fun `a reached binder with nothing asked of it is denied`() {
assertEquals(
BindVerdict.DENIED,
RuntimeInterfaceProbe.verdictFor(reached = true, answers = emptyList()),
)
}
@Test
fun `the survey never names a transaction that changes something`() {
val calls = RuntimeInterfaceProbe.POWER_CALLS
val writes = calls.filterValues { it.code in RuntimeInterfaceProbe.POWER_WRITES }
assertEquals(emptyMap<String, RuntimeInterfaceProbe.Call>(), writes)
}
@Test
fun `the property survey names no setter`() {
val writes = RuntimeInterfaceProbe.VEHICLE_PROPERTY_CALLS
.filterValues { it.code in RuntimeInterfaceProbe.PROPERTY_WRITES }
assertEquals(emptyMap<String, RuntimeInterfaceProbe.Call>(), writes)
}
@Test
fun `the charging survey never starts, stops or limits a charge`() {
val writes = RuntimeInterfaceProbe.CHARGING_CALLS
.filterValues { it.code in RuntimeInterfaceProbe.CHARGING_WRITES }
assertEquals(emptyMap<String, RuntimeInterfaceProbe.Call>(), writes)
}
@Test
fun `every property read carries the id it is asking for`() {
val idless = RuntimeInterfaceProbe.VEHICLE_PROPERTY_CALLS.filterValues { it.args.size != 1 }
assertEquals(emptyMap<String, RuntimeInterfaceProbe.Call>(), idless)
}
@Test
fun `vehicle speed is read as a float`() {
assertEquals(
RuntimeInterfaceProbe.ReadAs.FLOAT,
RuntimeInterfaceProbe.EVS_CALLS.getValue("getVehicleSpeed").readAs,
)
}
@Test
fun `each config getter has its own transaction code`() {
val calls = RuntimeInterfaceProbe.CONFIG_CALLS
assertEquals(calls.size, calls.values.map { it.code }.toSet().size)
}
}
@@ -0,0 +1,99 @@
package com.evsuite.hardware.saic
import android.os.Parcel
import android.os.Parcelable
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* The one thing about [SaicNavGuidance.startNavFromEvRoute] that can be proven without a car.
*
* The adapter reads the argument with `Parcel.createTypedArrayList(EVRoutPoiInfo.CREATOR)`, and
* this module writes it by hand — it does not carry the vendor's bean. A layout that is wrong by
* one int is not a compile error and not a crash here: it is an adapter that reads a length off
* the wrong offset and hands the navigation app a route to nowhere.
*
* So the layout is compared against what Android's own `writeTypedList` produces for a bean with
* the same three fields in the same order, which is exactly what `EVRoutPoiInfo.writeToParcel`
* is. If a platform release ever changes that framing, this test says so rather than a drive.
*/
@RunWith(RobolectricTestRunner::class)
// The head unit runs API 28; 34 is what this module's other Robolectric test pins and what
// the sandbox has. Parcel framing has not moved between them, which is the point of the test.
@Config(sdk = [34])
class EvRoutPoiParcelTest {
/** `EVRoutPoiInfo`'s own serialisation: latitude, longitude, name. Nothing else is in it. */
private class Stand(val poi: NavigationHandoff.Poi) : Parcelable {
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeDouble(poi.latitude)
dest.writeDouble(poi.longitude)
dest.writeString(poi.name)
}
}
private fun handWritten(pois: List<NavigationHandoff.Poi>): ByteArray =
bytes { parcel -> with(SaicNavGuidance) { parcel.writeEvRoutPoiList(pois) } }
private fun framework(pois: List<NavigationHandoff.Poi>): ByteArray =
bytes { parcel -> parcel.writeTypedList(pois.map(::Stand)) }
private fun bytes(write: (Parcel) -> Unit): ByteArray {
val parcel = Parcel.obtain()
return try {
write(parcel)
parcel.marshall()
} finally {
parcel.recycle()
}
}
@Test fun `one destination is framed the way the adapter reads it`() {
val pois = listOf(NavigationHandoff.Poi(43.5583, 1.5333, "Auzielle"))
assertArrayEquals(framework(pois), handWritten(pois))
}
@Test fun `a pathway of several stops keeps its order and its framing`() {
val pois = listOf(
NavigationHandoff.Poi(43.343, 3.215, "Ionity Beziers Est"),
NavigationHandoff.Poi(44.128, 4.081, "Ales"),
)
assertArrayEquals(framework(pois), handWritten(pois))
}
@Test fun `an empty pathway is a count of zero, not an absent argument`() {
// The adapter reads two lists whatever happens; a route with no waypoint still has to
// write the first one, or the destination is read off the wrong offset.
assertArrayEquals(framework(emptyList()), handWritten(emptyList()))
}
@Test fun `an accented name survives the framing`() {
val pois = listOf(NavigationHandoff.Poi(43.6, 3.9, "Alès Centre"))
assertArrayEquals(framework(pois), handWritten(pois))
}
/**
* `goTo` writes three plain values, and the only way to get them wrong is the order. Read
* back exactly as `IGeneralService.Stub` reads them: `readString`, then latitude, then
* longitude. A latitude and a longitude both fit in a double, so nothing else would catch it.
*/
@Test fun `goTo names the place first, then latitude, then longitude`() {
val parcel = Parcel.obtain()
try {
with(SaicNavGuidance) {
parcel.writeGoTo(NavigationHandoff.Poi(43.5583, 1.5333, "Auzielle"))
}
parcel.setDataPosition(0)
assertEquals("Auzielle", parcel.readString())
assertEquals(43.5583, parcel.readDouble(), 0.0)
assertEquals(1.5333, parcel.readDouble(), 0.0)
} finally {
parcel.recycle()
}
}
}
@@ -0,0 +1,154 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class NavGuidanceReducerTest {
private fun fold(code: Int, first: Int? = null, second: Int? = null, text: String? = null) =
NavGuidanceReducer.fold(NavGuidance.EMPTY, code, first, second, text, atElapsedMs = 1_000L)
@Test fun `remaining distance and time land in their own fields`() {
val distance = fold(NavGuidanceReducer.TX_REMAINING_DISTANCE_CHANGE, first = 143)
assertEquals(143, distance.remainingDistanceRaw)
assertNull("distance must not be mistaken for a duration", distance.remainingMinutes)
val minutes = fold(NavGuidanceReducer.TX_REMAINING_TIMES_CHANGE, first = 96)
assertEquals(96, minutes.remainingMinutes)
assertNull("duration must not be mistaken for a distance", minutes.remainingDistanceRaw)
}
@Test fun `guide infos keep the turn distance apart from the trip distance`() {
val state = fold(
NavGuidanceReducer.TX_GUIDE_INFOS_CHANGE,
first = 7, second = 300, text = "D906",
)
assertEquals(7, state.nextTurnIcon)
assertEquals(300, state.nextTurnDistanceRaw)
assertEquals("D906", state.direction)
// The whole point of the separation: 300 m to a turn is not 300 of anything to Alès.
assertNull(state.remainingDistanceRaw)
}
@Test fun `an unknown transaction changes nothing at all`() {
val before = NavGuidance.EMPTY.copy(remainingDistanceRaw = 143, events = 4)
val after = NavGuidanceReducer.fold(before, code = 99, first = 1, atElapsedMs = 2_000L)
assertSame(before, after)
}
@Test fun `folding accumulates a trip without losing earlier fields`() {
var state = NavGuidance.EMPTY
state = NavGuidanceReducer.fold(
state, NavGuidanceReducer.TX_GUIDE_STATUS_CHANGE, first = 1, atElapsedMs = 10L,
)
state = NavGuidanceReducer.fold(
state, NavGuidanceReducer.TX_REMAINING_DISTANCE_CHANGE, first = 143, atElapsedMs = 20L,
)
state = NavGuidanceReducer.fold(
state, NavGuidanceReducer.TX_REMAINING_TIMES_CHANGE, first = 96, atElapsedMs = 30L,
)
state = NavGuidanceReducer.fold(
state, NavGuidanceReducer.TX_ROAD_INFO_CHANGE, text = "A61", atElapsedMs = 40L,
)
assertEquals(1, state.guideStatus)
assertEquals(143, state.remainingDistanceRaw)
assertEquals(96, state.remainingMinutes)
assertEquals("A61", state.road)
assertEquals(4, state.events)
assertEquals(40L, state.updatedAtElapsedMs)
}
@Test fun `nothing seen yet is null everywhere rather than zero`() {
assertNull(NavGuidance.EMPTY.remainingDistanceRaw)
assertNull(NavGuidance.EMPTY.remainingMinutes)
assertNull(NavGuidance.EMPTY.guideStatus)
assertNull(NavGuidance.EMPTY.updatedAtElapsedMs)
assertEquals(0, NavGuidance.EMPTY.events)
}
@Test fun `the known transaction set matches the decoded callbacks`() {
assertEquals(setOf(1, 2, 3, 12, 13), NavGuidanceReducer.KNOWN_TRANSACTIONS)
}
}
class TransactionCensusTest {
@Test fun `every code is counted, decoded or not`() {
val census = TransactionCensus()
census.record(NavGuidanceReducer.TX_REMAINING_DISTANCE_CHANGE)
census.record(NavGuidanceReducer.TX_REMAINING_DISTANCE_CHANGE)
census.record(41)
assertEquals(mapOf(13 to 2, 41 to 1), census.snapshot())
assertEquals(0, census.beyondCeiling)
}
@Test fun `a code past the ceiling is separated rather than dropped`() {
val census = TransactionCensus(ceiling = 16)
census.record(99)
census.record(-1)
assertEquals(emptyMap<Int, Int>(), census.snapshot())
assertEquals(2, census.beyondCeiling)
}
@Test fun `a shifted map shows as traffic on undecoded codes`() {
// What an inserted method one revision earlier would look like: everything this build
// decodes goes quiet, and the codes one above it carry the traffic instead.
val census = TransactionCensus()
listOf(2, 3, 4, 13, 14).forEach(census::record)
val seen = census.snapshot().keys
val decoded = NavGuidanceReducer.KNOWN_TRANSACTIONS
assertEquals(setOf(4, 14), seen - decoded)
}
@Test fun `clearing forgets the previous capture`() {
val census = TransactionCensus()
census.record(13, payloadBytes = 48)
census.record(999)
census.clear()
assertEquals(emptyMap<Int, Int>(), census.snapshot())
assertEquals(emptyMap<Int, Int>(), census.payloadBytes())
assertEquals(0, census.beyondCeiling)
}
/**
* The 2026-09-07 capture read a remaining distance of zero while guidance was running, and
* nothing in it could say whether the car meant zero or this build read the wrong shape. The
* size does say: a parcel of one int is the descriptor plus four bytes.
*/
@Test fun `the last parcel size is kept per code, and an unmeasured code has none`() {
val census = TransactionCensus()
census.record(13, payloadBytes = 48)
census.record(13, payloadBytes = 96)
census.record(11, payloadBytes = 0)
census.record(12)
assertEquals(mapOf(11 to 0, 13 to 96), census.payloadBytes())
assertEquals(mapOf(11 to 1, 12 to 1, 13 to 2), census.snapshot())
}
}
class NavGuidanceDistanceUnitTest {
/** The 2026-09-04 capture: 9788 remaining, 25 minutes, "Rue de la Fontaine". */
private val seen = NavGuidance(remainingDistanceRaw = 9788, remainingMinutes = 25)
@Test fun `a metres generation reports kilometres`() {
val km = seen.remainingDistanceKm(com.evsuite.hardware.FirmwareInfo.Gen.SWI68)!!
assertEquals(9.788, km, 1e-9)
// The reading that makes it metres: 9.788 km in 25 minutes is a plausible town route.
assertEquals(23.5, km / (25.0 / 60.0), 0.1)
}
@Test fun `an unlisted generation claims no kilometres at all`() {
com.evsuite.hardware.FirmwareInfo.Gen.entries
.filterNot { it == com.evsuite.hardware.FirmwareInfo.Gen.SWI68 }
.forEach { assertNull("$it", seen.remainingDistanceKm(it)) }
}
@Test fun `no raw distance means no kilometres`() {
assertNull(
NavGuidance.EMPTY.remainingDistanceKm(com.evsuite.hardware.FirmwareInfo.Gen.SWI68)
)
}
}
@@ -0,0 +1,131 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The whole of [NavLegChain]'s behaviour, on the JVM, because the alternative is a drive.
*
* Every case here is a sequence of answers to one question — "is the map guiding" — and what the
* chain does with them. That is all the class knows, so that is all a test needs to give it.
*/
class NavLegChainTest {
private val stop = NavigationHandoff.Poi(43.343, 3.215, "Ionity Beziers Est")
private val destination = NavigationHandoff.Poi(43.5583, 1.5333, "Auzielle")
private fun chain() = NavLegChain(listOf(stop, destination))
/** Drives a leg to its end: guidance running, then the settle window of silence. */
private fun NavLegChain.arrive(): NavLegChain.Step {
tick(guiding = true)
repeat(NavLegChain.SETTLE_TICKS - 1) {
assertEquals(NavLegChain.Step.Wait, tick(guiding = false))
}
return tick(guiding = false)
}
@Test fun `the second leg goes out when guidance for the first one ends`() {
val chain = chain()
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true))
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true))
repeat(NavLegChain.SETTLE_TICKS - 1) {
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false))
}
assertEquals(
NavLegChain.Step.Send(destination, number = 2, count = 2),
chain.tick(guiding = false),
)
}
/**
* The reading that must not move the car. On 2026-09-09 the adapter's remaining distance went
* to nothing seven minutes into a route it was still guiding; a single false from its sibling
* flag would otherwise send the car onward from a charger it is still driving to.
*/
@Test fun `one lone false reading is not an arrival`() {
val chain = chain()
chain.tick(guiding = true)
repeat(NavLegChain.SETTLE_TICKS - 1) {
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false))
}
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true))
// The window starts again from nothing, so the blip cost the leg no ground at all.
repeat(NavLegChain.SETTLE_TICKS - 1) {
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false))
}
assertEquals(
NavLegChain.Step.Send(destination, number = 2, count = 2),
chain.tick(guiding = false),
)
}
@Test fun `nothing goes out while the first leg is still being driven`() {
val chain = chain()
repeat(50) { assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true)) }
}
@Test fun `a leg that never starts is abandoned rather than chained onto`() {
val chain = chain()
repeat(NavLegChain.GIVE_UP_TICKS - 1) {
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false))
}
assertEquals(NavLegChain.Step.Done(arrived = false), chain.tick(guiding = false))
}
@Test fun `guidance appearing late still counts, and the grace restarts with it`() {
val chain = chain()
repeat(NavLegChain.GIVE_UP_TICKS - 1) { chain.tick(guiding = false) }
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = true))
assertEquals(
NavLegChain.Step.Send(destination, number = 2, count = 2),
chain.arrive(),
)
}
@Test fun `the leg handed over gets its own grace before it is given up on`() {
val chain = chain()
chain.arrive()
// The car was just sent somewhere; the map has not come up yet. That silence is not a
// refusal until the grace runs out again, and it must run out again in full.
repeat(NavLegChain.GIVE_UP_TICKS - 1) {
assertEquals(NavLegChain.Step.Wait, chain.tick(guiding = false))
}
assertEquals(NavLegChain.Step.Done(arrived = false), chain.tick(guiding = false))
}
@Test fun `the last leg ending ends the chain, and every later tick says so`() {
val chain = chain()
chain.arrive()
assertEquals(NavLegChain.Step.Done(arrived = true), chain.arrive())
assertEquals(NavLegChain.Step.Done(arrived = true), chain.tick(guiding = true))
}
@Test fun `three legs are driven one after the other, in order`() {
val second = NavigationHandoff.Poi(44.128, 4.081, "Ales")
val chain = NavLegChain(listOf(stop, second, destination))
assertEquals(NavLegChain.Step.Send(second, number = 2, count = 3), chain.arrive())
assertEquals(NavLegChain.Step.Send(destination, number = 3, count = 3), chain.arrive())
assertEquals(NavLegChain.Step.Done(arrived = true), chain.arrive())
}
@Test fun `a trip with one point has nothing to chain`() {
val chain = NavLegChain(listOf(destination))
assertTrue(chain.tick(guiding = true) is NavLegChain.Step.Done)
}
@Test fun `an empty chain is over before it starts`() {
assertEquals(
NavLegChain.Step.Done(arrived = true),
NavLegChain(emptyList()).tick(guiding = false),
)
}
@Test fun `the legs are copied, so the caller's list cannot change the trip`() {
val legs = mutableListOf(stop, destination)
val chain = NavLegChain(legs)
legs.clear()
assertEquals(NavLegChain.Step.Send(destination, number = 2, count = 2), chain.arrive())
}
}
@@ -0,0 +1,109 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.Locale
class NavigationHandoffTest {
@Test fun `a destination becomes a geo uri both readings agree on`() {
assertEquals(
"geo:43.558300,1.533300?q=43.558300,1.533300",
NavigationHandoff.geoUri(43.5583, 1.5333),
)
}
@Test fun `a label travels in the q parameter`() {
assertEquals(
"geo:43.343000,3.215000?q=43.343000,3.215000(Ionity%20Beziers%20Est)",
NavigationHandoff.geoUri(43.343, 3.215, "Ionity Beziers Est"),
)
}
@Test fun `a french locale does not turn the decimal point into a field separator`() {
val previous = Locale.getDefault()
Locale.setDefault(Locale.FRANCE)
try {
assertEquals(
"geo:43.558300,1.533300?q=43.558300,1.533300",
NavigationHandoff.geoUri(43.5583, 1.5333),
)
} finally {
Locale.setDefault(previous)
}
}
@Test fun `a label cannot end itself or add a parameter`() {
assertEquals(
"geo:43.343000,3.215000?q=43.343000,3.215000(Total%20Energies%20A9)",
NavigationHandoff.geoUri(43.343, 3.215, "Total (Energies) &A9"),
)
}
@Test fun `an accented place name keeps its accents`() {
// Carried literally rather than percent-encoded. Android's Uri is lenient about this and
// a French place name stripped of its accents is a different name on a map.
assertEquals(
"geo:43.343000,3.215000?q=43.343000,3.215000(Béziers)",
NavigationHandoff.geoUri(43.343, 3.215, "Béziers"),
)
}
@Test fun `a label of nothing but punctuation is no label at all`() {
assertEquals(
"geo:43.343000,3.215000?q=43.343000,3.215000",
NavigationHandoff.geoUri(43.343, 3.215, "()&"),
)
}
@Test fun `coordinates that are not a place on earth are refused`() {
// The common swap: a French longitude read as a latitude stays in range, but a latitude
// read as a longitude does not always, and neither does anything from a parse failure.
assertNull(NavigationHandoff.geoUri(191.0, 3.0))
assertNull(NavigationHandoff.geoUri(43.0, 210.0))
assertNull(NavigationHandoff.geoUri(Double.NaN, 3.0))
assertNull(NavigationHandoff.geoUri(43.0, Double.POSITIVE_INFINITY))
}
@Test fun `the poles and the meridian are places`() {
assertEquals("geo:-90.000000,-180.000000?q=-90.000000,-180.000000", NavigationHandoff.geoUri(-90.0, -180.0))
assertEquals("geo:0.000000,0.000000?q=0.000000,0.000000", NavigationHandoff.geoUri(0.0, 0.0))
}
@Test fun `a point keeps its name as the driver reads it, not as a uri would`() {
// The URI form replaces the parenthesis and escapes the space; the adapter form is a
// parcel field and must not, or the map shows a name nobody wrote.
assertEquals(
NavigationHandoff.Poi(43.343, 3.215, "Ionity Beziers Est (A75)"),
NavigationHandoff.poi(43.343, 3.215, "Ionity Beziers Est (A75)"),
)
}
@Test fun `a name that is only whitespace falls back to the point itself`() {
assertEquals("43.343000,3.215000", NavigationHandoff.poi(43.343, 3.215, " ")?.name)
assertEquals("43.343000,3.215000", NavigationHandoff.poi(43.343, 3.215)?.name)
}
@Test fun `control characters and doubled spaces cannot travel in a name`() {
assertEquals("A B", NavigationHandoff.poi(43.0, 3.0, "A\u0000 \n B")?.name)
}
@Test fun `a point that is not on Earth is refused, exactly as the uri form refuses it`() {
assertNull(NavigationHandoff.poi(91.0, 3.0, "north of the pole"))
assertNull(NavigationHandoff.poi(43.0, 181.0, "past the date line"))
assertNull(NavigationHandoff.poi(Double.NaN, 3.0, "nowhere"))
}
@Test fun `a name longer than a label is cut before it becomes a payload`() {
val long = "x".repeat(200)
assertEquals(48, NavigationHandoff.poi(43.0, 3.0, long)?.name?.length)
}
@Test fun `the pathway is capped rather than refused`() {
val stops = List(20) { NavigationHandoff.Poi(43.0, 3.0, "stop $it") }
assertEquals(NavigationHandoff.MAX_PATHWAY_POINTS, NavigationHandoff.pathway(stops).size)
// The cap keeps the first stops: a plan is in the order it is driven.
assertEquals("stop 0", NavigationHandoff.pathway(stops).first().name)
}
}
@@ -0,0 +1,58 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* A driver types a station, not a transaction argument. What the parser must never do is
* turn text it did not understand into a frequency anyway.
*/
class RadioFrequencyTest {
private fun fm(khz: Int) = RadioFrequency.Station(SaicRadio.BAND_FM, khz)
private fun am(khz: Int) = RadioFrequency.Station(SaicRadio.BAND_AM, khz)
@Test
fun `a bare number lands on the only band it can belong to`() {
assertEquals(fm(103_500), RadioFrequency.parse("103.5"))
assertEquals(fm(87_500), RadioFrequency.parse("87.5"))
assertEquals(fm(108_000), RadioFrequency.parse("108"))
assertEquals(am(1080), RadioFrequency.parse("1080"))
assertEquals(am(522), RadioFrequency.parse("522"))
}
@Test
fun `the band may be written out, before or after`() {
assertEquals(fm(103_500), RadioFrequency.parse("FM 103.5"))
assertEquals(fm(103_500), RadioFrequency.parse("103.5 fm"))
assertEquals(am(1080), RadioFrequency.parse("1080 AM"))
}
@Test
fun `a decimal comma is a decimal point`() {
// Five of the six locales this app ships in write it that way.
assertEquals(fm(103_500), RadioFrequency.parse("103,5"))
}
@Test
fun `kilohertz written out is understood too`() {
assertEquals(fm(87_500), RadioFrequency.parse("87500"))
}
@Test
fun `text naming no station is refused rather than approximated`() {
assertNull(RadioFrequency.parse(""))
assertNull(RadioFrequency.parse("Radio 4"))
assertNull(RadioFrequency.parse("250"))
assertNull(RadioFrequency.parse("109.1"))
}
@Test
fun `a band that contradicts its own frequency is refused`() {
// The driver said two things. Silently keeping one of them is how a rule ends up on
// a station nobody chose.
assertNull(RadioFrequency.parse("AM 103.5"))
assertNull(RadioFrequency.parse("FM 1080"))
}
}
@@ -0,0 +1,224 @@
package com.evsuite.hardware.saic
import com.evsuite.hardware.FirmwareGen
import com.evsuite.hardware.FirmwareSupport
import com.evsuite.hardware.catalog.ActionType
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.catalog.SnapshotKeys
import com.evsuite.hardware.catalog.ValueKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The radio family added by CR-020, checked where it can be checked without a car.
*
* [SaicRadio.playPause] itself binds a vendor service, so its direction logic is
* re-implemented here over the same three inputs rather than mocked: what the test defends
* is the decision table — an unreadable state sends nothing — and that table is small enough
* to state twice and compare. The binder round trip is what the on-vehicle run covers.
*/
class RadioToggleTest {
/** [SaicRadio.playPause]'s decision, over a state that may be unreadable. */
private fun toggle(playing: Boolean?, accepted: Boolean): SaicRadio.ToggleResult {
if (playing == null) return SaicRadio.ToggleResult.STATE_UNKNOWN
if (!accepted) return SaicRadio.ToggleResult.REFUSED
return if (playing) SaicRadio.ToggleResult.PAUSED else SaicRadio.ToggleResult.PLAYED
}
@Test
fun `an unreadable state sends nothing in either direction`() {
// The whole point of the action: a rule fires unattended, so a guessed direction is
// a car left silent or left playing with nothing in the history saying it was a guess.
assertEquals(SaicRadio.ToggleResult.STATE_UNKNOWN, toggle(playing = null, accepted = true))
assertEquals(SaicRadio.ToggleResult.STATE_UNKNOWN, toggle(playing = null, accepted = false))
}
@Test
fun `the toggle follows the state the tuner reports`() {
assertEquals(SaicRadio.ToggleResult.PAUSED, toggle(playing = true, accepted = true))
assertEquals(SaicRadio.ToggleResult.PLAYED, toggle(playing = false, accepted = true))
}
@Test
fun `a refused call is not reported as an unknown state`() {
// Two different things to tell the driver: the service refused, or it was never asked.
assertEquals(SaicRadio.ToggleResult.REFUSED, toggle(playing = true, accepted = false))
assertEquals(SaicRadio.ToggleResult.REFUSED, toggle(playing = false, accepted = false))
}
@Test
fun `the band constants are the vendor RadioType values`() {
// tune() passes them straight through; a value invented here tunes the wrong band.
assertEquals(1, SaicRadio.BAND_AM)
assertEquals(2, SaicRadio.BAND_FM)
assertEquals(4, SaicRadio.BAND_DAB)
}
@Test
fun `every radio action reaches the bridge and declares its firmware`() {
RADIO_ACTIONS.forEach {
assertNotNull("${it.name} has no bridgeAction", it.bridgeAction)
assertTrue("${it.name} has no label", it.labelRes != 0)
}
}
@Test
fun `only opening a screen is gated, and the audio actions stay outside the gate`() {
// Audio-only radio commands follow the physical media controls: refusing "next
// station" above 0 km/h would make the action pointless. Opening a full-screen app in
// front of a moving driver is the one that is not audio, and it takes the gate — and
// adding it must not have pulled the rest of the family in with it.
assertTrue("opening the radio screen must be standstill-only", ActionType.OPEN_RADIO_SCREEN.gated)
(RADIO_ACTIONS - ActionType.OPEN_RADIO_SCREEN).forEach {
assertFalse("${it.name} is audio-only and must not take the gate", it.gated)
}
}
@Test
fun `the radio family carries no value to configure, except the tuner setting`() {
// A picker offering a control for an action that takes no argument asks the user for
// something it will then ignore.
(RADIO_ACTIONS - ActionType.TUNE_RADIO).forEach {
assertEquals("${it.name} takes no argument", ValueKind.NONE, it.spec.kind)
}
}
@Test
fun `band, frequency and playback are one entry`() {
// The merge is the point of the action: a band alone could not carry a station, a
// frequency alone could not reach DAB, and a driver saying "FM 103.5, and play it"
// had to write two rows. Splitting them again would put back both halves of the gap.
assertEquals(ValueKind.RADIO, ActionType.TUNE_RADIO.spec.kind)
assertEquals(
"the band picker must offer the vendor RadioType values",
listOf(SaicRadio.BAND_AM, SaicRadio.BAND_FM, SaicRadio.BAND_DAB),
ActionType.TUNE_RADIO.spec.options.map { it.value }
)
assertNull(
"there is no separate band action left to pick",
ActionType.entries.firstOrNull { it.name == "SELECT_RADIO_BAND" }
)
}
@Test
fun `a rule can ask whether the tuner is playing, which the media condition cannot answer`() {
// The whole reason the condition exists. AudioManager.isMusicActive — what
// MEDIA_PLAYING reads — is false while the radio plays, its stream not being the music
// one, so a car with the radio on reads as silent there. Two conditions, two keys, two
// sources: collapsing them would put that false answer behind a radio-shaped label.
assertEquals(ValueKind.BOOL, ConditionType.RADIO_PLAYING.spec.kind)
assertEquals(SnapshotKeys.KEY_RADIO_PLAYING, ConditionType.RADIO_PLAYING.snapshotKey)
assertNotEquals(
ConditionType.MEDIA_PLAYING.snapshotKey,
ConditionType.RADIO_PLAYING.snapshotKey
)
// A vendor-service read, so it is routed per generation — unlike the platform one,
// which answers on every firmware because Android answers it.
assertEquals(
setOf(FirmwareGen.SWI68, FirmwareGen.SWI165),
FirmwareSupport.gensOf(ConditionType.RADIO_PLAYING)
)
assertNull(FirmwareSupport.gensOf(ConditionType.MEDIA_PLAYING))
}
@Test
fun `the picked band decides the frequency, and a disagreement is refused`() {
// The two say the same thing, so the number needs no unit and no band prefix.
assertEquals(
RadioFrequency.Station(SaicRadio.BAND_FM, 103_500),
RadioFrequency.parse("103.5", SaicRadio.BAND_FM)
)
// "1080" alone is AM; asked for on FM it names no station, and inventing one is how a
// rule lands somewhere nobody chose.
assertNull(RadioFrequency.parse("1080", SaicRadio.BAND_FM))
assertNull(RadioFrequency.parse("AM 103.5", SaicRadio.BAND_FM))
// DAB names no frequency at all: the band alone is the whole instruction.
assertNull(RadioFrequency.parse("103.5", SaicRadio.BAND_DAB))
// No band picked is what every rule saved before the merge carries: read the text.
assertEquals(
RadioFrequency.Station(SaicRadio.BAND_AM, 1080),
RadioFrequency.parse("1080", null)
)
}
/** [SaicRadio.selectBand]'s decision, over a current band that may be unreadable. */
private fun band(current: Int?, requested: Int, tuneAccepted: Boolean): SaicRadio.BandResult {
if (!SaicRadio.isKnownBand(requested)) return SaicRadio.BandResult.UNSUPPORTED_BAND
if (current == null) return SaicRadio.BandResult.STATE_UNKNOWN
if (current == requested) return SaicRadio.BandResult.ALREADY_ON_BAND
return if (tuneAccepted) SaicRadio.BandResult.SWITCHED else SaicRadio.BandResult.REFUSED
}
@Test
fun `a band switch sends nothing when the tuner will not say where it is`() {
// Same rule as the play/pause toggle: without the current band there is no way to
// know whether a tune is a switch or a station change nobody asked for.
assertEquals(
SaicRadio.BandResult.STATE_UNKNOWN,
band(current = null, requested = SaicRadio.BAND_DAB, tuneAccepted = true)
)
}
@Test
fun `asking for the band already playing changes no station`() {
// The naive implementation tunes anyway and moves the driver off the station they
// were listening to, for an action that was supposed to be a no-op.
assertEquals(
SaicRadio.BandResult.ALREADY_ON_BAND,
band(current = SaicRadio.BAND_FM, requested = SaicRadio.BAND_FM, tuneAccepted = true)
)
}
@Test
fun `DAB is reachable by band where it is not reachable by frequency`() {
// The point of the action. RadioFrequency.parse names no DAB station — a DAB service
// is an ensemble and service id — but the band itself is one tune() argument away.
assertEquals(
SaicRadio.BandResult.SWITCHED,
band(current = SaicRadio.BAND_FM, requested = SaicRadio.BAND_DAB, tuneAccepted = true)
)
assertNull("no typed text names a DAB station", RadioFrequency.parse("11D"))
}
@Test
fun `an unknown band is refused before anything is sent`() {
assertEquals(
SaicRadio.BandResult.UNSUPPORTED_BAND,
band(current = SaicRadio.BAND_FM, requested = 9, tuneAccepted = true)
)
}
@Test
fun `a refused tune is not reported as a switch`() {
assertEquals(
SaicRadio.BandResult.REFUSED,
band(current = SaicRadio.BAND_AM, requested = SaicRadio.BAND_FM, tuneAccepted = false)
)
}
@Test
fun `the DAB band floor is a real Band III block frequency`() {
// Block 5A. It is only ever a starting point — a switch that lands there steps to the
// first station the tuner lists — but it must be inside Band III or the tune is
// meaningless.
assertTrue(SaicRadio.DAB_BAND_III_MIN_KHZ in 174_000..240_000)
}
private companion object {
val RADIO_ACTIONS = setOf(
ActionType.PLAY_RADIO,
ActionType.PAUSE_RADIO,
ActionType.RADIO_PLAY_PAUSE,
ActionType.TUNE_RADIO,
ActionType.RADIO_NEXT_STATION,
ActionType.RADIO_PREV_STATION,
ActionType.OPEN_RADIO_SCREEN
)
}
}
@@ -0,0 +1,28 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The consumption history arrives as the JSON array the charging service persists, so what is
* tested here is that format rather than this app's idea of it.
*/
class SaicChargingHistoryTest {
@Test fun `the car's own array is read oldest first`() {
assertEquals(
listOf(15.2f, 16.0f, 14.8f),
SaicCharging.parseHistory("[15.2,16.0,14.8]"),
)
}
@Test fun `a car that has never counted answers nothing, not zero`() {
assertEquals(emptyList<Float>(), SaicCharging.parseHistory(""))
assertEquals(emptyList<Float>(), SaicCharging.parseHistory(null))
assertEquals(emptyList<Float>(), SaicCharging.parseHistory("[]"))
}
@Test fun `a reading the service never wrote as a number is dropped, not guessed`() {
assertEquals(listOf(15.2f, 14.8f), SaicCharging.parseHistory("[15.2,null,14.8]"))
}
}
@@ -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="))
}
}