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