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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-19 09:44:00 +02:00
co-authored by Claude Opus 5.5
parent c6e6b3e88e
commit f0f4f57fee
71 changed files with 4646 additions and 191 deletions
@@ -143,12 +143,12 @@ object CarPropertyEvidence {
* between them, zero readings for either signal, and `EV_INSTANTANEOUS_CHARGE_RATE` and
* `EV_BATTERY_AVG_TEMP` answering `no CarPropertyValue` on every property probe.
*
* Consequence worth stating plainly: the CP-030 energy model can never train from battery
* power on this generation, because there is no battery power to integrate. An arrival
* forecast on this car has to come from state of charge per kilometre instead.
* Battery power left this list once the vendor pack voltage × current fallback published:
* −32,9 to +32,4 kW on the urban drive of 2026-10-16, net agreeing with the charge drop.
* The standard property is still silent, but a screen asking "is there battery power"
* now gets "not validated yet", which is the true answer, instead of "never".
*/
private val neverPublishedByFirmware: Map<Signal, Set<FirmwareInfo.Gen>> = mapOf(
Signal.BATTERY_POWER_KW to setOf(FirmwareInfo.Gen.SWI68),
Signal.BATTERY_TEMPERATURE_CELSIUS to setOf(FirmwareInfo.Gen.SWI68),
)
}
@@ -0,0 +1,153 @@
package com.evsuite.hardware
import java.lang.reflect.Method
/**
* The three settings the car exposes only in [com.evsuite.hardware.model.DriveMode.CUSTOM]:
* powertrain response, steering weight and brake-pedal feel (CR-040).
*
* Callers speak in an **index** — 0 Eco/Comfort, 1 Normal, 2 Sport — and never in vehicle
* codes. The codes differ per setting and per platform, and one of them is a trap: the pedal's
* Normal is `0`, not the middle value. The conversion lives in [code] / [index] and nowhere else.
*
* **Per generation.** VSM-based firmwares only, through the same VSM instance [VsmGlass] uses:
*
* • [Family.VSM] — SWI68 / SWI165, `VehicleSettingManager` `…Level` methods
* • [Family.A9] — SWI69 / SWI131 / SWI132, `CarVehicleSettingClient` `…Mode` methods
*
* SWI133 and unknown firmware answer [isAvailable] false. Their route is a raw property id, and
* no id for these settings is verified on this project — writing an unverified id is exactly
* what the library refuses to do. A method name is different in kind: if it is wrong it does
* not resolve, and the setting reports unavailable instead of writing somewhere else.
*
* **Evidence.** The VSM read/write codes (powertrain and steering 1/2/3, pedal 0/1/2) agree
* with RI-006. Which pedal code is Comfort and Sport, and the A9 powertrain offset (2/3/4,
* the drive-mode scale), are community leads (MG4Control 2.6.7, behaviour reference only) and
* stay unproven until an on-vehicle read-after-write confirms them per generation.
*/
object CustomDrive {
enum class Setting { POWER, STEERING, PEDAL }
enum class Family { VSM, A9 }
/** Positions a caller may ask for. */
val INDICES = 0..2
/** Pedal codes by index: Comfort 1, Normal **0**, Sport 2. */
private val PEDAL_CODES = intArrayOf(1, 0, 2)
fun familyOf(gen: FirmwareInfo.Gen): Family? = when (gen) {
FirmwareInfo.Gen.SWI68, FirmwareInfo.Gen.SWI165 -> Family.VSM
FirmwareInfo.Gen.SWI69, FirmwareInfo.Gen.SWI131, FirmwareInfo.Gen.SWI132 -> Family.A9
else -> null
}
/** Vehicle code for [index], or null when [index] is not a position. */
fun code(setting: Setting, index: Int, family: Family): Int? {
if (index !in INDICES) return null
return when (setting) {
Setting.POWER -> index + if (family == Family.A9) 2 else 1
Setting.STEERING -> index + 1
Setting.PEDAL -> PEDAL_CODES[index]
}
}
/** Index for a code read from the car, or null when the code is no known position. */
fun index(setting: Setting, code: Int, family: Family): Int? =
INDICES.firstOrNull { code(setting, it, family) == code }
fun methodName(setting: Setting, family: Family, write: Boolean): String {
val stem = when (family) {
Family.VSM -> when (setting) {
Setting.POWER -> "ElectricPowertrainLevel"
Setting.STEERING -> "SteeringLevel"
Setting.PEDAL -> "BrakePedalLevel"
}
Family.A9 -> when (setting) {
Setting.POWER -> "DrivingPowerTrainMode"
Setting.STEERING -> "SteeringMode"
Setting.PEDAL -> "BrakePedalMode"
}
}
return (if (write) "set" else "get") + stem
}
private val INT = Int::class.javaPrimitiveType!!
// Resolved once per VSM class, like VsmGlass: a profile apply reads and writes all three.
private var owner: Class<*>? = null
private val methods = HashMap<String, Method?>()
@Synchronized
private fun method(vsm: Any, name: String, vararg types: Class<*>): Method? {
if (owner !== vsm.javaClass) {
owner = vsm.javaClass
methods.clear()
}
return methods.getOrPut(name) {
try {
vsm.javaClass.getMethod(name, *types)
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name absent — ${e.javaClass.simpleName}")
null
}
}
}
private fun family(): Family? = familyOf(FirmwareInfo.getGeneration())
/** True when this firmware's VSM carries the setter for [setting]. */
fun isAvailable(setting: Setting): Boolean {
val family = family() ?: return false
val vsm = EVHardware.vsmInstance() ?: return false
return method(vsm, methodName(setting, family, write = true), INT) != null
}
/**
* The car's current position for [setting], or null when it cannot tell.
*
* Null covers an absent method, a failed call, and a code outside the scale — the last one
* is logged raw, because it is the evidence a wrong scale would leave behind.
*/
fun read(setting: Setting): Int? {
val family = family() ?: return null
val vsm = EVHardware.vsmInstance() ?: return null
val name = methodName(setting, family, write = false)
val get = method(vsm, name) ?: return null
val raw = try {
(get.invoke(vsm) as? Number)?.toInt()
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name() exc: ${e.message}")
null
} ?: return null
return index(setting, raw, family).also {
if (it == null && raw >= 0) AppLogger.w(TAG, " $name() = $raw — outside the $family scale")
}
}
/**
* Sets [setting] to position [index]. The car obeys only while it is in CUSTOM; the caller
* decides when that is (EVProfile shows the controls only then).
*/
@RequiresStandstill
fun write(setting: Setting, index: Int): Boolean {
val family = family() ?: return false
val value = code(setting, index, family) ?: return false
val vsm = EVHardware.vsmInstance() ?: return false
val name = methodName(setting, family, write = true)
val set = method(vsm, name, INT) ?: return false
// [T-904] Vehicle write: allowed only when stopped, refused if speed unreadable.
if (!VehicleWriteGate.allow("VSM $name")) return false
return try {
set.invoke(vsm, value)
AppLogger.i(TAG, " $name($value) ← index $index ($family)")
true
} catch (e: Exception) {
AppLogger.w(TAG, " VSM: $name($value) exc: ${e.message}")
false
}
}
private const val TAG = "EV_CUSTOM"
}
@@ -418,12 +418,15 @@ object EVHardware {
/** Total mileage, the vendor's own integer — `PERF_ODOMETER` never publishes here. */
private const val PROP_VENDOR_TOTAL_MILEAGE = 0x21401566
/**
* Charging session state. The vehicle's own values, from the stock charging screen:
* 1 = AC charging, 10 = DC charging, 5 = connecting, 7 = connected but not charging,
* 8 = stopped. Anything else is not a session.
*/
private const val PROP_VENDOR_CHARGE_STATUS = 0x2140F409
// Ids from MG4Control's SWI133 readings; unproven on SWI68. The plug and energy ids are
// probe-only. PROP_BMS_CHRG_STS is also read by getVendorChargeStatus() — the charging session
// state, the vehicle's own values from the stock charging screen: 1 = AC charging,
// 10 = DC charging, 5 = connecting, 7 = connected but not charging, 8 = stopped. Anything
// else is not a session.
private const val PROP_BMS_CHRG_STS = 0x2140F409 // vendor int
private const val PROP_CCU_ONBD_PLUG = 0x2140F43E // vendor int, AC socket
private const val PROP_CCU_OFFBD_PLUG = 0x2140F43F // vendor int, DC socket
private const val PROP_ENERGY_SINCE_CHARGE = 0x2160A1BB // vendor float kWh
/**
* `HVAC_AMBIENT_TEMPERATURE`, the one in-car candidate left on this firmware.
@@ -479,6 +482,24 @@ object EVHardware {
?.takeIf { it.isFinite() && it >= 0f }
}
/**
* Probe-only vendor charge properties, read raw. MG4Control (a system-signed app) reads them
* on SWI133: `BMS_CHRG_STS` 1 while charging, the AC and DC plug states 0 off a charge and
* non-zero on the active socket, and a kWh counter that resets at plug-in. Whether this app
* may read them on SWI68, and what they say there, is what the capture is for.
*/
internal fun probeVendorChargeStatus(): Int? = probeVendorInt(PROP_BMS_CHRG_STS)
internal fun probeVendorPlugAc(): Int? = probeVendorInt(PROP_CCU_ONBD_PLUG)
internal fun probeVendorPlugDc(): Int? = probeVendorInt(PROP_CCU_OFFBD_PLUG)
internal fun probeVendorEnergySinceChargeKwh(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_ENERGY_SINCE_CHARGE, AREA_GLOBAL)?.takeIf { it.isFinite() }
}
/** Null when unreadable, never the -1 [getIntPropertyCPM] stands in for a failure with. */
private fun probeVendorInt(propId: Int): Int? = supportedTelemetryRead {
readPropertyCPM(propId, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int
}
fun getStandardRangeKm(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_RANGE_REMAINING, AREA_GLOBAL)
?.takeIf { it.isFinite() && it >= 0f }
@@ -509,10 +530,10 @@ object EVHardware {
*
* The vendor property, read second: the charging service is the better source when it
* answers, and this is what is left on a unit where it does not.
* See [PROP_VENDOR_CHARGE_STATUS] for the values.
* See [PROP_BMS_CHRG_STS] for the values.
*/
fun getVendorChargeStatus(): Int? =
getIntPropertyCPM(PROP_VENDOR_CHARGE_STATUS, AREA_GLOBAL).takeIf { it >= 0 }
getIntPropertyCPM(PROP_BMS_CHRG_STS, AREA_GLOBAL).takeIf { it >= 0 }
fun getCabinTemperatureCelsius(): Float? = supportedTelemetryRead {
getFloatPropertyCPM(PROP_CABIN_TEMP, AREA_HVAC)
@@ -642,6 +663,15 @@ object EVHardware {
probeProperty("HVAC_AMBIENT_TEMPERATURE", PROP_HVAC_AMBIENT_TEMP_SWI68, AREA_GLOBAL, Float::class.javaObjectType),
)
/**
* The six exterior-light properties (CR-046), read raw whatever [ExteriorLights.PROVEN] says:
* this is the evidence that fills it. EVTasker's Diagnostic shows and exports it — read once
* with a light off and once with it on; a generation is proven when the state follows.
*/
fun probeExteriorLights(): List<PropertyReport> = ExteriorLights.NAMES.map { (id, name) ->
probeProperty(name, id, AREA_GLOBAL, Int::class.javaObjectType)
}
private fun probeProperty(name: String, propId: Int, areaId: Int, boxed: Class<*>): PropertyReport {
val cpm = sCarPropertyManager
?: return PropertyReport(name, propId, areaId, "CarPropertyManager not connected")
@@ -661,6 +691,118 @@ object EVHardware {
}
}
/**
* The vendor setting objects this library already holds, by the name a report prints.
* Probe use only ([com.evsuite.hardware.probe.VendorSurfaceProbe]); no production path
* reads this, and the probe calls nothing on them but value getters.
*/
internal fun vendorSettingHandles(): Map<String, Any> = listOfNotNull(
sVsm?.let { "VehicleSetting" to it }, // SWI68/165 manager, A9 CarVehicleSettingClient
sVsm133?.let { "VehicleSetting133" to it },
sVcontrol?.let { "VehicleControl" to it },
sGeneral?.let { "General" to it },
sCarGeneral?.let { "CarGeneral" to it },
sCarState?.let { "CarState" to it },
).toMap()
/**
* Every property the CarPropertyManager lists, read once per area, keyed `CPM 0x…/area`.
* Probe use only. Stops at [deadlineMs] and says so rather than blocking a diagnostic on a
* VHAL that answers slowly.
*/
internal fun readAllCarProperties(deadlineMs: Long): Map<String, String> {
val cpm = sCarPropertyManager ?: return mapOf("CPM" to "not connected")
cacheCarPropertyMethods(cpm)
val getProperty = sCpmGetPropertyMethod ?: return mapOf("CPM" to "no getProperty")
val configs = try {
cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*>
} catch (e: Exception) {
return mapOf("CPM" to "getPropertyList: ${e.readFailureReason()}")
} ?: return mapOf("CPM" to "getPropertyList: null")
val out = sortedMapOf<String, String>()
for (config in configs) {
if (config == null) continue
if (System.currentTimeMillis() > deadlineMs) {
out["CPM ~"] = "time budget spent after ${out.size} values"
break
}
val type = config.javaClass
val id = runCatching { type.getMethod("getPropertyId").invoke(config) as Int }.getOrNull() ?: continue
val valueType = runCatching { type.getMethod("getPropertyType").invoke(config) as Class<*> }
.getOrNull() ?: Any::class.java
val areas = runCatching { type.getMethod("getAreaIds").invoke(config) as IntArray }
.getOrNull()?.takeIf { it.isNotEmpty() } ?: intArrayOf(AREA_GLOBAL)
for (area in areas) {
val key = "CPM 0x%08X/%d".format(id, area)
out[key] = try {
val holder = getProperty.invoke(cpm, valueType, id, area)
if (holder == null) "no value" else {
val status = (holder.javaClass.getMethod("getStatus").invoke(holder) as? Int)
?.let(CarPropertyEvidence::describe) ?: "no status"
"$status ${com.evsuite.hardware.probe.VendorSurfaceRules.render(
holder.javaClass.getMethod("getValue").invoke(holder))}"
}
} catch (e: Exception) {
e.readFailureReason()
}
}
}
return out
}
// -------------------------------------------------------------------------
// Exterior lights (CR-046) — standard AAOS ids and policy in [ExteriorLights]
// -------------------------------------------------------------------------
/** Status-checked read, only on a proven generation: an unpublished 0 would read as OFF. */
private fun lightRead(propId: Int): Int? =
if (ExteriorLights.isProven(FirmwareInfo.getGeneration()))
readPropertyCPM(propId, AREA_GLOBAL, Int::class.javaObjectType) { sCpmGetIntMethod } as? Int
else null
/** Low beams as VehicleLightState (0 off, 1 on, 2 daytime running); null when unreadable. */
fun headlightsStateOrNull(): Int? = lightRead(ExteriorLights.PROP_HEADLIGHTS_STATE)
fun highBeamOnOrNull(): Boolean? =
ExteriorLights.isOn(lightRead(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_STATE))
fun fogLightsOnOrNull(): Boolean? =
ExteriorLights.isOn(lightRead(ExteriorLights.PROP_FOG_LIGHTS_STATE))
/** Switch positions, for the action editor's current value. */
fun headlightsSwitchOrNull(): Int? = lightRead(ExteriorLights.PROP_HEADLIGHTS_SWITCH)
fun highBeamSwitchOrNull(): Int? = lightRead(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_SWITCH)
fun fogLightsSwitchOnOrNull(): Boolean? =
lightRead(ExteriorLights.PROP_FOG_LIGHTS_SWITCH)?.let { it == ExteriorLights.SWITCH_ON }
/** Low beams ON or AUTOMATIC — never OFF ([ExteriorLights.HEADLIGHT_SWITCH_ALLOWED]). */
@RequiresStandstill
fun setHeadlightsSwitch(mode: Int): Boolean =
lightWrite(ExteriorLights.PROP_HEADLIGHTS_SWITCH, mode, ExteriorLights.HEADLIGHT_SWITCH_ALLOWED)
/** High beam OFF or AUTOMATIC — never forced ON. */
@RequiresStandstill
fun setHighBeamSwitch(mode: Int): Boolean =
lightWrite(ExteriorLights.PROP_HIGH_BEAM_LIGHTS_SWITCH, mode, ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED)
@RequiresStandstill
fun setFogLights(on: Boolean): Boolean = lightWrite(
ExteriorLights.PROP_FOG_LIGHTS_SWITCH,
if (on) ExteriorLights.SWITCH_ON else ExteriorLights.SWITCH_OFF,
ExteriorLights.FOG_SWITCH_ALLOWED,
)
/** Policy, then proven generation, then the 0 km/h gate inside [setIntPropertyCPM]. */
private fun lightWrite(propId: Int, value: Int, allowed: Set<Int>): Boolean {
ExteriorLights.writeRefusal(FirmwareInfo.getGeneration(), allowed, value)?.let {
AppLogger.w(TAG, " light 0x${Integer.toHexString(propId)} refused: $it")
return false
}
return setIntPropertyCPM(propId, AREA_GLOBAL, value)
}
private inline fun <T> supportedTelemetryRead(read: () -> T?): T? =
when (FirmwareInfo.getGeneration()) {
FirmwareInfo.Gen.SWI68,
@@ -1439,7 +1581,7 @@ object EVHardware {
return try {
val native = (g.javaClass.getMethod("getBrightness").invoke(g) as? Int) ?: return -1
if (native < 0) return -1
val pct = (native * 100 / BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
val pct = ((native * 100 + BRIGHTNESS_NATIVE_MAX / 2) / BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
AppLogger.d(TAG, " getBrightness native=$native → $pct%")
pct
} catch (e: Exception) {
@@ -1450,7 +1592,7 @@ object EVHardware {
private fun setBrightnessOldSdk(clampedPct: Int): Boolean {
val g = sGeneral ?: return false
val native = (clampedPct * BRIGHTNESS_NATIVE_MAX / 100).coerceIn(0, BRIGHTNESS_NATIVE_MAX)
val native = ((clampedPct * BRIGHTNESS_NATIVE_MAX + 50) / 100).coerceIn(0, BRIGHTNESS_NATIVE_MAX)
if (logEnabled) AppLogger.i(TAG, "setBrightness → $clampedPct% (native=$native/255)")
return try {
g.javaClass.getMethod("setBrightness", Int::class.javaPrimitiveType).invoke(g, native)
@@ -1477,12 +1619,12 @@ object EVHardware {
AppLogger.w(TAG, " A9 getBrightness Settings.System exc: ${e.message}"); return -1
}
if (native < 0) return -1
return (native * 100 / A9_BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
return ((native * 100 + A9_BRIGHTNESS_NATIVE_MAX / 2) / A9_BRIGHTNESS_NATIVE_MAX).coerceIn(0, 100)
}
private fun setBrightnessA9(clampedPct: Int): Boolean {
val resolver = sAppContext?.contentResolver ?: return false
val native = (clampedPct.coerceIn(0, 100) * A9_BRIGHTNESS_NATIVE_MAX / 100).coerceIn(1, A9_BRIGHTNESS_NATIVE_MAX)
val native = ((clampedPct.coerceIn(0, 100) * A9_BRIGHTNESS_NATIVE_MAX + 50) / 100).coerceIn(1, A9_BRIGHTNESS_NATIVE_MAX)
return try {
// Manual mode, otherwise auto-brightness overwrites the value at once
android.provider.Settings.System.putInt(resolver,
@@ -2329,23 +2471,22 @@ object EVHardware {
fun getSeatHeatRight(): Int = seatHeatRightOrNull() ?: 0
fun isSteeringHeatOn(): Boolean = steeringHeatOnOrNull() ?: false
fun getDriveMode(): DriveMode? {
val cpm = sCarPropertyManager ?: return null
return try {
val raw = (cpm.javaClass
.getMethod("getIntProperty", Int::class.java, Int::class.java)
.invoke(cpm, PROP_DRIVE_MODE, AREA_GLOBAL) as? Int) ?: return null
DriveMode.fromValue(raw)
} catch (_: Exception) { null }
}
fun getDriveMode(): DriveMode? = getDriveModeRaw()?.let(DriveMode::fromValue)
fun getRegenLevel(): RegenLevel? {
/** The drive mode code as the car sends it; null when it could not be read. */
fun getDriveModeRaw(): Int? = readIntGlobal(PROP_DRIVE_MODE)
fun getRegenLevel(): RegenLevel? = getRegenLevelRaw()?.let(RegenLevel::fromValue)
/** The regen code as the car sends it: 6 is one-pedal on SWI68 (EVTasker CR-045 diff). */
fun getRegenLevelRaw(): Int? = readIntGlobal(PROP_REGEN_LEVEL)
private fun readIntGlobal(prop: Int): Int? {
val cpm = sCarPropertyManager ?: return null
return try {
val raw = (cpm.javaClass
cpm.javaClass
.getMethod("getIntProperty", Int::class.java, Int::class.java)
.invoke(cpm, PROP_REGEN_LEVEL, AREA_GLOBAL) as? Int) ?: return null
RegenLevel.fromValue(raw)
.invoke(cpm, prop, AREA_GLOBAL) as? Int
} catch (_: Exception) { null }
}
@@ -2464,8 +2605,9 @@ object EVHardware {
fun getAccTjaMode(): Int {
val useNewApi = FirmwareInfo.isNewGenVsm() || FirmwareInfo.getGeneration() == FirmwareInfo.Gen.SWI132
val method = if (useNewApi) "getAccTjaState" else "getAccTjaMode"
if (logEnabled) AppLogger.i(TAG, "$method →")
return (callVsm(method) as? Int) ?: -1
val value = (callVsm(method) as? Int) ?: -1
if (logEnabled) AppLogger.i(TAG, "$method → $value")
return value
}
/** SWI68/SWI165 : setAccTjaMode(I)V SWI69/SWI131/SWI132 : setAccTjaState(I)V — void */
@@ -2491,8 +2633,9 @@ object EVHardware {
/** Lit le mode du limiteur de speed. Returns 0/2/3, ou -1 si unavailable. */
fun getSpeedLimiterMode(): Int {
val method = if (useSasApi()) "getSasMode" else "getSpeedAsstMode"
if (logEnabled) AppLogger.i(TAG, "$method →")
return (callVsm(method) as? Int) ?: -1
val value = (callVsm(method) as? Int) ?: -1
if (logEnabled) AppLogger.i(TAG, "$method → $value")
return value
}
/** Configure le mode du limiteur de speed. 0=Disabled, 2=Manual, 3=Intelligent. */
@@ -4316,6 +4459,30 @@ object EVHardware {
return if (answered) open else null
}
/**
* Front-door evidence for a firmware the door feature does not claim yet, for EVTasker's
* Diagnostic.
*
* MG4Control reads DLOCK_DOOR_OPEN_STS on SWI68/69/131/165 through the change
* subscription, because the on-demand read throws there. Nothing here has seen a car
* confirm that, so [frontDoorOpenOrNull] stays gated. This connects and subscribes
* regardless of [hasDoorVolumeFeature] — the first call starts the connection, which is
* asynchronous — and reports what came back: export with the front doors shut, open
* one, export again. Proven when the event value follows the door.
*/
fun probeFrontDoors(): String {
connectCarProperty()
registerDoorCallback()
val reads = DOOR_FRONT_AREAS.joinToString(" ") { area ->
"read[0x${area.toString(16)}]=${readDoorOpen(area) ?: "?"}"
}
val events = synchronized(this) {
DOOR_FRONT_AREAS.joinToString(" ") { area -> "event[0x${area.toString(16)}]=${sDoorReadLast[area] ?: "?"}" }
}
return "managers property=${sCarPropMgr != null} doorlock=${sCarDoorMgr != null} " +
"subscribed property=$sDoorSubProperty doorlock=$sDoorSubDoorlock $reads $events"
}
/** Diagnostic button probe: logs the volume + door states at click time. */
fun runDoorVolumeDiag() {
AppLogger.i(VOL_TAG, "── DIAG (bouton Diagnostic) ──")
@@ -0,0 +1,79 @@
package com.evsuite.hardware
/**
* Exterior lights (CR-046): the standard AAOS ids, their values, and what a rule may do with them.
*
* Kept free of Android so the policy is pinned by JVM tests. The ids are the public
* `VehicleProperty` ones (types.hal 2.0, available on AAOS 9); nothing here comes from a vendor
* catalogue. Whether the MG4 VHAL publishes them is a per-generation question: a generation joins
* [PROVEN] only with a capture showing the state follow the stalk — EVTasker's Diagnostic
* `EXTERIOR_LIGHTS` row ([EVHardware.probeExteriorLights]), exported off and on — in the same commit as the
* `@SupportedOn` of the light entries and their `writeProven`.
*
* Owner decisions (2026-10-08): writes only at 0 km/h like every vehicle write, and a rule may
* never switch the low beams OFF. Same reasoning for the high beam: a rule may set it OFF or
* AUTOMATIC, never force it ON — that dazzles, and the stalk stays the driver's.
*/
object ExteriorLights {
// VehicleProperty — SYSTEM | GLOBAL | INT32 | 0x0E0x.
const val PROP_HEADLIGHTS_STATE = 0x11400E00
const val PROP_HIGH_BEAM_LIGHTS_STATE = 0x11400E01
const val PROP_FOG_LIGHTS_STATE = 0x11400E02
const val PROP_HEADLIGHTS_SWITCH = 0x11400E10
const val PROP_HIGH_BEAM_LIGHTS_SWITCH = 0x11400E11
const val PROP_FOG_LIGHTS_SWITCH = 0x11400E12
// VehicleLightState
const val STATE_OFF = 0
const val STATE_ON = 1
const val STATE_DAYTIME_RUNNING = 2
// VehicleLightSwitch
const val SWITCH_OFF = 0
const val SWITCH_ON = 1
const val SWITCH_DAYTIME_RUNNING = 2
const val SWITCH_AUTOMATIC = 0x100
/** Generations where the ids above are proven on the car. Empty until a capture says so. */
val PROVEN: Set<FirmwareInfo.Gen> = emptySet()
/** Names for the capture diff, so a changed light property reads as itself. */
val NAMES: Map<Int, String> = mapOf(
PROP_HEADLIGHTS_STATE to "HEADLIGHTS_STATE",
PROP_HIGH_BEAM_LIGHTS_STATE to "HIGH_BEAM_LIGHTS_STATE",
PROP_FOG_LIGHTS_STATE to "FOG_LIGHTS_STATE",
PROP_HEADLIGHTS_SWITCH to "HEADLIGHTS_SWITCH",
PROP_HIGH_BEAM_LIGHTS_SWITCH to "HIGH_BEAM_LIGHTS_SWITCH",
PROP_FOG_LIGHTS_SWITCH to "FOG_LIGHTS_SWITCH",
)
/** Low beams: ON or AUTOMATIC. Never OFF. */
val HEADLIGHT_SWITCH_ALLOWED = setOf(SWITCH_ON, SWITCH_AUTOMATIC)
/** High beam: OFF or AUTOMATIC. Never forced ON. */
val HIGH_BEAM_SWITCH_ALLOWED = setOf(SWITCH_OFF, SWITCH_AUTOMATIC)
/** Fog lights: on or off. */
val FOG_SWITCH_ALLOWED = setOf(SWITCH_OFF, SWITCH_ON)
fun isProven(gen: FirmwareInfo.Gen): Boolean = gen in PROVEN
/**
* Why a light write must not be sent, or null when it may go on to the speed gate.
* Policy first, so a forbidden value is refused on every car, proven or not.
*/
fun writeRefusal(
gen: FirmwareInfo.Gen,
allowed: Set<Int>,
value: Int,
proven: Set<FirmwareInfo.Gen> = PROVEN,
): String? = when {
value !in allowed -> "value 0x${Integer.toHexString(value)} not allowed for a rule"
gen !in proven -> "exterior lights not proven on $gen"
else -> null
}
/** A light state read as "on"; daytime running lights are not headlights on. */
fun isOn(state: Int?): Boolean? = state?.let { it == STATE_ON }
}
@@ -3,20 +3,44 @@ package com.evsuite.hardware
import com.evsuite.hardware.catalog.SnapshotKeys
/**
* Decodes `com.saic.keyevent.hardkey.report` payloads.
* Decodes the head unit's two hard-key broadcasts.
*
* Codes 5/23/24/25/87/88/164/286/287 come from R69 EOL's
* PhysicalKeysBroadcastReceiver; 287 is also named VOICE_KEY_CODE by VrSpeechService.
* Codes 17 and 18 are firmware aliases already observed by EVProfile.
* They number their keys in two different spaces, so a code only means something together
* with the broadcast it came on ([Source]):
*
* - `com.saic.keyevent.hardkey.report` carries Android key codes. 5/23/24/25/87/88/164/286/287
* come from R69 EOL's PhysicalKeysBroadcastReceiver; 287 is also VrSpeechService's
* VOICE_KEY_CODE. 17 and 18 are firmware aliases already observed by EVProfile.
* - `com.android.systemui.ACTION_HARD_KEY_EVENT` carries SAIC's own numbering: 1/2 volume,
* 3 mute, 4/5 previous/next (mediaservice HardKeyMonitor, radioservice ReceiveUtils),
* 6–10 the instrument-cluster pad (engineermodeservice KeyEventReceiver, `_IPK`), 16 phone
* (btcallservice).
*
* The same physical press arrives on both. Read as one space, SystemUI's 5 (next track) was
* decoded as the report's 5 (phone), and a phone press arriving twice read as a double.
* So SystemUI keys the report already carries are dropped, and only the cluster pad — which
* the report does not carry — is read from SystemUI.
*
* A key neither list names is still reported, as an unknown key with its own [Event.keyId],
* so a driver can bind a button this table has not met yet.
*/
class PhysicalButtonEventDecoder {
enum class Button(val codes: Set<Int>, val shortKey: String, val longKey: String) {
PHONE(setOf(5, 16), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
UP(setOf(6), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG),
DOWN(setOf(7), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG),
OK(setOf(8), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG),
LEFT(setOf(9), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG),
RIGHT(setOf(10), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG),
enum class Source { HARDKEY_REPORT, SYSTEM_UI }
enum class Button(
/** On the report broadcast. */
val codes: Set<Int>,
val shortKey: String,
val longKey: String,
/** On the SystemUI broadcast, for keys the report does not carry. */
val systemUiCodes: Set<Int> = emptySet(),
) {
PHONE(setOf(5), SnapshotKeys.KEY_PHONE_SHORT, SnapshotKeys.KEY_PHONE_LONG),
UP(emptySet(), SnapshotKeys.KEY_UP_SHORT, SnapshotKeys.KEY_UP_LONG, setOf(6)),
DOWN(emptySet(), SnapshotKeys.KEY_DOWN_SHORT, SnapshotKeys.KEY_DOWN_LONG, setOf(7)),
OK(emptySet(), SnapshotKeys.KEY_OK_SHORT, SnapshotKeys.KEY_OK_LONG, setOf(8)),
LEFT(emptySet(), SnapshotKeys.KEY_LEFT_SHORT, SnapshotKeys.KEY_LEFT_LONG, setOf(9)),
RIGHT(emptySet(), SnapshotKeys.KEY_RIGHT_SHORT, SnapshotKeys.KEY_RIGHT_LONG, setOf(10)),
SOURCE(setOf(110), SnapshotKeys.KEY_SOURCE_SHORT, SnapshotKeys.KEY_SOURCE_LONG),
CENTER(setOf(23), SnapshotKeys.KEY_CENTER_SHORT, SnapshotKeys.KEY_CENTER_LONG),
VOLUME_UP(setOf(24), SnapshotKeys.KEY_VOLUME_UP_SHORT, SnapshotKeys.KEY_VOLUME_UP_LONG),
@@ -26,15 +50,28 @@ class PhysicalButtonEventDecoder {
MUTE(setOf(164), SnapshotKeys.KEY_MUTE_SHORT, SnapshotKeys.KEY_MUTE_LONG),
STAR_LEFT(setOf(17), SnapshotKeys.KEY_STAR_LEFT_SHORT, SnapshotKeys.KEY_STAR_LEFT_LONG),
STAR_RIGHT(setOf(286, 18), SnapshotKeys.KEY_STAR_RIGHT_SHORT, SnapshotKeys.KEY_STAR_RIGHT_LONG),
ASSISTANT(setOf(287), SnapshotKeys.KEY_ASSISTANT_SHORT, SnapshotKeys.KEY_ASSISTANT_LONG)
ASSISTANT(setOf(287), SnapshotKeys.KEY_ASSISTANT_SHORT, SnapshotKeys.KEY_ASSISTANT_LONG);
/** The number a rule stores for this button — the first code, as rules always stored. */
val id: Int get() = (codes + systemUiCodes).first()
companion object {
fun byId(id: Int): Button? = entries.firstOrNull { it.id == id }
}
}
enum class Press { SHORT, LONG, DOUBLE }
data class Event(val button: Button, val press: Press) {
val value: String get() = "${button.name}:${press.name}"
/**
* One decoded press. [keyId] is [Button.id] for a known button, and an [unknownKeyId]
* otherwise.
*/
data class Event(val keyId: Int, val press: Press) {
val button: Button? get() = Button.byId(keyId)
val value: String get() = "${keyName(keyId)}:${press.name}"
fun readings(): Map<String, Any> = mapOf(SnapshotKeys.KEY_PHYSICAL_BUTTON_EVENT to value)
}
private enum class State { DOWN, LONG_REPORTED }
private val states = mutableMapOf<Button, State>()
private val states = mutableMapOf<Int, State>()
/**
* How long a second press has to arrive within to count as a double.
@@ -42,7 +79,7 @@ class PhysicalButtonEventDecoder {
* The stock steering controls use the same window, and it is short on purpose: longer
* would make two deliberate single presses merge into one double.
*/
private val lastShortAt = mutableMapOf<Button, Long>()
private val lastShortAt = mutableMapOf<Int, Long>()
/**
* @param atMillis when the event happened. A monotonic default, and JVM-only so the
@@ -53,27 +90,28 @@ class PhysicalButtonEventDecoder {
down: Boolean,
longPress: Boolean,
atMillis: Long = System.nanoTime() / 1_000_000,
source: Source = Source.HARDKEY_REPORT,
): Event? {
val button = Button.entries.firstOrNull { keyCode in it.codes } ?: return null
val key = keyId(source, keyCode) ?: return null
return when {
down && longPress && states[button] != State.LONG_REPORTED -> {
states[button] = State.LONG_REPORTED
down && longPress && states[key] != State.LONG_REPORTED -> {
states[key] = State.LONG_REPORTED
// A long press ends the pairing: the release that follows must not become the
// second half of a double the driver never made.
lastShortAt.remove(button)
Event(button, Press.LONG)
lastShortAt.remove(key)
Event(key, Press.LONG)
}
down -> { states.putIfAbsent(button, State.DOWN); null }
states.remove(button) == State.DOWN -> {
val previous = lastShortAt[button]
down -> { states.putIfAbsent(key, State.DOWN); null }
states.remove(key) == State.DOWN -> {
val previous = lastShortAt[key]
if (previous != null && atMillis - previous <= DOUBLE_TAP_MS) {
// Consumed, so three presses read as one double and one single rather than
// as two overlapping doubles.
lastShortAt.remove(button)
Event(button, Press.DOUBLE)
lastShortAt.remove(key)
Event(key, Press.DOUBLE)
} else {
lastShortAt[button] = atMillis
Event(button, Press.SHORT)
lastShortAt[key] = atMillis
Event(key, Press.SHORT)
}
}
else -> null
@@ -91,5 +129,37 @@ class PhysicalButtonEventDecoder {
* double — see EVTasker's vehicle service.
*/
const val DOUBLE_TAP_MS = 300L
/** SystemUI codes for keys the report broadcast already carries — see the class doc. */
private val SYSTEM_UI_DUPLICATES = setOf(1, 2, 3, 4, 5, 16)
private const val UNKNOWN_REPORT_BASE = 10_000
private const val UNKNOWN_SYSTEM_UI_BASE = 20_000
/** Kept apart from every [Button.id] and from the other broadcast's codes. */
fun unknownKeyId(source: Source, keyCode: Int): Int = keyCode + when (source) {
Source.HARDKEY_REPORT -> UNKNOWN_REPORT_BASE
Source.SYSTEM_UI -> UNKNOWN_SYSTEM_UI_BASE
}
/** Where an unknown key id came from, as `(source, code)` — null for a known button. */
fun unknownKey(keyId: Int): Pair<Source, Int>? = when {
Button.byId(keyId) != null -> null
keyId >= UNKNOWN_SYSTEM_UI_BASE -> Source.SYSTEM_UI to keyId - UNKNOWN_SYSTEM_UI_BASE
keyId >= UNKNOWN_REPORT_BASE -> Source.HARDKEY_REPORT to keyId - UNKNOWN_REPORT_BASE
else -> null
}
fun keyName(keyId: Int): String = Button.byId(keyId)?.name ?: "KEY_$keyId"
private fun keyId(source: Source, keyCode: Int): Int? {
if (keyCode < 0) return null
return when (source) {
Source.HARDKEY_REPORT -> Button.entries.firstOrNull { keyCode in it.codes }?.id
Source.SYSTEM_UI -> {
if (keyCode in SYSTEM_UI_DUPLICATES) return null
Button.entries.firstOrNull { keyCode in it.systemUiCodes }?.id
}
} ?: unknownKeyId(source, keyCode)
}
}
}
@@ -22,6 +22,7 @@ enum class ActionGroup(@StringRes val labelRes: Int) {
ENERGY(R.string.group_energy),
AUDIO(R.string.group_audio),
ADAS(R.string.group_adas),
LIGHTS(R.string.group_lights),
SYSTEM(R.string.group_system)
}
@@ -165,6 +166,32 @@ enum class ActionType(
"SET_SCREEN_BRIGHTNESS", currentKey = SnapshotKeys.KEY_BRIGHTNESS
),
// ── Exterior lights (CR-046, gated) ──────────────────────────────────────
// Owner decisions: 0 km/h only, never low beams OFF, never high beam forced ON — the
// options below cannot express either, and ExteriorLights refuses them again at the write.
// Unproven everywhere: empty @SupportedOn and writeProven = false until a capture and an
// observed effect prove a generation.
@SupportedOn
SET_HEADLIGHTS(
R.string.act_headlights, ActionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HEADLIGHT_SWITCH_CHOICES),
"SET_HEADLIGHTS", gated = true, currentKey = SnapshotKeys.KEY_HEADLIGHTS_SWITCH,
writeProven = false
),
@SupportedOn
SET_HIGH_BEAM(
R.string.act_high_beam, ActionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HIGH_BEAM_SWITCH_CHOICES),
"SET_HIGH_BEAM", gated = true, currentKey = SnapshotKeys.KEY_HIGH_BEAM_SWITCH,
writeProven = false
),
@SupportedOn
SET_FOG_LIGHTS(
R.string.act_fog_lights, ActionGroup.LIGHTS,
ValueSpec.BOOL, "SET_FOG_LIGHTS", gated = true,
currentKey = SnapshotKeys.KEY_FOG_LIGHTS_SWITCH, writeProven = false
),
// ── Climate (vendor service — com.evsuite.hardware.saic.SaicClimate) ─────────
// These are the calls the car's own HVAC screen makes, so what they do is not in doubt.
// Not gated: changing the cabin temperature does not alter road behaviour, and a rule
@@ -191,8 +218,13 @@ enum class ActionType(
* A second action rather than a zone argument on the first: the driver's target is the
* one every existing rule sets, and giving it a zone would have made every saved rule
* carry a value it never chose.
*
* Not SWI68: the passenger target reads -1 there, the converter's answer for a zone
* the car does not have, so nothing shows the write lands anywhere. EVTasker's
* Diagnostic prints that raw read (`CLIMATE_ZONES`); a SWI68 that answers a real
* target there is what would bring the action back.
*/
@SupportedOn(SWI68, SWI165)
@SupportedOn(SWI165)
SET_PASSENGER_TEMP(
R.string.act_passenger_temp, ActionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
@@ -296,11 +328,15 @@ enum class ActionType(
),
// ── Energy (vendor service — com.evsuite.hardware.saic.SaicCharging) ─────────
// Unproven: the write sends a percent, but SWI68 reads the limit back as `5` (2026-10-13),
// a code from a table nobody has read. Writing 80 into a coded field is a guess, so the
// action is held back until one write is seen to set the limit shown on the car's screen.
@SupportedOn(SWI68, SWI165)
SET_CHARGE_LIMIT(
R.string.act_charge_limit, ActionGroup.ENERGY,
number(VehicleEnums.CHARGE_LIMIT_MIN, VehicleEnums.CHARGE_LIMIT_MAX, R.string.unit_percent),
"SET_CHARGE_LIMIT", currentKey = SnapshotKeys.KEY_CHARGE_LIMIT
"SET_CHARGE_LIMIT", currentKey = SnapshotKeys.KEY_CHARGE_LIMIT,
writeProven = false
),
@SupportedOn(SWI68, SWI165)
SET_CHARGING_ENABLED(
@@ -23,7 +23,8 @@ enum class ConditionGroup(@StringRes val labelRes: Int) {
CLIMATE(R.string.group_climate),
COMFORT(R.string.group_comfort),
AUDIO(R.string.group_audio),
ADAS(R.string.group_adas)
ADAS(R.string.group_adas),
LIGHTS(R.string.group_lights)
}
/**
@@ -362,8 +363,12 @@ enum class ConditionType(
* The two are the same number until dual zone is on, which is exactly when a rule needs
* to tell them apart — and there is no readable "dual zone" flag, so the honest way to
* ask is to compare the two targets.
*
* Not SWI68: the service reads the passenger zone (area 68) and answers -1, which is what
* its converter returns for a value out of range — a single-zone car has no such zone.
* Seen on a SWI68 MG4 that read the driver's target fine in the same pass.
*/
@SupportedOn(SWI68, SWI165)
@SupportedOn(SWI165)
PASSENGER_TEMP(
R.string.cond_passenger_temp, ConditionGroup.CLIMATE,
number(VehicleEnums.CABIN_TEMP_MIN, VehicleEnums.CABIN_TEMP_MAX, R.string.unit_celsius),
@@ -431,7 +436,12 @@ enum class ConditionType(
number(0, 100, R.string.unit_percent),
SnapshotKeys.KEY_WINDOW_REAR_RIGHT, comparable = true
),
@SupportedOn(SWI68, SWI165)
/**
* Not SWI68: `getDoorLock` reads back the property `setDoorLock` writes, a lock request
* rather than a lock state. A SWI68 MG4 in use answered 0, which is neither of the two
* named values, so the condition never had anything to say there.
*/
@SupportedOn(SWI165)
DOORS_LOCKED(
R.string.cond_doors_locked, ConditionGroup.CONTEXT,
ValueSpec.BOOL, SnapshotKeys.KEY_DOORS_LOCKED
@@ -473,6 +483,26 @@ enum class ConditionType(
SnapshotKeys.KEY_BRIGHTNESS, comparable = true
),
// ── Exterior lights (CR-046) ─────────────────────────────────────────────
// Standard AAOS ids (ExteriorLights). Proven on no generation yet: @SupportedOn stays empty
// until a capture shows the state follow the stalk, in the commit that fills
// ExteriorLights.PROVEN.
@SupportedOn
HEADLIGHTS(
R.string.cond_headlights, ConditionGroup.LIGHTS,
ValueSpec(ValueKind.ENUM, options = VehicleEnums.HEADLIGHT_STATES), SnapshotKeys.KEY_HEADLIGHTS
),
@SupportedOn
HIGH_BEAM_ON(
R.string.cond_high_beam, ConditionGroup.LIGHTS,
ValueSpec.BOOL, SnapshotKeys.KEY_HIGH_BEAM
),
@SupportedOn
FOG_LIGHTS_ON(
R.string.cond_fog_lights, ConditionGroup.LIGHTS,
ValueSpec.BOOL, SnapshotKeys.KEY_FOG_LIGHTS
),
// ── Audio ────────────────────────────────────────────────────────────────
// What is coming out of the speakers, and how loud. The picker groups them together for
// the same reason the action catalogue does: a rule about the radio is written next to
@@ -60,6 +60,13 @@ object SnapshotKeys {
const val KEY_MEDIA_VOLUME = "mediaVolume"
const val KEY_MEDIA_VOLUME_MAX = "mediaVolumeMax"
const val KEY_BRIGHTNESS = "brightnessPct"
// Exterior lights (CR-046): states for conditions, switch positions for the action editor.
const val KEY_HEADLIGHTS = "headlightsState"
const val KEY_HIGH_BEAM = "highBeamOn"
const val KEY_FOG_LIGHTS = "fogLightsOn"
const val KEY_HEADLIGHTS_SWITCH = "headlightsSwitch"
const val KEY_HIGH_BEAM_SWITCH = "highBeamSwitch"
const val KEY_FOG_LIGHTS_SWITCH = "fogLightsSwitchOn"
const val KEY_AEB_ENABLED = "aebEnabled"
const val KEY_AEB_MODE = "aebMode"
const val KEY_AEB_SENSITIVITY = "aebSensitivity"
@@ -176,4 +176,23 @@ object VehicleEnums {
EnumOption(2, R.string.radio_band_fm),
EnumOption(4, R.string.radio_band_dab)
)
/** VehicleLightState of the low beams — see ExteriorLights. */
val HEADLIGHT_STATES = listOf(
EnumOption(0, R.string.light_off),
EnumOption(1, R.string.light_on),
EnumOption(2, R.string.light_daytime_running)
)
/** What a rule may ask of the low beams: ON or AUTOMATIC, never OFF. */
val HEADLIGHT_SWITCH_CHOICES = listOf(
EnumOption(1, R.string.light_on),
EnumOption(0x100, R.string.light_auto)
)
/** What a rule may ask of the high beam: OFF or AUTOMATIC, never forced ON. */
val HIGH_BEAM_SWITCH_CHOICES = listOf(
EnumOption(0, R.string.light_off),
EnumOption(0x100, R.string.light_auto)
)
}
@@ -0,0 +1,101 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonObject
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
import java.util.Base64
/**
* Posts one diagnostic artifact to an inbox the driver runs at home, instead of a USB stick.
*
* The inbox is the driver's own server (a Node-RED flow on the house network, for one): the
* app holds no account and no key, and nothing is sent unless the driver taps Send and
* confirms the address. HTTPS only, no redirect followed, no credentials or query in the URL,
* so a typo cannot send a car's report somewhere else in clear.
*
* The body is one JSON object, the same for every app, so one script on the desk unpacks them
* all: `{"evsuite":1,"app","version","name","createdAtMs","contentBase64"}`. The content is the
* artifact exactly as it would land on the stick — a zip, or a text report — base64-encoded.
*
* No Android dependency: [poster] is injectable, so a test checks the wire without a network.
*/
object DiagnosticInbox {
const val FORMAT_VERSION = 1
/** Above what a bundle is allowed to be on the stick, with base64's third on top. */
const val MAX_CONTENT_BYTES = 1024 * 1024
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 20_000
data class Artifact(
val app: String,
val version: String,
val name: String,
val createdAtMs: Long,
val content: ByteArray,
)
sealed interface Outcome {
data class Sent(val status: Int) : Outcome
data class Failed(val reason: String) : Outcome
}
/** Why [url] is not an inbox address, or null when it is one. */
fun refuse(url: String): String? {
val uri = runCatching { URI(url.trim()) }.getOrNull() ?: return "not a URL"
if (!uri.isAbsolute) return "not an absolute URL"
if (!uri.scheme.equals("https", ignoreCase = true)) return "must be https"
if (uri.host.isNullOrBlank()) return "no host"
if (uri.userInfo != null) return "must not carry credentials"
if (uri.query != null || uri.fragment != null) return "must not carry a query"
return null
}
internal fun body(artifact: Artifact): String = JsonObject().apply {
addProperty("evsuite", FORMAT_VERSION)
addProperty("app", artifact.app)
addProperty("version", artifact.version)
addProperty("name", artifact.name)
addProperty("createdAtMs", artifact.createdAtMs)
addProperty("contentBase64", Base64.getEncoder().encodeToString(artifact.content))
}.toString()
fun send(
url: String,
artifact: Artifact,
poster: (String, String) -> Int = ::post,
): Outcome {
refuse(url)?.let { return Outcome.Failed(it) }
if (artifact.content.size > MAX_CONTENT_BYTES) {
return Outcome.Failed("artifact is ${artifact.content.size} bytes, over $MAX_CONTENT_BYTES")
}
return try {
val status = poster(url.trim(), body(artifact))
if (status in 200..299) Outcome.Sent(status) else Outcome.Failed("HTTP $status")
} catch (e: Exception) {
Outcome.Failed(e.javaClass.simpleName + (e.message?.let { ": $it" } ?: ""))
}
}
private fun post(url: String, body: String): Int {
val bytes = body.toByteArray(Charsets.UTF_8)
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
instanceFollowRedirects = false
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setFixedLengthStreamingMode(bytes.size)
}
return try {
connection.outputStream.use { it.write(bytes) }
connection.responseCode
} finally {
connection.disconnect()
}
}
}
@@ -12,5 +12,8 @@ enum class DriveMode(val value: Int, val label: String, @StringRes val labelRes:
companion object {
fun fromValue(v: Int): DriveMode = values().firstOrNull { it.value == v } ?: NORMAL
/** Exact decode: a code this enum does not know is null, not a default. */
fun ofRaw(v: Int?): DriveMode? = values().firstOrNull { it.value == v }
}
}
@@ -37,5 +37,29 @@ data class DrivingProfile(
val tsrEnabled: Boolean = false,
val isDefault: Boolean = false,
// [BT-PROFILES] MAC of the Bluetooth device tied to this profile (null = none)
val btDeviceMac: String? = null
val btDeviceMac: String? = null,
// CUSTOM drive mode (CR-040) — CustomDrive index: 0=Eco/Comfort, 1=Normal, 2=Sport.
// null = not configured, never written; profiles saved before this feature read as null.
// Written only when driveMode is CUSTOM — the car ignores them in any other mode.
val customPower: Int? = null,
val customSteering: Int? = null,
val customPedal: Int? = null,
// Climate (CR-041) — null = the profile does not touch climate (every older profile).
val climate: ProfileClimate? = null
)
/**
* The climate a profile applies (CR-041). A null switch means "unchanged": the profile leaves
* that control where the car has it, which is what lets a profile care about the setpoint
* without turning the defrost off.
*/
data class ProfileClimate(
val powerOn: Boolean = true,
val autoOn: Boolean? = null,
val acOn: Boolean? = null,
val tempCelsius: Int? = null,
val fanLevel: Int? = null,
val recirculation: Boolean? = null,
val frontDefrost: Boolean? = null,
val rearDefrost: Boolean? = null
)
@@ -13,5 +13,8 @@ enum class RegenLevel(val value: Int, val label: String, @StringRes val labelRes
companion object {
fun fromValue(v: Int): RegenLevel = values().firstOrNull { it.value == v } ?: MEDIUM
/** Exact decode: a code this enum does not know is null, not a default. */
fun ofRaw(v: Int?): RegenLevel? = values().firstOrNull { it.value == v }
}
}
@@ -1,6 +1,7 @@
package com.evsuite.hardware.probe
import android.content.Context
import android.content.pm.PackageManager
import android.os.IBinder
import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo
@@ -61,6 +62,7 @@ object RuntimeInterfaceProbe {
* it from a capture that has been up for a while.
*/
fun connect(context: Context) {
packages = context.applicationContext.packageManager
SaicHub.connect(context)
engMode.connect(context)
}
@@ -276,6 +278,80 @@ object RuntimeInterfaceProbe {
add(vehicleProperty())
add(vehicleCharging())
add(carDiagnostics())
addAll(componentMap())
}
/** Kept from [connect], so [componentMap] needs no extra argument from the callers. */
@Volatile private var packages: PackageManager? = null
/**
* Every service the vendor packages declare in their manifests, as PackageManager lists it.
*
* The hub maps above can only name what someone already guessed to ask for. This is the
* other direction: what is installed, which services each package declares, and whether an
* unprivileged caller may reach them — RI-001, RI-003, RI-004, RI-007 and RI-008 each start
* by not knowing which component publishes their signal.
*
* Manifest metadata only. Nothing is bound, no transaction is sent and no vendor class is
* loaded, so this cannot reach a write on any interface. Visibility of other packages holds
* because the target is API 28; a later target needs `QUERY_ALL_PACKAGES`, and without it
* this would read as a head unit with no vendor packages at all.
*/
fun componentMap(): List<InterfaceProbe> {
val pm = packages ?: return listOf(
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.ABSENT, note = NOT_CONNECTED),
)
val names = try {
pm.getInstalledPackages(0).map { it.packageName }
.filter { name -> COMPONENT_PREFIXES.any { name.startsWith(it) } }.sorted()
} catch (e: RuntimeException) {
return listOf(
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.DENIED, note = e.javaClass.name),
)
}
return listOf(componentSummary(names)) + names.take(MAX_COMPONENT_PACKAGES).map { name ->
val services = try {
pm.getPackageInfo(name, PackageManager.GET_SERVICES).services.orEmpty()
.map { ServiceRow(it.name, it.exported, it.permission) }
} catch (e: PackageManager.NameNotFoundException) {
return@map InterfaceProbe("$COMPONENT_MAP:$name", null, BindVerdict.ABSENT)
} catch (e: RuntimeException) {
return@map InterfaceProbe(
"$COMPONENT_MAP:$name", null, BindVerdict.DENIED, note = e.javaClass.name,
)
}
componentEntry(name, services)
}
}
/** One declared service, as the manifest states it. */
internal data class ServiceRow(val name: String, val exported: Boolean, val permission: String?)
/** How many vendor packages there are, and how many of them the entries below list. */
internal fun componentSummary(names: List<String>): InterfaceProbe = InterfaceProbe(
COMPONENT_MAP, null, BindVerdict.ANSWERED,
mapOf(
"packages" to names.size.toString(),
"listed" to minOf(names.size, MAX_COMPONENT_PACKAGES).toString(),
),
COMPONENT_NOTE,
)
/**
* One package's services, keyed by class name, the value kept as the manifest's own words.
*
* A package that declares none is still ANSWERED: the manifest was read and said so.
*/
internal fun componentEntry(pkg: String, services: List<ServiceRow>): InterfaceProbe {
val values = LinkedHashMap<String, String?>()
values["services"] = services.size.toString()
services.take(MAX_COMPONENT_SERVICES).forEach {
values[it.name] = "exported=${it.exported} permission=${it.permission}"
}
if (services.size > MAX_COMPONENT_SERVICES) {
values["truncated"] = (services.size - MAX_COMPONENT_SERVICES).toString()
}
return InterfaceProbe("$COMPONENT_MAP:$pkg", null, BindVerdict.ANSWERED, values)
}
/**
@@ -348,6 +424,16 @@ object RuntimeInterfaceProbe {
): InterfaceProbe {
val target = binder
?: return InterfaceProbe(label, descriptor, BindVerdict.ABSENT, note = note)
// The hub may hand over a different interface at this bind point. Every call would then
// fail the remote descriptor check, which reads as a refusal and is not one: the
// interface is not here, so the entry is ABSENT and nothing is called.
val answersAs = runCatching { target.interfaceDescriptor }.getOrNull()
if (answersAs != null && answersAs != descriptor) {
return InterfaceProbe(
label, descriptor, BindVerdict.ABSENT,
note = "$note; the handle answers as $answersAs, so nothing was called",
)
}
val values = calls.mapValues { (_, call) ->
val args = call.args.toTypedArray()
when (call.readAs) {
@@ -365,6 +451,18 @@ object RuntimeInterfaceProbe {
*/
private val CODE_SWEEP = (0x0..0x12).toList()
private const val COMPONENT_MAP = "componentMap"
private val COMPONENT_PREFIXES = listOf("com.saicmotor.", "com.saicvehicleservice.")
/** A bundle is pasted and read; a few hundred lines of manifest is the useful end of it. */
internal const val MAX_COMPONENT_PACKAGES = 64
internal const val MAX_COMPONENT_SERVICES = 40
private const val NOT_CONNECTED = "connect() was not called, so there is no PackageManager"
private const val COMPONENT_NOTE =
"PackageManager GET_SERVICES only: nothing bound, called or loaded. exported=false or " +
"a permission we do not hold means unreachable from EVSuite, not absent."
/**
* What ABSENT means when there is no adapter client to ask. Carried on every swept entry
* rather than stated once at the top of the bundle: a later session reads one entry, not the
@@ -0,0 +1,81 @@
package com.evsuite.hardware.probe
import com.evsuite.hardware.AppLogger
import com.evsuite.hardware.EVHardware
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.saic.SaicCharging
import java.lang.reflect.InvocationTargetException
/**
* Two read-only diagnostic tools. EVTasker's stable diagnostic screen calls [capture] from two
* buttons (CR-045/CR-046, 2026-10-13), because the owner only installs stable builds; that is
* safe only because **no setter is called from this file** — only the getters
* [VendorSurfaceRules.readableGetters] admits.
*
* 1. [socSourcesReport] (CR-044): every place state of charge might come from, side by side,
* for the generations where the uploader reads none.
* 2. [capture] + [VendorSurfaceRules.diff] (CR-045): read everything, let the driver flip a
* setting on the car's own screen at standstill, read again, list what changed. That is how
* an unknown setting (Automatic High Beam) is found on a given firmware without taking an
* identifier from anybody else's code. It works for any setting, and across a SOC change
* too: capture, drive a few kilometres, capture again.
*
* Every value printed is a candidate. Only a lead proven per generation becomes a read path.
*/
object VendorSurfaceProbe {
private const val TAG = "EV_SURVEY"
/** Longest a capture may take before it stops and says so. */
const val BUDGET_MS = 20_000L
/** Getter-name words that may carry battery state, for the SOC survey. */
val SOC_WORDS = listOf(
"soc", "battery", "batt", "bms", "energy", "endurance", "range", "electric",
"charge", "mileage",
)
/**
* Everything readable, keyed by source: every CarPropertyManager property (when
* [words] is empty) and every value getter of the vendor setting objects EVHardware holds.
* Call off the main thread: these are binder calls.
*/
fun capture(words: List<String> = emptyList()): Map<String, String> {
val deadline = System.currentTimeMillis() + BUDGET_MS
val out = sortedMapOf<String, String>()
if (words.isEmpty()) out += EVHardware.readAllCarProperties(deadline)
for ((name, handle) in EVHardware.vendorSettingHandles()) {
for (getter in VendorSurfaceRules.readableGetters(handle.javaClass, words)) {
if (System.currentTimeMillis() > deadline) {
out["$name ~"] = "time budget spent"
break
}
out["$name.${getter.name}"] = try {
VendorSurfaceRules.render(getter.invoke(handle))
} catch (e: InvocationTargetException) {
"ERR ${e.targetException?.javaClass?.simpleName}"
} catch (e: Exception) {
"ERR ${e.javaClass.simpleName}"
}
}
}
AppLogger.i(TAG, "capture(${words.joinToString()}) → ${out.size} values")
return out
}
/** CR-044: where state of charge can come from on this car, and what each source says. */
fun socSourcesReport(): String = buildString {
appendLine("─── SOC sources (CR-044) — ${FirmwareInfo.getGeneration()} ───")
appendLine("SaicCharging hub: ${if (SaicCharging.isAvailable) "bound" else "absent or not bound"}" +
" → SOC ${SaicCharging.stateOfChargePercent() ?: "null"}")
appendLine("Vendor SOC property (SWI68 path) → ${EVHardware.getVendorBatterySocPercent() ?: "null"}")
EVHardware.probeTelemetryProperties()
.filter { it.name in setOf("EV_BATTERY_LEVEL", "INFO_EV_BATTERY_CAPACITY", "RANGE_REMAINING") }
.forEach { appendLine(it.toString()) }
val getters = capture(SOC_WORDS)
appendLine("Vendor getters with battery words: ${getters.size}")
getters.forEach { (key, value) -> appendLine(" $key = $value") }
appendLine("Candidates only. To find SOC among unnamed properties, use the before/after")
appendLine("capture across a SOC change (a few km, or a charge).")
}
}
@@ -0,0 +1,112 @@
package com.evsuite.hardware.probe
import java.lang.reflect.Method
import java.lang.reflect.Modifier
/**
* The decisions behind [VendorSurfaceProbe], kept free of Android so the JVM tests reach them.
*
* Two diagnostic tools stand on these rules (CR-044, CR-045). Both answer "where does this car
* publish X?" by READING what the runtime already exposes. Neither tool writes anything:
* - the SOC survey reads every candidate source of state of charge side by side;
* - the before/after capture reads everything twice — once before the driver flips a setting
* on the car's own screen, once after — and lists what changed.
*
* Getters are named by the runtime class itself, not by any external catalogue. What changed is
* a lead to prove per generation before it becomes a supported read or write.
*/
object VendorSurfaceRules {
/** Read-only name shapes: a getter answers, it does not act. */
private val GETTER_PREFIXES = listOf("get", "is", "has")
/**
* Words that make a "getter" something else: a call that starts, registers, resets or
* connects can move state even when its name begins with `get`, and one that hands back a
* service or a listener is plumbing, not a reading. Matched as whole camel-case words, so
* `getHighBeamSetting` and `isAutoEnabled` stay readings while `getAndReset` does not.
*/
private val ACTION_WORDS = setOf(
"set", "reset", "clear", "request", "start", "stop", "connect", "disconnect",
"register", "unregister", "bind", "unbind", "toggle", "send", "write", "apply", "click",
"and", "instance", "service", "binder", "context", "listener", "callback", "handler",
"looper",
)
/** `getAndResetCount` → [and, reset, count]. */
fun words(name: String): List<String> =
name.split(Regex("(?<=[a-z0-9])(?=[A-Z])|_")).filter { it.isNotEmpty() }
.map { it.lowercase() }
/** Only values: anything returning an object could hand back a binder or a context. */
private val VALUE_TYPES: Set<Class<*>> = setOf(
Int::class.javaPrimitiveType!!, Long::class.javaPrimitiveType!!,
Float::class.javaPrimitiveType!!, Double::class.javaPrimitiveType!!,
Boolean::class.javaPrimitiveType!!, Short::class.javaPrimitiveType!!,
Byte::class.javaPrimitiveType!!,
Int::class.javaObjectType, Long::class.javaObjectType, Float::class.javaObjectType,
Double::class.javaObjectType, Boolean::class.javaObjectType, Short::class.javaObjectType,
Byte::class.javaObjectType, String::class.java,
)
/** The getters of [cls] safe to call for a reading, sorted by name. */
fun readableGetters(cls: Class<*>, keywords: List<String> = emptyList()): List<Method> =
cls.methods
.filter { isReadableGetter(it) }
.filter { m -> keywords.isEmpty() || keywords.any { m.name.contains(it, ignoreCase = true) } }
.distinctBy { it.name }
.sortedBy { it.name }
fun isReadableGetter(m: Method): Boolean {
if (m.parameterCount != 0) return false
if (!Modifier.isPublic(m.modifiers) || Modifier.isStatic(m.modifiers)) return false
if (m.declaringClass == Any::class.java) return false
if (m.returnType !in VALUE_TYPES) return false
val name = m.name
val prefix = GETTER_PREFIXES.firstOrNull { name.startsWith(it) && name.length > it.length }
?: return false
if (!name[prefix.length].isUpperCase()) return false
return words(name.substring(prefix.length)).none { it in ACTION_WORDS }
}
data class Change(val key: String, val before: String?, val after: String?)
/**
* What differs between two readings. A key present on one side only is a change too: a
* property that starts answering when a feature is switched on is exactly the lead sought.
*/
fun diff(before: Map<String, String>, after: Map<String, String>): List<Change> =
(before.keys + after.keys).sorted()
.filter { before[it] != after[it] }
.map { Change(it, before[it], after[it]) }
/** One line per value, shaped like the rest of the diagnostic report. */
fun formatDiff(changes: List<Change>, beforeCount: Int, afterCount: Int): String = buildString {
appendLine("Values read: $beforeCount before, $afterCount after — ${changes.size} changed")
if (changes.isEmpty()) {
appendLine(" nothing changed: the setting is not in what this app can read, or the")
appendLine(" car had not applied it yet when the second capture was taken")
}
changes.forEach { appendLine(" ${label(it.key)}: ${it.before ?: "—"} → ${it.after ?: "—"}") }
}
private val CPM_KEY = Regex("""^CPM 0x([0-9A-Fa-f]{8})/""")
/** `CPM 0x11400E00/0` → `CPM 0x11400E00/0 (HEADLIGHTS_STATE)` for a standard id we name. */
fun label(key: String, names: Map<Int, String> = com.evsuite.hardware.ExteriorLights.NAMES): String {
val id = CPM_KEY.find(key)?.groupValues?.get(1)?.toLongOrNull(16)?.toInt() ?: return key
return names[id]?.let { "$key ($it)" } ?: key
}
/** Arrays print their content; everything else its own text. */
fun render(value: Any?): String = when (value) {
null -> "null"
is IntArray -> value.contentToString()
is LongArray -> value.contentToString()
is FloatArray -> value.contentToString()
is BooleanArray -> value.contentToString()
is ByteArray -> value.contentToString()
is Array<*> -> value.contentDeepToString()
else -> value.toString()
}
}
@@ -222,7 +222,7 @@ object SaicNavGuidance {
* A `List<EVRoutPoiInfo>` as `Parcel.writeTypedList` lays it out: the count, then per item a
* non-null marker and the bean's own three fields in the order its `writeToParcel` wrote
* them. Written by hand because the bean is the vendor's class and this module does not
* carry it — the layout is three lines and depending on a decompiled class would be worse.
* carry it — the layout is three lines and depending on an OEM class would be worse.
*/
internal fun Parcel.writeEvRoutPoiList(pois: List<NavigationHandoff.Poi>) {
writeInt(pois.size)
@@ -5,6 +5,7 @@ import android.os.IBinder
import com.evsuite.hardware.A9Climate
import com.evsuite.hardware.GlassProbe
import com.evsuite.hardware.VsmGlass
import com.evsuite.hardware.catalog.VehicleEnums
/**
* The vendor vehicle service the head unit's HVAC and charging screens talk to.
@@ -173,6 +174,15 @@ object SaicClimate {
fun fanLevelMax(): Int? = if (a9) null else level(TX_GET_MAX_AIR_VOLUME)
fun driverTemp(): Int? = if (a9) A9Climate.driverTemp() else level(TX_GET_DRV_TEMP)
fun passengerTemp(): Int? = if (a9) null else level(TX_GET_PSG_TEMP)
/**
* The passenger target exactly as the service answers it, for the diagnostic only.
*
* [passengerTemp] drops -1, which is also what the service's converter returns for a
* zone the car does not have: the raw number is what tells "single zone" from "the
* read did not answer" (null).
*/
fun passengerTempState(): Int? = if (a9) null else SaicAidl.callInt(binder(), DESCRIPTOR, TX_GET_PSG_TEMP)
fun frontDefrostOn(): Boolean? = if (a9) A9Climate.frontDefrostOn() else flag(TX_GET_FRONT_DEFROST)
fun rearDefrostOn(): Boolean? = if (a9) A9Climate.rearDefrostOn() else flag(TX_GET_BACK_DEFROST)
/** The service answers [TEMP_ERROR] for a reading it holds no value for, not an error. */
@@ -254,8 +264,18 @@ object SaicCharging {
fun chargingStatus(): Int? = read(TX_GET_CHARGING_STATUS)
/** Charge limit in percent — charging stops here. */
fun chargeLimitPercent(): Int? = read(TX_GET_CLOSE_SOC)
/**
* Charge limit in percent — charging stops here.
*
* Null outside the range the car accepts: SWI68 answered `5` on 2026-10-13, a code from a
* table nobody has read, not a percent. Null sends every caller down its "limit
* unreadable" path instead of planning a charge to 5 %.
*/
fun chargeLimitPercent(): Int? = chargeLimitOrNull(read(TX_GET_CLOSE_SOC))
internal fun chargeLimitOrNull(raw: Int?): Int? =
raw?.takeIf { it in VehicleEnums.CHARGE_LIMIT_MIN..VehicleEnums.CHARGE_LIMIT_MAX }
fun setChargeLimitPercent(percent: Int): Boolean =
SaicAidl.callVoid(binder(), DESCRIPTOR, TX_SET_CLOSE_SOC, percent.coerceIn(0, 100))
@@ -0,0 +1,163 @@
package com.evsuite.hardware.telemetry
import com.google.gson.Gson
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs
import kotlin.math.max
/**
* One day of the battery analyses, kept so that tomorrow can be compared with today (CP-087).
*
* Nothing here is measured: every field is copied from [StateOfHealthEstimator],
* [CalibrationDrift] and [BatteryExposure] as they stood that day. A null is an analysis that
* refused, never a zero.
*/
data class BatteryDigest(
/** The local calendar day, `yyyy-MM-dd`. One record per day; the last write of a day wins. */
val day: String,
val healthPercent: Double?,
/** Half-width of the health band, in points. Null where the estimate refused. */
val healthBandPercent: Double?,
val healthWindows: Int,
val calibration: CalibrationVerdict?,
val daysSinceFullCharge: Double?,
val hoursAboveHighSoc: Double?,
val equivalentFullCycles: Double?,
val chargeCount: Int,
) {
companion object {
fun of(
day: String,
health: StateOfHealthResult,
drift: CalibrationDriftReport?,
exposure: BatteryExposureReport?,
charge: ChargeEnergyReport,
): BatteryDigest {
val estimate = (health as? StateOfHealthResult.Ready)?.estimate
return BatteryDigest(
day = day,
healthPercent = estimate?.stateOfHealthPercent?.value,
healthBandPercent = estimate?.stateOfHealthPercent?.uncertainty,
healthWindows = estimate?.windowCount ?: 0,
calibration = drift?.verdict,
daysSinceFullCharge = drift?.daysSinceFullCharge,
hoursAboveHighSoc = exposure?.hoursAboveHighSoc,
equivalentFullCycles = exposure?.equivalentFullCycles,
chargeCount = charge.charges.size,
)
}
/**
* What moved between two days by more than its own uncertainty, and only that.
*
* A health figure that wanders inside its band has not changed: saying so every morning
* is the notification a driver turns off in a week.
*/
fun changes(previous: BatteryDigest?, current: BatteryDigest): List<DigestChange> {
if (previous == null) return emptyList()
return listOfNotNull(
healthMoved(previous, current),
bandNarrowed(previous, current),
calibrationChanged(previous, current),
)
}
private fun healthMoved(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val from = previous.healthPercent ?: return null
val to = current.healthPercent ?: return null
val band = max(previous.healthBandPercent ?: 0.0, current.healthBandPercent ?: 0.0)
if (abs(to - from) <= band) return null
return DigestChange.HealthMoved(from, to, current.healthBandPercent ?: band)
}
private fun bandNarrowed(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val from = previous.healthBandPercent
val to = current.healthBandPercent ?: return null
// A first estimate is a narrowing from nothing, and the driver has never seen it.
if (from == null) {
val health = current.healthPercent ?: return null
return DigestChange.HealthBandNarrowed(null, to, health)
}
if (from - to < MIN_BAND_NARROWING_PERCENT) return null
return DigestChange.HealthBandNarrowed(from, to, current.healthPercent ?: return null)
}
private fun calibrationChanged(previous: BatteryDigest, current: BatteryDigest): DigestChange? {
val to = current.calibration ?: return null
if (previous.calibration == to) return null
return DigestChange.CalibrationChanged(previous.calibration, to, current.daysSinceFullCharge)
}
/** A point of band is what one more watched window buys; less is rounding. */
const val MIN_BAND_NARROWING_PERCENT = 1.0
}
}
sealed interface DigestChange {
data class HealthMoved(val fromPercent: Double, val toPercent: Double, val bandPercent: Double) : DigestChange
data class HealthBandNarrowed(
val fromBandPercent: Double?,
val toBandPercent: Double,
val healthPercent: Double,
) : DigestChange
data class CalibrationChanged(
val from: CalibrationVerdict?,
val to: CalibrationVerdict,
val daysSinceFullCharge: Double?,
) : DigestChange
}
/** A year of days, newest last. Same atomic write and quarantine as [BatteryLedgerStore]. */
class BatteryDigestStore(
private val target: File,
private val maxDays: Int = MAX_DAYS,
private val gson: Gson = Gson(),
) {
fun read(): List<BatteryDigest> {
if (!target.exists()) return emptyList()
val text = runCatching { target.readText() }.getOrNull() ?: return emptyList()
val envelope = runCatching { gson.fromJson(text, Envelope::class.java) }.getOrNull()
if (envelope == null || envelope.schemaVersion != SCHEMA_VERSION) {
target.renameTo(File(target.parentFile, "${target.name}.quarantine.${System.currentTimeMillis()}"))
return emptyList()
}
@Suppress("SENSELESS_COMPARISON")
return envelope.days.orEmpty().filter { it != null && it.day != null }.sortedBy { it.day }
}
/** Replaces the record of the same day, or adds it; returns the day before it, if any. */
fun upsert(digest: BatteryDigest): BatteryDigest? {
val others = read().filter { it.day != digest.day }
val previous = others.lastOrNull { it.day < digest.day }
write((others + digest).sortedBy { it.day }.takeLast(maxDays.coerceAtLeast(1)))
return previous
}
private fun write(days: List<BatteryDigest>): Boolean {
val bytes = gson.toJson(Envelope(SCHEMA_VERSION, days)).toByteArray(Charsets.UTF_8)
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.${System.nanoTime()}.tmp")
return try {
FileOutputStream(temp).use { output ->
output.write(bytes)
output.fd.sync()
}
if (temp.renameTo(target)) true else {
temp.delete()
false
}
} catch (_: Exception) {
temp.delete()
false
}
}
private data class Envelope(val schemaVersion: Int = 0, val days: List<BatteryDigest>? = null)
companion object {
const val SCHEMA_VERSION = 1
const val MAX_DAYS = 365
const val FILE_NAME = "battery-digest.json"
}
}
@@ -166,6 +166,9 @@ class BatteryLedgerRecorder(
/** @return true when this sample earned an entry and it was written. */
fun observe(snapshot: EnergySnapshot): Boolean {
// A head unit woken by a night charge runs on its factory clock (late 2018) until it syncs.
// The ledger is read in time order, so one such entry would sort first and split the charge.
if (snapshot.timestampMs < MIN_PLAUSIBLE_CLOCK_MS) return false
seed()
integrate(snapshot)
val soc = snapshot.socPercent?.takeIf { it.isFinite() } ?: return false
@@ -231,5 +234,8 @@ class BatteryLedgerRecorder(
const val SOC_STEP_PERCENT = 1.0f
const val QUIET_INTERVAL_MS = 15 * 60 * 1000L
const val MAX_SAMPLE_GAP_MS = 5_000L
/** 2025-01-01T00:00Z: no reading of this app can be older. Seen by MG4Control on the car. */
const val MIN_PLAUSIBLE_CLOCK_MS = 1_735_689_600_000L
}
}
@@ -0,0 +1,137 @@
package com.evsuite.hardware.telemetry
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
/**
* How long the rest of a charge takes, from the driver's own watched charges (CP-089).
*
* The unit is minutes per point of charge, measured on [ChargeEnergy.steps]: the wall clock
* between two ledger entries over the points between them. No kWh per point and no power sign
* stand between the history and the answer. A band of [BAND_PERCENT] points that no watched
* charge crossed is a refusal, never an extrapolation.
*/
data class ChargeDurationEstimate(
val fromPercent: Double,
val targetPercent: Int,
/** Median charge per band, summed. */
val minutes: Double,
/** Fastest and slowest past charge per band, summed: the range the history allows. */
val lowMinutes: Double,
val highMinutes: Double,
/** From [LAST_STRETCH_PERCENT] to the target, median; null when the path does not cross it. */
val lastStretchMinutes: Double?,
/** True when only charges at a similar outside temperature were used. */
val similarTemperature: Boolean,
)
sealed interface ChargeDurationResult {
data class Ready(val estimate: ChargeDurationEstimate) : ChargeDurationResult
data class Refused(val reason: ChargeDurationRefusal, val bandFromPercent: Int? = null) :
ChargeDurationResult
}
enum class ChargeDurationRefusal { AT_TARGET, NO_WATCHED_CHARGE, MISSING_BAND }
/** One band of the charge in progress against the same band in past charges. */
data class ChargeCurveCheck(
val bandFromPercent: Int,
val minutesPerPoint: Double,
val usualLowMinutesPerPoint: Double,
val usualHighMinutesPerPoint: Double,
val pastCharges: Int,
) {
/** Slower than the slowest past charge in that band: less power than the history's low edge. */
val slower: Boolean get() = minutesPerPoint > usualHighMinutesPerPoint
}
object ChargeDuration {
fun estimate(
charges: List<ChargeEnergy>,
socPercent: Double,
targetPercent: Int,
outsideCelsius: Double?,
): ChargeDurationResult {
if (socPercent >= targetPercent) return ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET)
if (outsideCelsius != null) {
val similar = charges.filter { charge ->
val low = charge.minOutsideTempCelsius
val high = charge.maxOutsideTempCelsius
low != null && high != null && abs((low + high) / 2.0 - outsideCelsius) <= SIMILAR_TEMPERATURE_CELSIUS
}
val ready = along(history(similar), socPercent, targetPercent, similar = true)
if (ready is ChargeDurationResult.Ready) return ready
}
return along(history(charges), socPercent, targetPercent, similar = false)
}
fun curveCheck(charges: List<ChargeEnergy>, bandFromPercent: Int, minutesPerPoint: Double): ChargeCurveCheck? {
val past = history(charges)[bandFromPercent / BAND_PERCENT].orEmpty()
if (past.size < MIN_PAST_CHARGES) return null
return ChargeCurveCheck(bandFromPercent, minutesPerPoint, past.min(), past.max(), past.size)
}
/** Band index to one minutes-per-point figure per past charge that crossed it. */
fun history(charges: List<ChargeEnergy>): Map<Int, List<Double>> {
val byBand = HashMap<Int, MutableList<Double>>()
charges.forEach { charge ->
charge.steps.filter { it.toSocPercent > it.fromSocPercent && it.durationMs > 0 }
.groupBy { band(it.midSocPercent) }
.forEach { (band, steps) ->
val points = steps.sumOf { it.toSocPercent - it.fromSocPercent }
byBand.getOrPut(band) { ArrayList() } += steps.sumOf { it.durationMs } / MINUTE_MS / points
}
}
return byBand
}
private fun along(
history: Map<Int, List<Double>>,
socPercent: Double,
targetPercent: Int,
similar: Boolean,
): ChargeDurationResult {
if (history.isEmpty()) return ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE)
var minutes = 0.0
var low = 0.0
var high = 0.0
var lastStretch = 0.0
val first = band(socPercent)
val last = ceil(targetPercent / BAND_PERCENT.toDouble()).toInt() - 1
for (band in first..last) {
val past = history[band]?.sorted()
?: return ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, band * BAND_PERCENT)
val from = maxOf(socPercent, band * BAND_PERCENT.toDouble())
val to = minOf(targetPercent.toDouble(), (band + 1) * BAND_PERCENT.toDouble())
val median = past[past.size / 2]
minutes += (to - from) * median
low += (to - from) * past.first()
high += (to - from) * past.last()
val stretchFrom = maxOf(from, LAST_STRETCH_PERCENT.toDouble())
if (to > stretchFrom) lastStretch += (to - stretchFrom) * median
}
val crosses = socPercent < LAST_STRETCH_PERCENT && targetPercent > LAST_STRETCH_PERCENT
return ChargeDurationResult.Ready(
ChargeDurationEstimate(
fromPercent = socPercent,
targetPercent = targetPercent,
minutes = minutes,
lowMinutes = low,
highMinutes = high,
lastStretchMinutes = if (crosses) lastStretch else null,
similarTemperature = similar,
),
)
}
private fun band(socPercent: Double): Int =
floor(socPercent / BAND_PERCENT).toInt().coerceIn(0, 100 / BAND_PERCENT - 1)
const val BAND_PERCENT = 10
const val LAST_STRETCH_PERCENT = 80
const val SIMILAR_TEMPERATURE_CELSIUS = 8.0
const val MIN_PAST_CHARGES = 3
private const val MINUTE_MS = 60_000.0
}
@@ -0,0 +1,405 @@
package com.evsuite.hardware.telemetry
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
/**
* Which way this app's own integral moved while charge was entering the pack.
*
* [EnergySnapshot.batteryPowerKw] declares positive as energy leaving the battery, so an
* integral that **falls** during a charge is the declared convention holding. One that rises is
* the same convention inverted, and it matters well beyond a sign on a dashboard: the state of
* health estimator reads `PACK_PAIR_INTEGRAL` windows as energy that *left* the pack, so an
* inverted source would hand it discharge windows with the sign of a charge and the estimator
* would refuse every one of them.
*
* Nothing here corrects anything. RI-002 asks what the car actually does and this reports it.
*/
enum class ChargePackSign {
/** The integral fell, which is the convention [EnergySnapshot.batteryPowerKw] declares. */
ENERGY_FELL,
/** The integral rose, so the property signs a charge the other way round. */
ENERGY_ROSE,
/** Readable and level: the pack pair said nothing moved while the gauge said it did. */
FLAT,
/** No charge was watched closely enough, or no pack reading was integrated at all. */
UNREADABLE,
/** Two charges answered differently, which settles nothing and must not be averaged. */
CONTRADICTORY,
}
/**
* What the car's own kWh counters did across a charge.
*
* `SaicCharging` transactions 75 and 77 are documented as *since the last charge*, so the
* expected behaviour is [RESET] — and "expected" is the whole reason to measure it. A counter
* that resets is what lets [StateOfHealthEstimator] tell one discharge window from the next; a
* counter that never moves is a source that will never produce a window at all.
*/
enum class ChargeCounterBehaviour {
/** A counter went backwards: the car cleared it, which is what a charge is said to do. */
RESET,
/**
* A counter rose during the charge, which "since the last charge" does not predict.
*/
ROSE,
/** Read at both ends and unchanged. */
STATIC,
/** Never published on either end of any charge. */
ABSENT,
}
/**
* One charge session measured in energy rather than in points of charge.
*
* [BatteryExposure] already segments the ledger into sessions and says how fast each one moved
* the gauge. This adds the half nothing reads today: how much energy went in, which way the
* pack pair signed it, and what the car's own counters did while it happened.
*/
data class ChargeEnergy(
val session: BatteryChargeSession,
/**
* Whether this app was sampling throughout.
*
* The ledger earns an entry every quarter of an hour whatever else happens, so a longer
* silence than that is the app having been down — a head unit asleep on a charger overnight
* is the ordinary case, not an edge one. An unwatched charge still gained the charge it
* gained; what it cannot carry is a power, because the integral stopped moving while nobody
* was integrating.
*/
val watched: Boolean,
val maxEntryGapMs: Long,
/** Signed, as the integral moved. Negative is the declared convention; see [ChargePackSign]. */
val packDeltaKwh: Double?,
/** Magnitude over the wall clock, and only where the whole session was watched. */
val meanPowerKw: Double?,
val counterBehaviour: ChargeCounterBehaviour,
val consumedDeltaKwh: Double?,
val regeneratedDeltaKwh: Double?,
/**
* Odometer moved, when the car published one at both ends.
*
* A rise in charge is how a charge is detected here, and a long descent raises the charge
* too. Distance is what tells the two apart, so a moving session is regeneration and is
* never offered to the driver as "your last charge".
*/
val movedKm: Double?,
/** Every distinct value the charging status held, in the order the ledger saw them. */
val chargingStatuses: List<Int>,
/**
* Pack power between each pair of watched ledger entries, which the recorder writes once
* per point of charge: this is the charge curve, one step per point. Empty when unwatched.
*/
val steps: List<ChargePowerStep> = emptyList(),
/** Outside temperature over the session, named as such: the pack's own is unpublished. */
val minOutsideTempCelsius: Double? = null,
val maxOutsideTempCelsius: Double? = null,
) {
/** The fastest watched step, measured; null when no step was watched. */
val peakPowerKw: Double? get() = steps.maxOfOrNull { it.powerKw }
/**
* Whether this rise was a charge rather than regeneration.
*
* Three pieces of evidence, any one of them enough:
* - the odometer stood still;
* - the car reported a non-zero charging status during the rise (every drive recorded so far
* read zero throughout, 1 473 samples on SWI68);
* - the rise is too large to be regeneration **and** nobody watched the car move during it.
* A head unit asleep on the charger is the ordinary overnight case: the ledger then holds
* the evening entry and the morning one, the odometer may have moved between them because
* the drive home was never recorded either, and requiring it to stand still is what made
* a whole night's charge read as a descent and vanish from the battery page.
*/
val plugged: Boolean
get() {
val moved = movedKm
if (moved != null && moved <= STATIONARY_TOLERANCE_KM) return true
if (chargingStatuses.any { it != 0 }) return true
return session.gainedPercent >= UNAMBIGUOUS_CHARGE_PERCENT && (moved == null || !watched)
}
val sign: ChargePackSign
get() {
val delta = packDeltaKwh
if (!watched || delta == null) return ChargePackSign.UNREADABLE
return when {
delta <= -ChargeEnergyAnalyzer.MIN_ENERGY_KWH -> ChargePackSign.ENERGY_FELL
delta >= ChargeEnergyAnalyzer.MIN_ENERGY_KWH -> ChargePackSign.ENERGY_ROSE
else -> ChargePackSign.FLAT
}
}
companion object {
/** The odometer reads in whole kilometres, so anything under one is standing still. */
const val STATIONARY_TOLERANCE_KM = 0.5
/**
* A rise no descent produces. Regeneration on the steepest ordinary pass returns a few
* points; ten points of a 61,7 kWh pack is over 6 kWh of potential energy recovered.
*/
const val UNAMBIGUOUS_CHARGE_PERCENT = 10.0
}
}
/** One watched step of a charge: from one ledger entry to the next, usually one point. */
data class ChargePowerStep(
val fromSocPercent: Double,
val toSocPercent: Double,
val durationMs: Long,
/** Magnitude of the pack-pair integral's move over the step's wall clock. */
val powerKw: Double,
) {
val midSocPercent: Double get() = (fromSocPercent + toSocPercent) / 2.0
}
/** Time-weighted mean power over every watched step whose midpoint fell in one charge band. */
data class SocBandPower(
val fromPercent: Int,
val toPercent: Int,
val meanPowerKw: Double,
val hours: Double,
)
/** Every charge the ledger holds, and the two questions the set of them settles. */
data class ChargeEnergyReport(
val charges: List<ChargeEnergy>,
val packSign: ChargePackSign,
val counterBehaviour: ChargeCounterBehaviour,
val chargingStatuses: List<Int>,
) {
/** The most recent charge that stood still, which is the one a driver means by "my charge". */
val lastPluggedCharge: ChargeEnergy? get() = charges.lastOrNull { it.plugged }
/** Every charge that was a charge, oldest first. */
val pluggedCharges: List<ChargeEnergy> get() = charges.filter { it.plugged }
/**
* The charge curve across every watched charge: mean power per band of [bandPercent]
* points of charge. This is where a taper shows — the pack accepting less as it fills —
* and it is measured, one step per point, never modelled.
*/
fun powerBySocBand(bandPercent: Int = SOC_BAND_PERCENT): List<SocBandPower> =
pluggedCharges.flatMap { it.steps }
.groupBy { (it.midSocPercent / bandPercent).toInt().coerceIn(0, 100 / bandPercent - 1) }
.toSortedMap()
.map { (band, steps) ->
val ms = steps.sumOf { it.durationMs }.toDouble()
SocBandPower(
fromPercent = band * bandPercent,
toPercent = (band + 1) * bandPercent,
meanPowerKw = steps.sumOf { it.powerKw * it.durationMs } / ms,
hours = ms / 3_600_000.0,
)
}
val watchedCount: Int get() = charges.count { it.watched }
/**
* The report as lines, because the answer belongs in the diagnostic bundle rather than
* only on a screen. Same reason [com.evsuite.hardware.telemetry.model.SocConsumptionFitter]
* describes itself: a ticket is settled by a bundle a driver brought back, not by a UI.
*/
fun describe(): List<String> {
if (charges.isEmpty()) return listOf("charges=0")
val lines = ArrayList<String>()
lines += "charges=${charges.size} watched=$watchedCount"
lines += "pack_pair_sign=${packSign.name}"
lines += "vehicle_counters=${counterBehaviour.name}"
lines += "charging_status_values=" + chargingStatuses.joinToString(",").ifEmpty { "none" }
charges.takeLast(MAX_DESCRIBED_CHARGES).forEach { charge ->
lines += "charge started_epoch_ms=${charge.session.startedAtMs}" +
" gained_percent=${format(charge.session.gainedPercent)}" +
" hours=${format(charge.session.durationHours)}" +
" watched=${charge.watched}" +
" max_entry_gap_ms=${charge.maxEntryGapMs}" +
// Unwatched, the integral only moved during the few seconds the app was up: a
// number there reads as a measured charge of nearly nothing.
" pack_delta_kwh=${format(charge.packDeltaKwh?.takeIf { charge.watched })}" +
" mean_power_kw=${format(charge.meanPowerKw)}" +
" consumed_delta_kwh=${format(charge.consumedDeltaKwh)}" +
" regenerated_delta_kwh=${format(charge.regeneratedDeltaKwh)}" +
" moved_km=${format(charge.movedKm)}" +
" counters=${charge.counterBehaviour.name}" +
" sign=${charge.sign.name}" +
" plugged=${charge.plugged}" +
" peak_power_kw=${format(charge.peakPowerKw)}" +
" steps=${charge.steps.size}"
}
powerBySocBand().forEach {
lines += "soc_band ${it.fromPercent}-${it.toPercent} mean_power_kw=${format(it.meanPowerKw)}" +
" hours=${format(it.hours)}"
}
return lines
}
private fun format(value: Double?): String =
if (value == null) "unavailable" else String.format(Locale.ROOT, "%.2f", value)
companion object {
/** A bundle is bounded; the newest charges are the ones a question is asked about. */
const val MAX_DESCRIBED_CHARGES = 8
const val SOC_BAND_PERCENT = 10
}
}
/**
* The energy side of a charge, read from the ledger that is already being written.
*
* Nothing new is sampled and no new vehicle call is made. [BatteryLedgerRecorder] has been
* recording the charge gauge, the car's counters, the integrated pack energy and the odometer
* since CP-070; [BatteryExposure] already cuts that stream into charge sessions. What was
* missing is a reader: the pack's health is estimated from discharges alone, so the energy
* *entering* the pack — and with it the two facts RI-002 has been blocked on — was recorded and
* never looked at.
*
* **Sessions are not re-segmented here.** They arrive from [BatteryExposure], so the two
* screens can never disagree about what counts as a charge.
*/
class ChargeEnergyAnalyzer(
private val maxWatchedGapMs: Long = MAX_WATCHED_GAP_MS,
) {
fun analyse(
entries: List<BatteryLedgerEntry>,
sessions: List<BatteryChargeSession>,
): ChargeEnergyReport {
val ordered = entries.sortedBy { it.atMs }
val charges = sessions.map { measure(ordered, it) }
return ChargeEnergyReport(
charges = charges,
packSign = consensusSign(charges),
counterBehaviour = mostInformative(charges.map { it.counterBehaviour }),
chargingStatuses = charges.flatMap { it.chargingStatuses }.distinct().sorted(),
)
}
private fun measure(
ordered: List<BatteryLedgerEntry>,
session: BatteryChargeSession,
): ChargeEnergy {
val slice = ordered.filter { it.atMs in session.startedAtMs..session.endedAtMs }
var gap = 0L
for (index in 1 until slice.size) gap = max(gap, slice[index].atMs - slice[index - 1].atMs)
val watched = slice.size >= 2 && gap <= maxWatchedGapMs
val packDelta = span(slice.mapNotNull { it.packEnergyKwh })
val consumed = span(slice.mapNotNull { it.vehicleConsumedKwh?.toDouble() })
val regenerated = span(slice.mapNotNull { it.vehicleRegeneratedKwh?.toDouble() })
val hours = session.durationHours
val temps = slice.mapNotNull { it.outsideTempCelsius?.toDouble() }
return ChargeEnergy(
session = session,
watched = watched,
maxEntryGapMs = gap,
packDeltaKwh = packDelta,
meanPowerKw = if (watched && packDelta != null && hours > 0.0) {
abs(packDelta) / hours
} else {
null
},
counterBehaviour = behaviourOf(consumed, regenerated),
consumedDeltaKwh = consumed,
regeneratedDeltaKwh = regenerated,
movedKm = span(slice.mapNotNull { it.odometerKm?.toDouble() }),
chargingStatuses = slice.mapNotNull { it.chargingStatus }.distinct(),
steps = if (watched) steps(slice) else emptyList(),
minOutsideTempCelsius = temps.minOrNull(),
maxOutsideTempCelsius = temps.maxOrNull(),
)
}
/** Adjacent pairs that both carry the integral; a pair with none says nothing and is dropped. */
private fun steps(slice: List<BatteryLedgerEntry>): List<ChargePowerStep> =
slice.zipWithNext().mapNotNull { (a, b) ->
val from = a.packEnergyKwh ?: return@mapNotNull null
val to = b.packEnergyKwh ?: return@mapNotNull null
val ms = b.atMs - a.atMs
if (ms <= 0L || !from.isFinite() || !to.isFinite()) return@mapNotNull null
ChargePowerStep(
fromSocPercent = a.socPercent.toDouble(),
toSocPercent = b.socPercent.toDouble(),
durationMs = ms,
powerKw = abs(to - from) / (ms / 3_600_000.0),
)
}
/** Last reading minus first, or null when fewer than two of them were published. */
private fun span(values: List<Double>): Double? {
val finite = values.filter { it.isFinite() }
return if (finite.size < 2) null else finite.last() - finite.first()
}
private fun behaviourOf(consumed: Double?, regenerated: Double?): ChargeCounterBehaviour {
val deltas = listOfNotNull(consumed, regenerated)
if (deltas.isEmpty()) return ChargeCounterBehaviour.ABSENT
if (deltas.any { it <= -COUNTER_EPSILON_KWH }) return ChargeCounterBehaviour.RESET
if (deltas.any { it >= COUNTER_EPSILON_KWH }) return ChargeCounterBehaviour.ROSE
return ChargeCounterBehaviour.STATIC
}
/**
* One answer from several charges, or an admission that there is not one.
*
* Two charges that signed the pack pair differently is not a figure to average: it says the
* property means something this app has not understood, and reporting either half of it as
* the answer would settle RI-002 wrongly.
*/
private fun consensusSign(charges: List<ChargeEnergy>): ChargePackSign {
val signs = charges.map { it.sign }
val decided = signs.filter {
it == ChargePackSign.ENERGY_FELL || it == ChargePackSign.ENERGY_ROSE
}.distinct()
return when {
decided.size > 1 -> ChargePackSign.CONTRADICTORY
decided.size == 1 -> decided.first()
signs.contains(ChargePackSign.FLAT) -> ChargePackSign.FLAT
else -> ChargePackSign.UNREADABLE
}
}
/**
* A reset seen once is a fact about the car; a static reading elsewhere only says that
* charge did not end where the car clears its counters. So the set is reported by its most
* informative member rather than refused as a contradiction.
*/
private fun mostInformative(behaviours: List<ChargeCounterBehaviour>): ChargeCounterBehaviour =
INFORMATIVENESS.firstOrNull { behaviours.contains(it) } ?: ChargeCounterBehaviour.ABSENT
companion object {
/**
* How long the ledger may go quiet inside a charge before it stops being watched.
*
* Twice [BatteryLedgerRecorder.QUIET_INTERVAL_MS]: the recorder earns an entry every
* quarter of an hour on its own, so a longer silence is this app having been down rather
* than a slow charge. The margin covers a sampler that stopped for one interval.
*/
const val MAX_WATCHED_GAP_MS = 2 * BatteryLedgerRecorder.QUIET_INTERVAL_MS
/**
* Below this a charge says nothing about the sign.
*
* A watched charge of any length moves the integral by kilowatt-hours; half of one is
* comfortably below anything real and comfortably above the drift of a pack pair read
* at rest.
*/
const val MIN_ENERGY_KWH = 0.5
/** The car's counters step by a whole kilowatt-hour, so anything above noise is a move. */
const val COUNTER_EPSILON_KWH = 0.05
private val INFORMATIVENESS = listOf(
ChargeCounterBehaviour.RESET,
ChargeCounterBehaviour.ROSE,
ChargeCounterBehaviour.STATIC,
ChargeCounterBehaviour.ABSENT,
)
}
}
@@ -0,0 +1,108 @@
package com.evsuite.hardware.telemetry
import java.time.Instant
import java.time.YearMonth
import java.time.ZoneId
/**
* What a drive asked of the pack at the moments a pack minds most (CP-090): hard discharge at a
* low charge or in the cold, and regeneration pushed into a cold pack.
*
* Measured quantities only, never a wear figure. "Hard" is the trip's own: discharge above
* [HARD_SHARE] of that trip's peak, so a gentle driver's hardest moments count as well as a
* heavy one's.
*
* [TripSample.batteryPowerKw]'s sign is unvalidated on this car (RI-002), so each trip orients
* itself: the charge fell, so the way the power integral moved over the trip is discharge. A trip
* whose charge did not fall, or whose integral did not move, cannot say which way is which and
* gives no figure.
*/
data class DrivingStress(
/** Minutes of hard discharge below [LOW_SOC_PERCENT] charge or below [COLD_CELSIUS] outside. */
val stressedMinutes: Double,
/** Energy regenerated below [COLD_CELSIUS] outside; null when the temperature was never read. */
val coldRegeneratedKwh: Double?,
) {
companion object {
/**
* Null when the trip cannot say: no orientation, or more than [MAX_UNDECIDED_SHARE] of
* its time with an unread power, or a hard moment with an unread charge or temperature.
*/
fun of(samples: List<TripSample>): DrivingStress? {
val socs = samples.mapNotNull { it.socPercent }
if (socs.size < 2 || socs.first() - socs.last() < MIN_SOC_DROP) return null
val segments = samples.zipWithNext().filter { (a, b) -> b.atMs - a.atMs in 1..MAX_GAP_MS }
val net = segments.sumOf { (a, b) -> (a.batteryPowerKw ?: 0f) * hours(a, b) }
val orientation = when {
net >= MIN_NET_KWH -> 1.0
net <= -MIN_NET_KWH -> -1.0
else -> return null
}
val peak = samples.mapNotNull { it.batteryPowerKw }.maxOfOrNull { it * orientation } ?: return null
if (peak <= 0.0) return null
var totalMs = 0L
var undecidedMs = 0L
var stressedMs = 0L
var coldKnown = false
var coldRegen = 0.0
segments.forEach { (a, b) ->
val ms = b.atMs - a.atMs
totalMs += ms
// An unread power could have been a hard one.
val power = a.batteryPowerKw ?: run { undecidedMs += ms; return@forEach }
val discharge = power * orientation
val cold = a.outsideTempCelsius?.let { it < COLD_CELSIUS }
val low = a.socPercent?.let { it < LOW_SOC_PERCENT }
if (cold != null) coldKnown = true
if (cold == true && discharge < 0.0) coldRegen += -discharge * hours(a, b)
if (discharge <= HARD_SHARE * peak) return@forEach
when {
cold == true || low == true -> stressedMs += ms
cold == null || low == null -> undecidedMs += ms
}
}
if (totalMs == 0L || undecidedMs > MAX_UNDECIDED_SHARE * totalMs) return null
return DrivingStress(stressedMs / MINUTE_MS, coldRegen.takeIf { coldKnown })
}
/** Per local month, over the trips that could say; a month none could say is absent. */
fun monthly(trips: List<StoredTrip>, zone: ZoneId): List<MonthlyDrivingStress> = trips
.mapNotNull { trip -> of(trip.samples.orEmpty())?.let { trip.summary.startedAtMs to it } }
.groupBy { (atMs, _) -> YearMonth.from(Instant.ofEpochMilli(atMs).atZone(zone)) }
.map { (month, list) ->
val regen = list.mapNotNull { it.second.coldRegeneratedKwh }
MonthlyDrivingStress(
month = month,
stressedMinutes = list.sumOf { it.second.stressedMinutes },
coldRegeneratedKwh = regen.takeIf { it.isNotEmpty() }?.sum(),
trips = list.size,
)
}
.sortedBy { it.month }
private fun hours(a: TripSample, b: TripSample) = (b.atMs - a.atMs) / HOUR_MS
const val HARD_SHARE = 0.6
const val LOW_SOC_PERCENT = 20f
const val COLD_CELSIUS = 0f
/** A trip that spent less than a point cannot orient its power. */
const val MIN_SOC_DROP = 1f
const val MIN_NET_KWH = 0.1
/** A longer hole in the track is a pause, not a stretch of known power. */
const val MAX_GAP_MS = 5 * 60_000L
/** A dropped reading here and there is not a blind trip; more than this is. */
const val MAX_UNDECIDED_SHARE = 0.1
private const val MINUTE_MS = 60_000.0
private const val HOUR_MS = 3_600_000.0
}
}
data class MonthlyDrivingStress(
val month: YearMonth,
val stressedMinutes: Double,
val coldRegeneratedKwh: Double?,
/** Trips that could say; the others are not counted as zero. */
val trips: Int,
)
@@ -0,0 +1,384 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
import java.util.Locale
import kotlin.math.abs
/** How this drive compares with what the driver's own fitted history expected of it. */
enum class EcoBand {
/** Below the expectation by more than the fit's own band. */
BETTER,
/** Inside the band. A fit that does not know much says this often, and should. */
TYPICAL,
/** Above the expectation by more than the band. */
WORSE,
}
/**
* The one thing worth saying, chosen from the two the recorded signals can measure.
*
* The climate system is deliberately not here. Cabin state is passive state — an on/off flag and
* a fan level — and turning either into kilowatts is exactly what `AGENTS.md` forbids. The cabin's
* share is a fitted number that only exists after the trip, where `EnergyAttribution` produces it.
*/
enum class EcoLever {
/**
* Drag rises with the square of speed, and the fitted model carries that term, so the saving
* is arithmetic on a fit this car trained rather than a rule of thumb.
*/
CRUISE_SPEED,
/**
* Time spent accelerating or braking hard. Measured from the speed channel alone, which
* matters: on a firmware where battery power is derived rather than measured this lever is
* still a measurement.
*/
STEADINESS,
}
/**
* One piece of advice, with the number that earned it.
*
* Exactly one of the two number pairs is populated, decided by [lever]. There is no common
* currency between "14 % less at 110" and "a fifth of this drive was hard acceleration", and
* inventing one to rank them would be inventing the more important of the two figures.
*/
data class EcoAdvice(
val lever: EcoLever,
/** [EcoLever.CRUISE_SPEED]: what the fit says the slower speed costs, as a percentage saved. */
val savingPercent: Double? = null,
val fromSpeedKmh: Double? = null,
val toSpeedKmh: Double? = null,
/** [EcoLever.STEADINESS]: the share of moving time spent beyond the threshold, either sign. */
val harshSharePercent: Double? = null,
)
/**
* What this drive is costing, against what this driver's own history expected it to cost.
*
* Everything here is in **percent of charge per 100 km**, never kilowatt-hours, and that is a
* decision rather than a convenience: `EnergySnapshot.batteryPowerKw`'s sign convention is still
* unvalidated (RI-002, CP-073), so every kWh figure in this app inherits an open question. Charge
* and speed are the two signals CP-003 actually proved on this car, and a verdict the driver is
* asked to act on is built on those.
*/
data class EcoVerdict(
/**
* The verdict, and — as its band — the width of the model's own `TYPICAL` zone in percent
* per 100 km: how far [observedPercentPer100Km] would have had to move to say something
* else. A fit that knows little carries a wide one and says `TYPICAL` for almost any drive,
* which is the honest answer from a fit that knows little.
*/
val band: Provenanced<EcoBand>,
val observedPercentPer100Km: Double? = null,
val expectedPercentPer100Km: Double? = null,
/** Signed: negative is below the expectation, which is the good direction. */
val deltaPercent: Double? = null,
val meanSpeedKmh: Double? = null,
val advice: EcoAdvice? = null,
) {
/** For the diagnostic bundle: the verdict, its inputs, and any refusal, on one line each. */
fun describe(): List<String> {
val lines = ArrayList<String>()
lines += "band=" + (band.value?.name ?: "unavailable(${band.reason?.name})")
lines += "observed_percent_per_100km=${format(observedPercentPer100Km)}"
lines += "expected_percent_per_100km=${format(expectedPercentPer100Km)}"
lines += "delta_percent=${format(deltaPercent)}"
lines += "mean_speed_kmh=${format(meanSpeedKmh)}"
val advice = advice
lines += if (advice == null) {
"advice=none"
} else {
"advice=${advice.lever.name}" +
" saving_percent=${format(advice.savingPercent)}" +
" from_speed_kmh=${format(advice.fromSpeedKmh)}" +
" to_speed_kmh=${format(advice.toSpeedKmh)}" +
" harsh_share_percent=${format(advice.harshSharePercent)}"
}
return lines
}
private fun format(value: Double?): String =
if (value == null) "unavailable" else String.format(Locale.ROOT, "%.2f", value)
}
/**
* The eco verdict and its one piece of advice, live on the dashboard and replayed after the trip.
*
* **One analyzer, two callers.** The dashboard feeds it the 1 Hz speed channel as it arrives; the
* trip review replays a stored [TripSample] track through the same instance. That is the whole
* reason it lives here rather than in the app: the number the driver saw at 90 km/h and the number
* they read afterwards come out of the same code, and cannot disagree.
*
* **It costs nothing to run.** No new sampling, no vehicle call, no allocation per frame beyond one
* bounded deque of speeds. The verdict is arithmetic over a trip summary the recorder already keeps
* and a model CP-052 already trained.
*
* **It refuses rather than guesses.** No fit, a speed outside the envelope the fit was trained in,
* too little distance to divide by, too small a charge drop to read against a gauge that moves in
* whole percent — each of those is an [UnavailableReason] the rest of the app already renders, not
* a number with a shrug attached.
*/
class EcoDrivingMonitor(
private val windowMs: Long = WINDOW_MS,
private val harshAccelerationMs2: Double = HARSH_ACCELERATION_MS2,
private val maxSamples: Int = MAX_WINDOW_SAMPLES,
private val maxGapMs: Long = MAX_GAP_MS,
) {
private data class Speed(val atMs: Long, val kmh: Double)
private val window = ArrayDeque<Speed>()
init {
require(windowMs > 0L) { "the lever window must be positive" }
require(harshAccelerationMs2 > 0.0) { "the acceleration threshold must be positive" }
require(maxSamples > 1) { "a window of one sample measures nothing" }
require(maxGapMs > 0L) { "the sampling gap must be positive" }
}
/** Feed one frame. A null or negative speed is a gap in the window, never a zero. */
fun add(atMs: Long, speedKmh: Float?) {
if (window.lastOrNull()?.let { atMs <= it.atMs } == true) reset()
val speed = speedKmh?.toDouble()?.takeIf { it.isFinite() && it >= 0.0 }
if (speed != null) window.addLast(Speed(atMs, speed))
while (window.size > maxSamples) window.removeFirst()
while (window.size > 1 && atMs - window.first().atMs > windowMs) window.removeFirst()
}
fun reset() = window.clear()
/** Replays a stored track, so a finished trip is judged by the same code that judged it live. */
fun replay(samples: List<TripSample>): EcoDrivingMonitor {
reset()
samples.forEach { add(it.atMs, it.speedKmh) }
return this
}
/**
* The share of moving time spent accelerating or braking hard, whatever the cruise lever said.
*
* Live, the two levers compete for one line and the cruise lever wins when it has a measured
* saving. A trip review has no such contest: the motorway question is answered there by
* `SpeedWhatIfCalculator` from the trip's own intervals, and this is the other half.
*/
fun steadiness(): EcoAdvice? = steadinessAdvice()
/**
* @param trip the trip so far, live, or the finished one. Its charge drop and its distance are
* what the verdict is measured from.
* @param outsideTempCelsius the fit's second input; without it the model cannot be asked.
*/
fun verdict(
model: SocConsumptionModel?,
trip: EnergyTripSummary?,
outsideTempCelsius: Float?,
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
): EcoVerdict {
val advice = advice(model, outsideTempCelsius)
if (model == null) return refuse(UnavailableReason.MODEL_NOT_TRAINED, advice)
// A distance the current speed conversion did not produce may carry a 3.6x error, and a
// verdict built on it would be wrong by the same factor without saying so.
val distanceKm = trip?.modellableDistanceKm(generation)
val start = trip?.startSocPercent?.toDouble()
val end = trip?.endSocPercent?.toDouble()
val hours = trip?.durationMs?.let { it / MILLIS_PER_HOUR }
if (distanceKm == null || distanceKm < SocConsumptionFitter.MIN_SEGMENT_KM ||
start == null || end == null || hours == null || hours <= 0.0
) {
return refuse(UnavailableReason.INSUFFICIENT_SAMPLES, advice)
}
// The gauge moves in whole percent. Below the fitter's own floor the quantisation is
// larger than the thing being measured.
val drop = start - end
if (drop < SocConsumptionFitter.MIN_SOC_DROP_PERCENT) {
return refuse(UnavailableReason.INSUFFICIENT_SAMPLES, advice)
}
val meanSpeedKmh = distanceKm / hours
val temp = outsideTempCelsius?.toDouble()
?: return refuse(UnavailableReason.SIGNAL_ABSENT, advice, meanSpeedKmh)
val expected = model.predict(meanSpeedKmh, temp)
val expectedValue = expected.value
?: return refuse(
expected.reason ?: UnavailableReason.MODEL_NOT_TRAINED, advice, meanSpeedKmh
)
val observed = drop / distanceKm * PER_100_KM
// Compared against the fit's *own* band rather than a threshold invented here: a fit with
// a wide residual says TYPICAL more often, which is the correct behaviour for a fit that
// does not know much yet. Half of it, though, not all: the prediction band is 1.96
// residuals wide, so TYPICAL covered 95 % of drives and the verdict almost never said
// anything else. One residual either side leaves about a third of drives outside it.
val band = (expected.uncertainty ?: 0.0) * VERDICT_BAND_SHARE
val verdict = when {
observed < expectedValue - band -> EcoBand.BETTER
observed > expectedValue + band -> EcoBand.WORSE
else -> EcoBand.TYPICAL
}
return EcoVerdict(
band = Provenanced.estimated(verdict, band),
observedPercentPer100Km = observed,
expectedPercentPer100Km = expectedValue,
deltaPercent = (observed - expectedValue) / expectedValue * PERCENT,
meanSpeedKmh = meanSpeedKmh,
advice = advice,
)
}
private fun refuse(
reason: UnavailableReason,
advice: EcoAdvice?,
meanSpeedKmh: Double? = null,
) = EcoVerdict(
band = Provenanced.unavailable(reason),
meanSpeedKmh = meanSpeedKmh,
advice = advice,
)
/**
* At most one lever, and by precedence rather than by a ranking that would have to invent a
* comparison. The two are near-disjoint in practice anyway: cruise speed cannot fire below
* [CRUISE_MIN_KMH], and a drive spent above it is not a drive spent accelerating.
*/
private fun advice(model: SocConsumptionModel?, outsideTempCelsius: Float?): EcoAdvice? =
cruiseAdvice(model, outsideTempCelsius) ?: steadinessAdvice()
private fun cruiseAdvice(
model: SocConsumptionModel?,
outsideTempCelsius: Float?,
): EcoAdvice? {
if (model == null) return null
val temp = outsideTempCelsius?.toDouble() ?: return null
val from = windowMeanSpeedKmh() ?: return null
if (from < CRUISE_MIN_KMH) return null
val to = from - CRUISE_STEP_KMH
val at = model.predict(from, temp).value ?: return null
val slower = model.predict(to, temp).value ?: return null
val saving = (at - slower) / at * PERCENT
if (!saving.isFinite() || saving < MIN_ADVICE_SAVING_PERCENT) return null
return EcoAdvice(
lever = EcoLever.CRUISE_SPEED,
savingPercent = saving,
fromSpeedKmh = from,
toSpeedKmh = to,
)
}
/**
* The share of moving time spent accelerating or braking hard, below the advice threshold
* included.
*
* Braking counts because every hard stop throws away what regeneration could not take back,
* and because the trip review (`DrivingStyle` in EVChargePilot) has always counted it: a live
* gauge that saw only half the habit read lower than the summary of the same drive.
*
* Null only while the window holds less than [MIN_LEVER_WINDOW_MS] of movement, so a caller
* can tell "not measured yet" from "measured, and calm" — the two read the same through
* [steadiness], which answers only when there is something to say.
*/
fun harshSharePercent(): Double? {
var movingMs = 0L
var harshMs = 0L
window.zipWithNext { previous, current ->
val deltaMs = current.atMs - previous.atMs
// Beyond the gap this app allows itself to integrate over, the missing minutes are
// unknown motion. Reading them as one enormous step would invent an acceleration
// out of the app having been asleep.
if (deltaMs !in 1..maxGapMs) return@zipWithNext
if (current.kmh <= 0.0 && previous.kmh <= 0.0) return@zipWithNext
movingMs += deltaMs
val acceleration = (current.kmh - previous.kmh) / KMH_PER_MS2 / (deltaMs / 1000.0)
if (abs(acceleration) >= harshAccelerationMs2) harshMs += deltaMs
}
if (movingMs < MIN_LEVER_WINDOW_MS) return null
return harshMs.toDouble() / movingMs * PERCENT
}
private fun steadinessAdvice(): EcoAdvice? {
val share = harshSharePercent() ?: return null
if (share < MIN_HARSH_SHARE_PERCENT) return null
return EcoAdvice(lever = EcoLever.STEADINESS, harshSharePercent = share)
}
private fun windowMeanSpeedKmh(): Double? {
if (window.size < 2) return null
var weightedMs = 0L
var distanceKmh = 0.0
window.zipWithNext { previous, current ->
val deltaMs = current.atMs - previous.atMs
if (deltaMs !in 1..maxGapMs) return@zipWithNext
weightedMs += deltaMs
distanceKmh += (previous.kmh + current.kmh) / 2.0 * deltaMs
}
if (weightedMs < MIN_LEVER_WINDOW_MS) return null
return distanceKmh / weightedMs
}
companion object {
/**
* The whole of a finished drive, rather than the last three minutes of a live one.
*
* A review that judged steadiness on the final window would grade the driver on their
* arrival, and the bound that keeps the live window cheap has no purpose over a track
* that is already on the disk.
*/
fun forReplay() = EcoDrivingMonitor(
windowMs = Long.MAX_VALUE,
maxSamples = Int.MAX_VALUE,
maxGapMs = REPLAY_MAX_GAP_MS,
)
/** The share of the prediction band that counts as TYPICAL: about one residual. */
const val VERDICT_BAND_SHARE = 0.5
/** Long enough that a single roundabout is not a driving style. */
const val WINDOW_MS = 180_000L
/**
* Brisk rather than emergency. Eco-driving practice puts the useful line around here, and
* the number is a calibration knob rather than physics: a heavier car, a different tyre or
* a driver who simply disagrees moves it, and nothing else in this class assumes its value.
*
* 1.5 m/s² was too low on the car: ordinary town drives counted 7 to 16 hard stops every
* 10 km (bundle of 2026-10-18), and regeneration on High alone comes close to that line
* with no brake pedal at all. 2.5 sits above lift-off, where the brakes do the work.
*/
const val HARSH_ACCELERATION_MS2 = 2.5
/** Below this the levers are measuring a traffic light, not a drive. */
const val MIN_LEVER_WINDOW_MS = 60_000L
/** Under this share, saying anything would be nagging about noise. */
const val MIN_HARSH_SHARE_PERCENT = 12.0
/** Motorway, where the square term is worth more than everything else on this list. */
const val CRUISE_MIN_KMH = 95.0
/** The step the advice proposes: large enough to matter, small enough to be plausible. */
const val CRUISE_STEP_KMH = 10.0
/** A saving smaller than this is not worth a line the driver has to read. */
const val MIN_ADVICE_SAVING_PERCENT = 3.0
/** The app's own integration rule: past five seconds, the motion in between is unknown. */
const val MAX_GAP_MS = 5_000L
/** Stored samples cross the five-second boundary on the next live telemetry frame. */
private const val REPLAY_MAX_GAP_MS = TripSampleTrack.DEFAULT_INTERVAL_MS * 2
/** Three minutes at 1 Hz, with room for a faster caller. */
const val MAX_WINDOW_SAMPLES = 1_024
private const val MILLIS_PER_HOUR = 3_600_000.0
private const val PER_100_KM = 100.0
private const val PERCENT = 100.0
/** 1 m/s² is 3.6 km/h per second. */
private const val KMH_PER_MS2 = 3.6
}
}
@@ -0,0 +1,81 @@
package com.evsuite.hardware.telemetry
import kotlin.math.roundToInt
/**
* A trip out of 100, and the three measured parts it is made of.
*
* Every part is a measurement this car is known to give: hard braking and hard acceleration
* from the speed channel, counted at 1 Hz by [EnergyTripAccumulator], and the charge against
* the driver's own fitted expectation from [EcoVerdict]. A part that could not be measured is
* null and the total is taken over the others; a trip where none could be measured has no score
* at all, never a default one.
*
* The scales are calibration knobs rather than physics. They are set so a calm town drive at
* the driver's usual consumption lands in the sixties and the eighties need effort: a score that
* starts near 100 has nothing left to reward.
*/
data class EcoScore(
val total: Int,
/** Hard braking per 10 km: braking hard throws away what regeneration could not take back. */
val anticipation: Int?,
/** Share of moving time beyond the hard-acceleration threshold, either sign. */
val smoothness: Int?,
/** Charge against the driver's own expectation at this speed and temperature. */
val efficiency: Int?,
) {
companion object {
fun of(summary: EnergyTripSummary, verdict: EcoVerdict?): EcoScore? {
val distanceKm = summary.recordedDistanceKm
val anticipation = summary.harshBrakings?.let { brakings ->
distanceKm?.takeIf { it >= MIN_DISTANCE_KM }?.let { km ->
scale(brakings / km * PER_10_KM, best = 0.0, worst = BRAKINGS_PER_10_KM_AT_ZERO)
}
}
val smoothness = summary.harshMs?.let { harsh ->
summary.movingMs?.takeIf { it >= MIN_MOVING_MS }?.let { moving ->
scale(harsh.toDouble() / moving * PERCENT, best = 0.0, worst = HARSH_SHARE_AT_ZERO)
}
}
val efficiency = verdict?.takeIf { it.band.value != null }?.deltaPercent?.let {
scale(it, best = DELTA_AT_FULL, worst = DELTA_AT_ZERO)
}
val parts = listOf(
anticipation to ANTICIPATION_WEIGHT,
smoothness to SMOOTHNESS_WEIGHT,
efficiency to EFFICIENCY_WEIGHT,
).filter { it.first != null }
if (parts.isEmpty()) return null
val total = parts.sumOf { it.first!! * it.second } / parts.sumOf { it.second }
return EcoScore(total.roundToInt(), anticipation, smoothness, efficiency)
}
/** 100 at [best], 0 at [worst], straight in between and clamped outside. */
private fun scale(value: Double, best: Double, worst: Double): Int? {
if (!value.isFinite()) return null
val share = ((worst - value) / (worst - best)).coerceIn(0.0, 1.0)
return (share * PERCENT).roundToInt()
}
/** Shorter than the fitter's own segment, a single junction would be a habit. */
const val MIN_DISTANCE_KM = 2.0
const val MIN_MOVING_MS = 60_000L
/** Six hard stops every 10 km is a drive with no anticipation left to measure. */
const val BRAKINGS_PER_10_KM_AT_ZERO = 6.0
/** A quarter of moving time spent hard is the bottom of the scale. */
const val HARSH_SHARE_AT_ZERO = 25.0
/** 15 % under the driver's own expectation is full marks; 25 % over is none. */
const val DELTA_AT_FULL = -15.0
const val DELTA_AT_ZERO = 25.0
const val ANTICIPATION_WEIGHT = 0.35
const val SMOOTHNESS_WEIGHT = 0.25
const val EFFICIENCY_WEIGHT = 0.40
private const val PER_10_KM = 10.0
private const val PERCENT = 100.0
}
}
@@ -1,5 +1,8 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.model.DriveMode
import com.evsuite.hardware.model.RegenLevel
import com.evsuite.hardware.FirmwareInfo
/** One immutable, nullable, read-only view of the vehicle's energy state. */
@@ -33,6 +36,18 @@ data class EnergySnapshot(
val tirePressures: TirePressureSnapshot,
val batteryVoltageV: Float? = null,
val batteryCurrentA: Float? = null,
/**
* CP-086. The widest-open window, 0 closed to 100 open; null where the car does not answer,
* never 0 — a closed window and an unread one are different facts to the attribution.
*/
val widestWindowPercent: Int? = null,
/**
* The vendor's own drive mode and regen codes, unmapped: [DriveMode.ofRaw] and
* [RegenLevel.ofRaw] decode them, and a code neither knows stays a code rather than
* becoming NORMAL or MEDIUM. Null where the car did not answer.
*/
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) {
val hasVehicleData: Boolean
get() = socPercent != null || rangeKm != null || speedKmh != null ||
@@ -40,7 +55,8 @@ data class EnergySnapshot(
batteryTempCelsius != null || batteryEnergyKwh != null || batteryCapacityKwh != null ||
odometerKm != null || chargePortConnected != null || chargingStatus != null ||
vehicleConsumedKwh != null || vehicleRegeneratedKwh != null ||
parked != null || climate.hasData || tirePressures.hasData
parked != null || climate.hasData || tirePressures.hasData ||
widestWindowPercent != null || driveModeRaw != null || regenLevelRaw != null
}
data class TirePressureSnapshot(
@@ -6,6 +6,7 @@ import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.saic.SaicCharging
import com.evsuite.hardware.saic.SaicClimate
import com.evsuite.hardware.saic.SaicHub
import com.evsuite.hardware.saic.SaicVehicleControl
/** Injectable signal seam: production uses [EvHardwareEnergySignalSource], tests use a fake. */
interface EnergySignalSource {
@@ -28,6 +29,11 @@ interface EnergySignalSource {
fun parked(): Boolean?
fun batteryVoltageV(): Float? = null
fun batteryCurrentA(): Float? = null
/** Widest window opening in percent; null where the firmware has no read. */
fun widestWindowPercent(): Int? = null
/** Raw vendor codes; null where the firmware has no read. */
fun driveModeRaw(): Int? = null
fun regenLevelRaw(): Int? = null
fun climate(): ClimateSnapshot
fun tirePressures(): TirePressureSnapshot
/** Probe-only values excluded from [EnergySnapshot] and normal consumers. */
@@ -70,6 +76,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
override fun parked() = EVHardware.isVehicleInPark()
override fun batteryVoltageV() = EVHardware.getVendorBatteryVoltageV()
override fun batteryCurrentA() = EVHardware.getVendorBatteryCurrentA()
override fun widestWindowPercent() = SaicVehicleControl.widestWindowPercent()
override fun driveModeRaw() = EVHardware.getDriveModeRaw()
override fun regenLevelRaw() = EVHardware.getRegenLevelRaw()
override fun tirePressures() = TirePressureSnapshot(
frontLeftKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_LEFT),
frontRightKpa = EVHardware.getTirePressureKpa(EVHardware.Wheel.FRONT_RIGHT),
@@ -80,7 +89,23 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_CURRENT_BATTERY_CAPACITY_WH,
EVHardware.probeCurrentBatteryCapacityWh()?.toDouble(),
)
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_CHARGE_STATUS,
EVHardware.probeVendorChargeStatus()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_PLUG_AC,
EVHardware.probeVendorPlugAc()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_PLUG_DC,
EVHardware.probeVendorPlugDc()?.toDouble(),
),
TelemetryEvidenceProbe(
TelemetryEvidenceRecorder.CANDIDATE_VENDOR_ENERGY_SINCE_CHARGE_KWH,
EVHardware.probeVendorEnergySinceChargeKwh()?.toDouble(),
),
)
override fun climate() = ClimateSnapshot(
powerOn = SaicClimate.powerOn(),
@@ -94,11 +119,9 @@ class EvHardwareEnergySignalSource(context: Context) : EnergySignalSource {
?: EVHardware.getTemperatureSetCelsius(),
// The passenger setpoint is its own signal only when the car publishes one: the vendor
// service reads it with a default of 68, which its own converter maps to -1, and -1 is
// dropped upstream. With a single zone the driver's setpoint is what both seats get,
// so that is what the passenger reads — the same number, because it is the same air.
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat()
?: SaicClimate.driverTemp()?.toFloat()
?: EVHardware.getTemperatureSetCelsius(),
// dropped upstream. Null then, not the driver's number: a screen that shows both seats
// the same air says so itself, and evidence captured here must not call a copy a reading.
passengerTargetCelsius = SaicClimate.passengerTemp()?.toFloat(),
)
}
@@ -128,6 +151,9 @@ class EnergyTelemetryReader(private val source: EnergySignalSource) {
tirePressures = source.tirePressures(),
batteryVoltageV = source.batteryVoltageV()?.takeIf { it.isFinite() },
batteryCurrentA = source.batteryCurrentA()?.takeIf { it.isFinite() },
widestWindowPercent = source.widestWindowPercent()?.takeIf { it in 0..100 },
driveModeRaw = source.driveModeRaw(),
regenLevelRaw = source.regenLevelRaw(),
)
/** Reads CP-004 candidates only when an unstable evidence capture explicitly requests them. */
@@ -32,6 +32,20 @@ data class EnergyTripSummary(
* train on it. See [VehicleSpeedEvidence].
*/
val speedEvidence: VehicleSpeedEvidence? = null,
/**
* How the trip was driven, counted at the live sampling rate over the whole trip.
*
* The stored track is one sample per five seconds, and a two-second hard stop averaged over
* five reads as a gentle one: replayed, the same drive came out calmer than it was. Null on
* trips recorded before these were counted, which a reader treats as "not counted", never 0.
*/
val movingMs: Long? = null,
val harshMs: Long? = null,
val harshAccelerations: Int? = null,
val harshBrakings: Int? = null,
/** The vendor drive mode and regen codes held for the longest share of the trip. */
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) {
/**
* The distance, only when the conversion behind it is the one believed correct today.
@@ -75,6 +89,13 @@ class EnergyTripAccumulator(
private var hasPowerInterval = false
private var recordedMs = 0L
private var latestSocPercent = startSocPercent
private var movingMs = 0L
private var harshMs = 0L
private var harshAccelerations = 0
private var harshBrakings = 0
private var harshSign = 0
private val modeMs = HashMap<Int, Long>()
private val regenMs = HashMap<Int, Long>()
fun add(sample: EnergySnapshot) {
track.add(sample)
@@ -90,6 +111,7 @@ class EnergyTripAccumulator(
hasSpeedInterval = true
distanceKm += ((previous.speedKmh + sample.speedKmh) / 2.0) * hours
}
countStyle(previous, sample, gapMs)
if (previous.batteryPowerKw != null && sample.batteryPowerKw != null) {
hasPowerInterval = true
val intervalKwh =
@@ -99,10 +121,36 @@ class EnergyTripAccumulator(
}
}
/**
* One interval of driving style. A hard stretch counts once however many seconds it lasts:
* a driver asked to brake less hard three times wants three to be the number, not eleven.
*/
private fun countStyle(previous: EnergySnapshot, sample: EnergySnapshot, gapMs: Long) {
sample.driveModeRaw?.let { modeMs.merge(it, gapMs, Long::plus) }
sample.regenLevelRaw?.let { regenMs.merge(it, gapMs, Long::plus) }
val from = previous.speedKmh ?: return
val to = sample.speedKmh ?: return
if (from <= 0f && to <= 0f) {
harshSign = 0
return
}
movingMs += gapMs
val acceleration = (to - from) / KMH_PER_MS2 / (gapMs / 1000.0)
val sign = when {
acceleration >= EcoDrivingMonitor.HARSH_ACCELERATION_MS2 -> 1
acceleration <= -EcoDrivingMonitor.HARSH_ACCELERATION_MS2 -> -1
else -> 0
}
if (sign != 0) harshMs += gapMs
if (sign == 1 && harshSign != 1) harshAccelerations++
if (sign == -1 && harshSign != -1) harshBrakings++
harshSign = sign
}
fun samples(): List<TripSample> = track.samples()
fun snapshot(nowMs: Long) = EnergyTripSummary(
startedAtMs = startedAtMs,
startedAtMs = startedAtMs(nowMs),
endedAtMs = nowMs,
durationMs = recordedMs,
distanceKm = distanceKm,
@@ -113,9 +161,30 @@ class EnergyTripAccumulator(
distanceAvailable = hasSpeedInterval,
speedEvidence = speedEvidence.takeIf { hasSpeedInterval },
batteryPowerEvidence = batteryPowerEvidence.takeIf { hasPowerInterval },
movingMs = movingMs.takeIf { hasSpeedInterval },
harshMs = harshMs.takeIf { hasSpeedInterval },
harshAccelerations = harshAccelerations.takeIf { hasSpeedInterval },
harshBrakings = harshBrakings.takeIf { hasSpeedInterval },
driveModeRaw = modeMs.maxByOrNull { it.value }?.key,
regenLevelRaw = regenMs.maxByOrNull { it.value }?.key,
)
private companion object { const val MAX_SAMPLE_GAP_MS = 5_000L }
/**
* A trip that started before the head unit's clock was set (it boots on its factory date and
* syncs later) would sort years back. Once the clock is right, its start is the end minus the
* time driven; the jump itself is a gap [add] already refuses to count.
*/
private fun startedAtMs(nowMs: Long): Long =
if (startedAtMs < BatteryLedgerRecorder.MIN_PLAUSIBLE_CLOCK_MS &&
nowMs >= BatteryLedgerRecorder.MIN_PLAUSIBLE_CLOCK_MS
) nowMs - recordedMs else startedAtMs
private companion object {
const val MAX_SAMPLE_GAP_MS = 5_000L
/** 1 m/s² is 3.6 km/h per second. */
const val KMH_PER_MS2 = 3.6
}
}
/** Process-local recording state shared by any energy dashboard in the same app process. */
@@ -43,7 +43,7 @@ data class TripHistoryFile(
class EnergyTripHistoryStore(
private val target: File,
private val maxTrips: Int = 200,
private val maxBytes: Int = 512 * 1024,
private val maxBytes: Int = 2 * 1024 * 1024,
private val gson: Gson = Gson(),
) {
/** Trips with whatever tracks survived eviction, newest first. */
@@ -104,6 +104,7 @@ class TelemetryEvidenceRecorder {
record(CHARGING_STATUS, snapshot.chargingStatus?.toDouble(), at)
record(CHARGE_PORT_CONNECTED, snapshot.chargePortConnected, at)
record(PARKED, snapshot.parked, at)
record(WINDOWS_WIDEST_PERCENT, snapshot.widestWindowPercent?.toDouble(), at)
val climate = snapshot.climate
record(CLIMATE_POWER_ON, climate.powerOn, at)
@@ -250,6 +251,7 @@ class TelemetryEvidenceRecorder {
const val CHARGING_STATUS = "chargingStatus"
const val CHARGE_PORT_CONNECTED = "chargePortConnected"
const val PARKED = "parked"
const val WINDOWS_WIDEST_PERCENT = "windows.widestPercent"
const val CLIMATE_POWER_ON = "climate.powerOn"
const val CLIMATE_AC_ON = "climate.acOn"
const val CLIMATE_AUTO_ON = "climate.autoOn"
@@ -261,6 +263,10 @@ class TelemetryEvidenceRecorder {
const val CLIMATE_PASSENGER_TARGET_C = "climate.passengerTargetCelsius"
const val CANDIDATE_CURRENT_BATTERY_CAPACITY_WH =
"candidate.evCurrentBatteryCapacityWh"
const val CANDIDATE_VENDOR_CHARGE_STATUS = "candidate.vendorChargeStatus"
const val CANDIDATE_VENDOR_PLUG_AC = "candidate.vendorPlugAc"
const val CANDIDATE_VENDOR_PLUG_DC = "candidate.vendorPlugDc"
const val CANDIDATE_VENDOR_ENERGY_SINCE_CHARGE_KWH = "candidate.vendorEnergySinceChargeKwh"
}
}
@@ -73,7 +73,8 @@ class TripDetector {
transition(State.RECORDING, timestampMs)
Result(state)
}
elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) -> {
elapsed(timestampMs) >= STOP_DEBOUNCE_MS && endConfirmed(snapshot) ||
elapsed(timestampMs) >= PARKED_STOP_DEBOUNCE_MS && endObserved(snapshot) -> {
transition(State.IDLE, timestampMs)
Result(state, Event.STOP)
}
@@ -117,9 +118,13 @@ class TripDetector {
private fun endConfirmed(snapshot: EnergySnapshot): Boolean {
val confirmationAvailable = snapshot.parked != null || snapshot.chargePortConnected != null
return !confirmationAvailable || snapshot.parked == true || snapshot.chargePortConnected == true
return !confirmationAvailable || endObserved(snapshot)
}
/** The car said so itself: a gear in P or a cable in the port is not a traffic stop. */
private fun endObserved(snapshot: EnergySnapshot): Boolean =
snapshot.parked == true || snapshot.chargePortConnected == true
companion object {
/** Ignores parking creep and noisy zero readings; 5 km/h is unambiguous road motion. */
const val START_SPEED_KMH = 5.0
@@ -133,6 +138,13 @@ class TripDetector {
/** Longer than the ticket's 90-second traffic stop while still practical at arrival. */
const val STOP_DEBOUNCE_MS = 120_000L
/**
* The SWI68 head unit powers off about twelve seconds after P (2026-10-16: P at
* 12:13:54, off at 12:14:06), so the two-minute timer never ran out and every
* automatic trip died with the process. A confirmed P needs no traffic allowance.
*/
const val PARKED_STOP_DEBOUNCE_MS = 2_000L
/** Five seconds tolerates scheduler jitter but cannot bridge a meaningful telemetry hole. */
const val MAX_OBSERVATION_GAP_MS = 5_000L
}
@@ -24,6 +24,14 @@ data class TripSample(
val climatePowerOn: Boolean?,
val climateAcOn: Boolean?,
val climateFanLevel: Int?,
/** CP-086. Absent from trips stored before it, which Gson reads back as null: unknown. */
val widestWindowPercent: Int? = null,
/**
* Raw vendor drive mode and regen codes, as [EnergySnapshot] carries them. Ints rather than
* the enums: Gson writes an enum by its constant name, which a release build may rename.
*/
val driveModeRaw: Int? = null,
val regenLevelRaw: Int? = null,
) {
companion object {
fun of(snapshot: EnergySnapshot) = TripSample(
@@ -37,6 +45,9 @@ data class TripSample(
climatePowerOn = snapshot.climate.powerOn,
climateAcOn = snapshot.climate.acOn,
climateFanLevel = snapshot.climate.fanLevel,
widestWindowPercent = snapshot.widestWindowPercent,
driveModeRaw = snapshot.driveModeRaw,
regenLevelRaw = snapshot.regenLevelRaw,
)
}
}
@@ -7,7 +7,14 @@ import com.evsuite.hardware.telemetry.UnavailableReason
import kotlin.math.abs
import kotlin.math.max
enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN }
/**
* Which auxiliary load an interval's residual may belong to.
*
* CP-086: [WINDOWS_OPEN] takes an interval with a window open at either end out of the climate
* groups, so the drag of open glass never reads as cabin energy. Its residual may still carry
* the climate system if that ran at the same time; its average fan level says whether it did.
*/
enum class ResidualContext { CLIMATE_ACTIVE, CLIMATE_INACTIVE, CLIMATE_UNKNOWN, WINDOWS_OPEN }
enum class ResidualFinding {
DISTINGUISHABLE,
@@ -115,7 +122,11 @@ object EnergyAttributionCalculator {
val modelled = predictedConsumption * distanceKm / 100.0
val uncertainty = predictedUncertainty * distanceKm / 100.0
val measured = (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * hours
val context = climateContext(previous, current)
val context = if (windowOpen(previous) || windowOpen(current)) {
ResidualContext.WINDOWS_OPEN
} else {
climateContext(previous, current)
}
tractionKwh += modelled
tractionUncertaintyKwh += uncertainty
@@ -159,6 +170,9 @@ object EnergyAttributionCalculator {
}
}
private fun windowOpen(sample: TripSample): Boolean =
sample.widestWindowPercent?.let { it > 0 } == true
private fun climateActive(sample: TripSample): Boolean? = when {
sample.climatePowerOn == true || sample.climateAcOn == true ||
sample.climateFanLevel?.let { it > 0 } == true -> true
@@ -6,6 +6,7 @@ import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.UnavailableReason
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sqrt
sealed interface EnergyModelTrainingResult {
@@ -40,29 +41,33 @@ class EnergyModelTrainer(
trips: List<StoredTrip>,
evidence: BatteryPowerEvidence,
): EnergyModelTrainingResult {
// Both fits are accumulated in the one pass; the temperature span picks which to solve.
val equations = LeastSquares3()
val narrowed = LeastSquares3(2)
var minSpeed = Double.POSITIVE_INFINITY
var maxSpeed = Double.NEGATIVE_INFINITY
var minTemp = Double.POSITIVE_INFINITY
var maxTemp = Double.NEGATIVE_INFINITY
val count = forEachUsable(trips, evidence) { speed, temp, consumption ->
equations.add(speed, temp, consumption)
narrowed.add(speed, temp, consumption)
minSpeed = minOf(minSpeed, speed)
maxSpeed = maxOf(maxSpeed, speed)
minTemp = minOf(minTemp, temp)
maxTemp = maxOf(maxTemp, temp)
}
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH ||
maxTemp - minTemp < MIN_TEMPERATURE_SPAN_CELSIUS
) {
if (count < MIN_TRAINING_SAMPLES || maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
return EnergyModelTrainingResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
val coefficients = equations.solve()
// As in SocConsumptionFitter: one kind of weather is no evidence about weather, so two
// coefficients are fitted and the narrow envelope refuses the cold instead of guessing.
val features = if (maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
val coefficients = (if (features == 3) equations else narrowed).solve()
?: return EnergyModelTrainingResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
val rolling = coefficients[0]
val aero = coefficients[1]
val thermal = coefficients[2]
val thermal = if (features == 3) coefficients[2] else 0.0
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 ||
coefficients.any { !it.isFinite() }
) {
@@ -93,7 +98,19 @@ class EnergyModelTrainer(
return EnergyModelTrainingResult.Ready(model)
}
private inline fun forEachUsable(
/**
* Calls [block] once per kilometre of driving, with its mean speed, mean temperature and
* gross consumption, and returns how many kilometres it reported.
*
* A 5-second sample is not a consumption. The one taken while pulling away from a light
* costs several times the one taken cruising, and a fit made per sample read that spread
* as model error and refused town driving for good. Over a kilometre the accelerations
* average into what the road actually cost, which is also what the attribution predicts.
* Standing still is skipped without ending the window: it has no speed to be modelled at.
* A hole in the track does end it, as in SocConsumptionFitter, so no window spans
* kilometres nobody recorded.
*/
private fun forEachUsable(
trips: List<StoredTrip>,
evidence: BatteryPowerEvidence,
block: (speedKmh: Double, outsideTempCelsius: Double, consumption: Double) -> Unit,
@@ -101,22 +118,55 @@ class EnergyModelTrainer(
var accepted = 0
for (trip in trips) {
if (trip.summary.batteryPowerEvidence != evidence) continue
for (sample in trip.samples.orEmpty()) {
val speed = sample.speedKmh?.toDouble()
val power = sample.batteryPowerKw?.toDouble()
val temp = sample.outsideTempCelsius?.toDouble()
if (speed == null || power == null || temp == null ||
!speed.isFinite() || speed !in MIN_SPEED_KMH..MAX_SPEED_KMH ||
!power.isFinite() || power <= 0.0 ||
!temp.isFinite() || temp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS
val samples = trip.samples.orEmpty()
var km = 0.0
var hours = 0.0
var kwh = 0.0
var tempSum = 0.0
var tempCount = 0
for (index in 1 until samples.size) {
val previous = samples[index - 1]
val current = samples[index]
val durationMs = current.atMs - previous.atMs
val speed = mean(previous.speedKmh, current.speedKmh)
val previousPower = previous.batteryPowerKw?.toDouble()
val currentPower = current.batteryPowerKw?.toDouble()
val temp = mean(previous.outsideTempCelsius, current.outsideTempCelsius)
if (speed == null || temp == null || previousPower == null ||
currentPower == null || !previousPower.isFinite() ||
!currentPower.isFinite() || durationMs !in 1..MAX_SAMPLE_GAP_MS
) {
km = 0.0
hours = 0.0
kwh = 0.0
tempSum = 0.0
tempCount = 0
continue
}
if (speed < MIN_SPEED_KMH) continue
val intervalHours = durationMs / MILLIS_PER_HOUR
km += speed * intervalHours
hours += intervalHours
// Gross, as the attribution measures it: regeneration is a separate ledger line.
kwh += (max(0.0, previousPower) + max(0.0, currentPower)) / 2.0 * intervalHours
tempSum += temp
tempCount++
if (km < WINDOW_KM) continue
val meanSpeed = km / hours
val meanTemp = tempSum / tempCount
val consumption = kwh * 100.0 / km
km = 0.0
hours = 0.0
kwh = 0.0
tempSum = 0.0
tempCount = 0
if (meanSpeed !in MIN_SPEED_KMH..MAX_SPEED_KMH ||
meanTemp !in MIN_TEMP_CELSIUS..MAX_TEMP_CELSIUS ||
consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM
) {
continue
}
val consumption = power * 100.0 / speed
if (!consumption.isFinite() || consumption !in 0.0..MAX_CONSUMPTION_KWH_PER_100_KM) {
continue
}
block(speed, temp, consumption)
block(meanSpeed, meanTemp, consumption)
accepted++
if (accepted == maxSamples) return accepted
}
@@ -124,9 +174,19 @@ class EnergyModelTrainer(
return accepted
}
private fun mean(a: Float?, b: Float?): Double? {
val first = a?.toDouble()?.takeIf { it.isFinite() } ?: return null
val second = b?.toDouble()?.takeIf { it.isFinite() } ?: return null
return (first + second) / 2.0
}
companion object {
const val MIN_TRAINING_SAMPLES = 60
const val MAX_TRAINING_SAMPLES = 50_000
/** Kilometre windows, not samples: a dozen, as SocConsumptionFitter asks segments. */
const val MIN_TRAINING_SAMPLES = 12
const val MAX_TRAINING_SAMPLES = 20_000
const val WINDOW_KM = 1.0
private const val MAX_SAMPLE_GAP_MS = 120_000L
private const val MILLIS_PER_HOUR = 3_600_000.0
const val MAX_RESIDUAL_RMSE_KWH_PER_100_KM = 12.0
private const val MIN_SPEED_KMH = 10.0
private const val MAX_SPEED_KMH = 180.0
@@ -6,6 +6,7 @@ import com.evsuite.hardware.telemetry.Provenanced
import com.evsuite.hardware.telemetry.StoredTrip
import com.evsuite.hardware.telemetry.TripSample
import com.evsuite.hardware.telemetry.UnavailableReason
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
@@ -148,8 +149,8 @@ class SocConsumptionFitter(
* altogether — a trip skipped for a speed conversion that is no longer believed leaves no
* trace at all, and a history of them looks exactly like a history of short drives.
*
* Diagnostic only: it runs the same collection pass as [fit] a second time, so call it
* from a probe and not from a screen.
* Diagnostic only: it runs the same collection pass and solve as [fit] a second time, so
* call it from a probe and not from a screen.
*/
fun describe(
trips: List<StoredTrip>,
@@ -161,9 +162,20 @@ class SocConsumptionFitter(
"segments=${segments.size}/$MIN_SEGMENTS"
if (segments.isEmpty()) return head
val span = segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh }
return "$head span=${span.roundToInt()}/${MIN_SPEED_SPAN_KMH.roundToInt()} km/h"
val line = "$head span=${span.roundToInt()}/${MIN_SPEED_SPAN_KMH.roundToInt()} km/h"
if (segments.size < MIN_SEGMENTS || span < MIN_SPEED_SPAN_KMH) return line
// Past both gates the line used to stop, and a bundle could not say whether a model
// came out of the solve. Appended, so a parser of the prefix above still matches.
val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
val solved = solve(segments, evidence)
val model = solved.model ?: return "$line fit=refused(${solved.refusal})"
val rmse = model.residualRmsePercentPer100Km
return line + String.format(Locale.ROOT, " fit=ready rmse=%.2f", rmse)
}
/** What [solve] produced: a model, or the name of the guard that refused one. */
private class Solved(val model: SocConsumptionModel?, val refusal: String? = null)
fun fit(
trips: List<StoredTrip>,
generation: FirmwareInfo.Gen = FirmwareInfo.getGeneration(),
@@ -180,18 +192,30 @@ class SocConsumptionFitter(
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
if (segments.maxOf { it.meanSpeedKmh } - segments.minOf { it.meanSpeedKmh } <
MIN_SPEED_SPAN_KMH
) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
return solve(segments, evidence).model?.let { SocConsumptionFitResult.Ready(it) }
?: SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
}
/** The least-squares solve behind [fit], shared with [describe] so both report one answer. */
private fun solve(
segments: List<Segment>,
evidence: VehicleSpeedEvidence,
withWeather: Boolean = true,
): Solved {
val minSpeed = segments.minOf { it.meanSpeedKmh }
val maxSpeed = segments.maxOf { it.meanSpeedKmh }
val minTemp = segments.minOf { it.meanOutsideTempCelsius }
val maxTemp = segments.maxOf { it.meanOutsideTempCelsius }
if (maxSpeed - minSpeed < MIN_SPEED_SPAN_KMH) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.INSUFFICIENT_SAMPLES)
}
// A driver who has only recorded one kind of weather has no evidence about weather.
// Fitting two coefficients is the honest answer there; the envelope keeps the narrow
// temperature range, so this model refuses the cold instead of guessing at it.
val features = if (maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
val features = if (withWeather && maxTemp - minTemp >= MIN_TEMPERATURE_SPAN_CELSIUS) 3 else 2
val equations = LeastSquares3(features)
for (segment in segments) {
equations.add(
@@ -200,13 +224,19 @@ class SocConsumptionFitter(
segment.percentPer100Km,
)
}
val coefficients = equations.solve()
?: return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
val coefficients = equations.solve() ?: return Solved(null, "singular")
val rolling = coefficients[0]
val aero = coefficients[1]
val thermal = if (features == 3) coefficients[2] else 0.0
if (rolling <= 0.0 || aero < 0.0 || thermal < 0.0 || coefficients.any { !it.isFinite() }) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
when {
coefficients.any { !it.isFinite() } -> return Solved(null, "nonfinite")
rolling <= 0.0 -> return Solved(null, "rolling")
aero < 0.0 -> return Solved(null, "aero")
// The weather recorded so far costs nothing measurable: the bundle of 2026-10-16
// refused 87 segments over 77 km/h of span on this alone, and the model never
// came out on the car. That is the single-weather case again — two coefficients,
// and the envelope still refuses a temperature the history never saw.
thermal < 0.0 -> return solve(segments, evidence, withWeather = false)
}
var squaredError = 0.0
@@ -221,7 +251,7 @@ class SocConsumptionFitter(
}
val rmse = sqrt(squaredError / segments.size)
if (!rmse.isFinite() || rmse > maxResidualRmse) {
return SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
return Solved(null, String.format(Locale.ROOT, "rmse=%.2f", rmse))
}
val model = SocConsumptionModel(
@@ -233,11 +263,7 @@ class SocConsumptionFitter(
segmentCount = segments.size,
envelope = EnergyModelEnvelope(minSpeed, maxSpeed, minTemp, maxTemp),
)
return if (model.isValid()) {
SocConsumptionFitResult.Ready(model)
} else {
SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED)
}
return if (model.isValid()) Solved(model) else Solved(null, "invalid")
}
/**
@@ -247,6 +273,11 @@ class SocConsumptionFitter(
* refuses to build a segment from is most of what makes the fit trustworthy.
*/
internal fun collect(samples: List<TripSample>, out: MutableList<Segment>) {
// Two steps of this track's own gauge, never more than [minSocDropPercent]. SWI68
// publishes tenths of a percent, and waiting for two whole points there threw away
// every town drive shorter than ten kilometres — most of what the car is used for.
val minDrop = gaugeStep(samples)?.let { minOf(minSocDropPercent, 2.0 * it) }
?: minSocDropPercent
var startSoc: Double? = null
var distanceKm = 0.0
var hours = 0.0
@@ -302,13 +333,29 @@ class SocConsumptionFitter(
continue
}
val drop = start - soc
if (drop < minSocDropPercent) continue
// A short stretch keeps accumulating rather than being thrown away: on a fine gauge
// the drop arrives before the distance does.
if (drop < minDrop || distanceKm < MIN_SEGMENT_KM) continue
emit(distanceKm, hours, tempSum, tempCount, drop)?.let(out::add)
reset(soc)
if (out.size >= maxSegments) return
}
}
/** The smallest move this track's gauge made, or null when it never moved. */
private fun gaugeStep(samples: List<TripSample>): Double? {
var step: Double? = null
var previous: Double? = null
for (sample in samples) {
val soc = sample.socPercent?.toDouble()?.takeIf { it.isFinite() } ?: continue
val move = previous?.let { abs(soc - it) }
// Float storage turns 0.1 into 0.0999…; anything below a hundredth is that noise.
if (move != null && move >= 0.01) step = minOf(step ?: move, move)
previous = soc
}
return step
}
private fun emit(
distanceKm: Double,
hours: Double,
@@ -7,6 +7,17 @@
<string name="group_comfort">Komfort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Fahrerassistenz</string>
<string name="group_lights">Beleuchtung</string>
<string name="cond_headlights">Abblendlicht</string>
<string name="cond_high_beam">Fernlicht an</string>
<string name="cond_fog_lights">Nebelscheinwerfer an</string>
<string name="act_headlights">Abblendlicht</string>
<string name="act_high_beam">Fernlicht</string>
<string name="act_fog_lights">Nebelscheinwerfer</string>
<string name="light_off">Aus</string>
<string name="light_on">An</string>
<string name="light_daytime_running">Tagfahrlicht</string>
<string name="light_auto">Automatisch</string>
<string name="group_climate">Klima</string>
<string name="group_profile">Profil</string>
<string name="group_system">System</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Confort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Asistencia a la conducción</string>
<string name="group_lights">Iluminación</string>
<string name="cond_headlights">Luces de cruce</string>
<string name="cond_high_beam">Luces largas encendidas</string>
<string name="cond_fog_lights">Antiniebla encendidas</string>
<string name="act_headlights">Luces de cruce</string>
<string name="act_high_beam">Luces largas</string>
<string name="act_fog_lights">Antiniebla</string>
<string name="light_off">Apagadas</string>
<string name="light_on">Encendidas</string>
<string name="light_daytime_running">Luces diurnas</string>
<string name="light_auto">Automático</string>
<string name="group_climate">Climatización</string>
<string name="group_profile">Perfil</string>
<string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Confort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Aides à la conduite</string>
<string name="group_lights">Éclairage</string>
<string name="cond_headlights">Feux de croisement</string>
<string name="cond_high_beam">Feux de route allumés</string>
<string name="cond_fog_lights">Antibrouillards allumés</string>
<string name="act_headlights">Feux de croisement</string>
<string name="act_high_beam">Feux de route</string>
<string name="act_fog_lights">Antibrouillards</string>
<string name="light_off">Éteints</string>
<string name="light_on">Allumés</string>
<string name="light_daytime_running">Feux de jour</string>
<string name="light_auto">Automatique</string>
<string name="group_climate">Climatisation</string>
<string name="group_profile">Profil</string>
<string name="group_system">Système</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Comfort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Assistenza alla guida</string>
<string name="group_lights">Luci</string>
<string name="cond_headlights">Anabbaglianti</string>
<string name="cond_high_beam">Abbaglianti accesi</string>
<string name="cond_fog_lights">Fendinebbia accesi</string>
<string name="act_headlights">Anabbaglianti</string>
<string name="act_high_beam">Abbaglianti</string>
<string name="act_fog_lights">Fendinebbia</string>
<string name="light_off">Spente</string>
<string name="light_on">Accese</string>
<string name="light_daytime_running">Luci diurne</string>
<string name="light_auto">Automatico</string>
<string name="group_climate">Clima</string>
<string name="group_profile">Profilo</string>
<string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Conforto</string>
<string name="group_audio">Áudio</string>
<string name="group_adas">Assistência à condução</string>
<string name="group_lights">Iluminação</string>
<string name="cond_headlights">Médios</string>
<string name="cond_high_beam">Máximos ligados</string>
<string name="cond_fog_lights">Faróis de nevoeiro ligados</string>
<string name="act_headlights">Médios</string>
<string name="act_high_beam">Máximos</string>
<string name="act_fog_lights">Faróis de nevoeiro</string>
<string name="light_off">Desligados</string>
<string name="light_on">Ligados</string>
<string name="light_daytime_running">Luzes diurnas</string>
<string name="light_auto">Automático</string>
<string name="group_climate">Climatização</string>
<string name="group_profile">Perfil</string>
<string name="group_system">Sistema</string>
@@ -7,6 +7,17 @@
<string name="group_comfort">Comfort</string>
<string name="group_audio">Audio</string>
<string name="group_adas">Driver assistance</string>
<string name="group_lights">Lights</string>
<string name="cond_headlights">Low beams</string>
<string name="cond_high_beam">High beam on</string>
<string name="cond_fog_lights">Fog lights on</string>
<string name="act_headlights">Low beams</string>
<string name="act_high_beam">High beam</string>
<string name="act_fog_lights">Fog lights</string>
<string name="light_off">Off</string>
<string name="light_on">On</string>
<string name="light_daytime_running">Daytime running</string>
<string name="light_auto">Automatic</string>
<string name="group_climate">Climate</string>
<string name="group_profile">Profile</string>
<string name="group_system">System</string>
@@ -127,13 +127,28 @@ class ReadFailureLogTest {
class NeverPublishedCatalogueTest {
@Test fun `SWI68 never publishes battery power or battery temperature`() {
CarPropertyEvidence.Signal.entries.forEach { signal ->
assertTrue(
"$signal is proven absent on SWI68",
CarPropertyEvidence.isNeverPublished(signal, FirmwareInfo.Gen.SWI68),
@Test fun `SWI68 never publishes battery temperature`() {
assertTrue(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
}
)
}
@Test fun `SWI68 battery power publishes through the vendor pack and is not validated yet`() {
assertFalse(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
assertFalse(
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
FirmwareInfo.Gen.SWI68,
)
)
}
@Test fun `absent is not the same claim as validated`() {
@@ -141,13 +156,13 @@ class NeverPublishedCatalogueTest {
// never be validated. Both must be false-for-validated and true-for-absent at once.
assertFalse(
CarPropertyEvidence.isValidated(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
)
assertTrue(
CarPropertyEvidence.isNeverPublished(
CarPropertyEvidence.Signal.BATTERY_POWER_KW,
CarPropertyEvidence.Signal.BATTERY_TEMPERATURE_CELSIUS,
FirmwareInfo.Gen.SWI68,
)
)
@@ -0,0 +1,61 @@
package com.evsuite.hardware
import com.evsuite.hardware.CustomDrive.Family
import com.evsuite.hardware.CustomDrive.Setting
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CustomDriveTest {
private fun codes(setting: Setting, family: Family) =
CustomDrive.INDICES.map { CustomDrive.code(setting, it, family) }
@Test fun `VSM scale matches the RI-006 read and write codes`() {
assertEquals(listOf(1, 2, 3), codes(Setting.POWER, Family.VSM))
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.VSM))
assertEquals(listOf(1, 0, 2), codes(Setting.PEDAL, Family.VSM))
}
@Test fun `pedal Normal is zero on every family`() {
Family.values().forEach { assertEquals(0, CustomDrive.code(Setting.PEDAL, 1, it)) }
}
@Test fun `A9 powertrain reuses the drive-mode scale`() {
assertEquals(listOf(2, 3, 4), codes(Setting.POWER, Family.A9))
assertEquals(listOf(1, 2, 3), codes(Setting.STEERING, Family.A9))
}
@Test fun `every code reads back to its index`() {
Family.values().forEach { family ->
Setting.values().forEach { setting ->
CustomDrive.INDICES.forEach { i ->
assertEquals(i, CustomDrive.index(setting, CustomDrive.code(setting, i, family)!!, family))
}
}
}
}
@Test fun `out of range is refused both ways`() {
assertNull(CustomDrive.code(Setting.POWER, 3, Family.VSM))
assertNull(CustomDrive.code(Setting.PEDAL, -1, Family.A9))
assertNull(CustomDrive.index(Setting.POWER, 0, Family.VSM))
assertNull(CustomDrive.index(Setting.POWER, 1, Family.A9))
assertNull(CustomDrive.index(Setting.STEERING, -1, Family.VSM))
}
@Test fun `only VSM generations have a route`() {
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI68))
assertEquals(Family.VSM, CustomDrive.familyOf(FirmwareInfo.Gen.SWI165))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI69))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI131))
assertEquals(Family.A9, CustomDrive.familyOf(FirmwareInfo.Gen.SWI132))
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.SWI133))
assertNull(CustomDrive.familyOf(FirmwareInfo.Gen.UNKNOWN))
}
@Test fun `method names follow the family`() {
assertEquals("setBrakePedalLevel", CustomDrive.methodName(Setting.PEDAL, Family.VSM, write = true))
assertEquals("getDrivingPowerTrainMode", CustomDrive.methodName(Setting.POWER, Family.A9, write = false))
}
}
@@ -0,0 +1,97 @@
package com.evsuite.hardware
import com.evsuite.hardware.catalog.ActionType
import com.evsuite.hardware.catalog.ConditionType
import com.evsuite.hardware.probe.VendorSurfaceRules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* CR-046. Owner decisions pinned here, off the car: a rule never switches the low beams OFF,
* never forces the high beam ON, and touches no light on a generation nobody has proven.
*/
class ExteriorLightsTest {
private val gen = FirmwareInfo.Gen.SWI68
private val proven = setOf(gen)
@Test
fun `low beams can be set ON or AUTOMATIC, never OFF`() {
val allowed = ExteriorLights.HEADLIGHT_SWITCH_ALLOWED
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
}
@Test
fun `high beam can be set OFF or AUTOMATIC, never forced ON`() {
val allowed = ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED
assertNotNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_ON, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_OFF, proven))
assertNull(ExteriorLights.writeRefusal(gen, allowed, ExteriorLights.SWITCH_AUTOMATIC, proven))
}
@Test
fun `daytime running is never a value a rule can write`() {
listOf(
ExteriorLights.HEADLIGHT_SWITCH_ALLOWED,
ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED,
ExteriorLights.FOG_SWITCH_ALLOWED,
).forEach { assertFalse(ExteriorLights.SWITCH_DAYTIME_RUNNING in it) }
}
@Test
fun `an unproven generation refuses every light write`() {
assertNotNull(
ExteriorLights.writeRefusal(
FirmwareInfo.Gen.SWI69, ExteriorLights.FOG_SWITCH_ALLOWED, ExteriorLights.SWITCH_ON, proven
)
)
// Until a capture proves one, no generation is proven at all.
assertTrue(ExteriorLights.PROVEN.isEmpty())
FirmwareInfo.Gen.entries.forEach { assertFalse(ExteriorLights.isProven(it)) }
}
@Test
fun `daytime running lights are not headlights on`() {
assertEquals(true, ExteriorLights.isOn(ExteriorLights.STATE_ON))
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_DAYTIME_RUNNING))
assertEquals(false, ExteriorLights.isOn(ExteriorLights.STATE_OFF))
assertNull(ExteriorLights.isOn(null))
}
@Test
fun `the editor cannot even express a forbidden light value`() {
val lowBeam = ActionType.SET_HEADLIGHTS.spec.options.map { it.value }.toSet()
val highBeam = ActionType.SET_HIGH_BEAM.spec.options.map { it.value }.toSet()
assertEquals(ExteriorLights.HEADLIGHT_SWITCH_ALLOWED, lowBeam)
assertEquals(ExteriorLights.HIGH_BEAM_SWITCH_ALLOWED, highBeam)
}
@Test
fun `light actions are gated and unproven, light entries supported nowhere yet`() {
val actions = listOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
actions.forEach {
assertTrue("${it.name} must be gated", it.gated)
assertFalse("${it.name} must stay unproven until a capture", it.writeProven)
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
}
listOf(ConditionType.HEADLIGHTS, ConditionType.HIGH_BEAM_ON, ConditionType.FOG_LIGHTS_ON).forEach {
assertEquals(emptySet<FirmwareGen>(), FirmwareSupport.gensOf(it))
}
}
@Test
fun `a light property is named in the capture diff`() {
assertEquals(
"CPM 0x11400E00/0 (HEADLIGHTS_STATE)",
VendorSurfaceRules.label("CPM 0x11400E00/0"),
)
assertEquals("CPM 0x2160F404/0", VendorSurfaceRules.label("CPM 0x2160F404/0"))
assertEquals("VehicleSetting.getX", VendorSurfaceRules.label("VehicleSetting.getX"))
}
}
@@ -8,12 +8,6 @@ class PhysicalButtonEventDecoderTest {
@Test fun `every R69 hardkey code decodes`() {
val expected = mapOf(
5 to PhysicalButtonEventDecoder.Button.PHONE,
6 to PhysicalButtonEventDecoder.Button.UP,
7 to PhysicalButtonEventDecoder.Button.DOWN,
8 to PhysicalButtonEventDecoder.Button.OK,
9 to PhysicalButtonEventDecoder.Button.LEFT,
10 to PhysicalButtonEventDecoder.Button.RIGHT,
16 to PhysicalButtonEventDecoder.Button.PHONE,
23 to PhysicalButtonEventDecoder.Button.CENTER,
24 to PhysicalButtonEventDecoder.Button.VOLUME_UP,
25 to PhysicalButtonEventDecoder.Button.VOLUME_DOWN,
@@ -32,6 +26,72 @@ class PhysicalButtonEventDecoderTest {
assertEquals(button, decoder.accept(code, false, false)?.button)
}
}
@Test fun `the SystemUI broadcast adds only the cluster pad`() {
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
val expected = mapOf(
6 to PhysicalButtonEventDecoder.Button.UP,
7 to PhysicalButtonEventDecoder.Button.DOWN,
8 to PhysicalButtonEventDecoder.Button.OK,
9 to PhysicalButtonEventDecoder.Button.LEFT,
10 to PhysicalButtonEventDecoder.Button.RIGHT
)
expected.forEach { (code, button) ->
val decoder = PhysicalButtonEventDecoder()
decoder.accept(code, true, false, source = systemUi)
assertEquals(button, decoder.accept(code, false, false, source = systemUi)?.button)
}
}
@Test fun `a press on both broadcasts is one press`() {
// Next track: 87 on the report, 5 on SystemUI. Read as one space, SystemUI's 5 was a
// phone press; and the phone itself (5 and 16) arrived twice, i.e. as a double.
val decoder = PhysicalButtonEventDecoder()
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
val events = listOfNotNull(
decoder.accept(87, true, false, atMillis = 0),
decoder.accept(5, true, false, atMillis = 0, source = systemUi),
decoder.accept(87, false, false, atMillis = 50),
decoder.accept(5, false, false, atMillis = 50, source = systemUi),
decoder.accept(5, true, false, atMillis = 1_000),
decoder.accept(16, true, false, atMillis = 1_000, source = systemUi),
decoder.accept(5, false, false, atMillis = 1_050),
decoder.accept(16, false, false, atMillis = 1_050, source = systemUi)
)
assertEquals(listOf("MEDIA_NEXT:SHORT", "PHONE:SHORT"), events.map { it.value })
}
@Test fun `an unlisted key is reported with an id of its own`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(3, true, false)
val event = decoder.accept(3, false, false)!!
assertNull(event.button)
assertEquals(
PhysicalButtonEventDecoder.Source.HARDKEY_REPORT to 3,
PhysicalButtonEventDecoder.unknownKey(event.keyId)
)
// Same code, other broadcast, other key.
val systemUi = PhysicalButtonEventDecoder.Source.SYSTEM_UI
decoder.accept(30, true, false, source = systemUi)
val other = decoder.accept(30, false, false, source = systemUi)!!
assertEquals(PhysicalButtonEventDecoder.Source.SYSTEM_UI to 30, PhysicalButtonEventDecoder.unknownKey(other.keyId))
assertEquals("KEY_${event.keyId}:SHORT", event.value)
}
@Test fun `stored button ids are unchanged`() {
// Rules on disk hold these numbers; changing one silently rebinds a rule.
val ids = PhysicalButtonEventDecoder.Button.entries.associate { it.name to it.id }
assertEquals(
mapOf(
"PHONE" to 5, "UP" to 6, "DOWN" to 7, "OK" to 8, "LEFT" to 9, "RIGHT" to 10,
"SOURCE" to 110, "CENTER" to 23, "VOLUME_UP" to 24, "VOLUME_DOWN" to 25,
"MEDIA_NEXT" to 87, "MEDIA_PREVIOUS" to 88, "MUTE" to 164, "STAR_LEFT" to 17,
"STAR_RIGHT" to 286, "ASSISTANT" to 287
),
ids
)
}
@Test fun `two releases inside the window make one double`() {
val decoder = PhysicalButtonEventDecoder()
decoder.accept(17, true, false, atMillis = 0)
@@ -108,7 +108,8 @@ class CatalogConsistencyTest {
ActionType.SET_ELK_MODE, ActionType.SET_ELK_SENSITIVITY,
ActionType.SET_ACC_TJA_MODE, ActionType.SET_LIMITER_MODE,
ActionType.SET_TSR, ActionType.SET_OVERSPEED_ALARM,
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING
ActionType.SET_SPEED_LIMIT_TONE, ActionType.SET_SOUND_WARNING,
ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS
)
mustBeGated.forEach { type ->
@@ -325,7 +326,15 @@ class CatalogConsistencyTest {
}
// Exactly these: an unproven write is invisible to the user, so adding one elsewhere
// silently removes an action from every app that carries this catalogue.
assertEquals(glass.toSet(), ActionType.entries.filter { !it.writeProven }.toSet())
// CR-046: the exterior-light actions are unproven too, on purpose, until a capture
// proves a generation (see ExteriorLightsTest).
val lights = setOf(ActionType.SET_HEADLIGHTS, ActionType.SET_HIGH_BEAM, ActionType.SET_FOG_LIGHTS)
// CP-088: the charge limit reads back as a code on SWI68 while the write sends a percent.
val chargeLimit = setOf(ActionType.SET_CHARGE_LIMIT)
assertEquals(
glass.toSet() + lights + chargeLimit,
ActionType.entries.filter { !it.writeProven }.toSet()
)
}
@Test
@@ -0,0 +1,46 @@
package com.evsuite.hardware.diag
import com.google.gson.JsonParser
import java.util.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DiagnosticInboxTest {
private val artifact = DiagnosticInbox.Artifact(
app = "EVTasker", version = "2.17.0", name = "evtasker-diagnostic.txt",
createdAtMs = 1_792_323_000_000L, content = "report ✓".toByteArray(),
)
@Test fun `only a plain https address is an inbox`() {
assertNull(DiagnosticInbox.refuse("https://nodered.example/evsuite"))
assertNotNull(DiagnosticInbox.refuse("http://192.0.2.10:1880/evsuite"))
assertNotNull(DiagnosticInbox.refuse("https://user:pw@host/evsuite"))
assertNotNull(DiagnosticInbox.refuse("https://host/evsuite?token=1"))
assertNotNull(DiagnosticInbox.refuse("evsuite"))
}
@Test fun `the body carries the artifact byte for byte`() {
var sent: String? = null
val outcome = DiagnosticInbox.send("https://host/evsuite", artifact) { _, body -> sent = body; 200 }
assertEquals(DiagnosticInbox.Outcome.Sent(200), outcome)
val json = JsonParser.parseString(sent).asJsonObject
assertEquals(1, json["evsuite"].asInt)
assertEquals("EVTasker", json["app"].asString)
assertEquals("report ✓", String(Base64.getDecoder().decode(json["contentBase64"].asString)))
}
@Test fun `a refused address never reaches the network, a server error is a failure`() {
var called = false
val refused = DiagnosticInbox.send("http://host/evsuite", artifact) { _, _ -> called = true; 200 }
assertTrue(refused is DiagnosticInbox.Outcome.Failed && !called)
assertEquals(
DiagnosticInbox.Outcome.Failed("HTTP 500"),
DiagnosticInbox.send("https://host/evsuite", artifact) { _, _ -> 500 },
)
}
}
@@ -91,4 +91,25 @@ class RuntimeInterfaceProbeTest {
val calls = RuntimeInterfaceProbe.CONFIG_CALLS
assertEquals(calls.size, calls.values.map { it.code }.toSet().size)
}
@Test
fun `the component map keeps the manifest's words and says when it stopped listing`() {
val rows = (0 until RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 3).map {
RuntimeInterfaceProbe.ServiceRow("com.saicmotor.x.Service$it", it == 0, null)
}
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.x", rows)
assertEquals("componentMap:com.saicmotor.x", entry.name)
assertEquals(BindVerdict.ANSWERED, entry.verdict)
assertEquals((rows.size).toString(), entry.values["services"])
assertEquals("exported=true permission=null", entry.values["com.saicmotor.x.Service0"])
assertEquals("3", entry.values["truncated"])
assertEquals(RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 2, entry.values.size)
}
@Test
fun `a package that declares no service was still read`() {
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.y", emptyList())
assertEquals(BindVerdict.ANSWERED, entry.verdict)
assertEquals(mapOf("services" to "0"), entry.values)
}
}
@@ -0,0 +1,106 @@
package com.evsuite.hardware.probe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The before/after capture and the SOC survey call getters by reflection on vendor objects, in a
* car with a driver in it. The one thing that must never happen is a call that changes something,
* so the selection is pinned here, off the car, where a wrong answer is a failing test rather
* than a setting that moved.
*/
class VendorSurfaceRulesTest {
@Suppress("unused")
class FakeVendorClient {
fun getAutoHighBeamSetting(): Int = 1
fun isAutoHighBeamEnabled(): Boolean = true
fun getBatterySoc(): Float = 63f
fun hasRainSensor(): Boolean = false
fun getVersionName(): String = "x"
fun getStatusBoxed(): Int? = 2
fun setAutoHighBeamSetting(value: Int) = Unit
fun getAndResetCounter(): Int = 0
fun getInstance(): FakeVendorClient = this
fun getService(): Any = Any()
fun getListenerCount(): Int = 0
fun getLevel(area: Int): Int = area
fun requestState(): Int = 0
fun getter(): Int = 0
fun gettable(): Int = 0
fun getValues(): IntArray = intArrayOf()
fun startCharging(): Boolean = true
companion object {
@JvmStatic fun getStaticValue(): Int = 0
}
}
private val names = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java).map { it.name }
@Test
fun `value getters are read, including Setting and Enabled names`() {
assertEquals(
listOf(
"getAutoHighBeamSetting", "getBatterySoc", "getStatusBoxed", "getVersionName",
"hasRainSensor", "isAutoHighBeamEnabled",
),
names,
)
}
@Test
fun `nothing that acts, takes an argument or returns an object is called`() {
listOf(
"setAutoHighBeamSetting", "getAndResetCounter", "getInstance", "getService",
"getListenerCount", "getLevel", "requestState", "getter", "gettable", "getValues",
"startCharging", "getStaticValue", "getClass", "hashCode", "toString",
).forEach { assertFalse(it, it in names) }
}
@Test
fun `keywords narrow the survey, case-insensitively`() {
val soc = VendorSurfaceRules.readableGetters(FakeVendorClient::class.java, listOf("SOC", "battery"))
assertEquals(listOf("getBatterySoc"), soc.map { it.name })
}
@Test
fun `camel-case words split on case and underscore`() {
assertEquals(listOf("and", "reset", "counter"), VendorSurfaceRules.words("AndResetCounter"))
assertEquals(listOf("high", "beam", "setting"), VendorSurfaceRules.words("High_BeamSetting"))
}
@Test
fun `the diff lists changed, appeared and vanished values, and nothing else`() {
val before = mapOf("a" to "1", "b" to "2", "gone" to "x")
val after = mapOf("a" to "1", "b" to "3", "new" to "y")
assertEquals(
listOf(
VendorSurfaceRules.Change("b", "2", "3"),
VendorSurfaceRules.Change("gone", "x", null),
VendorSurfaceRules.Change("new", null, "y"),
),
VendorSurfaceRules.diff(before, after),
)
assertTrue(VendorSurfaceRules.diff(before, before).isEmpty())
}
@Test
fun `an empty diff says why it may be empty`() {
val text = VendorSurfaceRules.formatDiff(emptyList(), 10, 10)
assertTrue(text.contains("0 changed"))
assertTrue(text.contains("nothing changed"))
val one = VendorSurfaceRules.formatDiff(listOf(VendorSurfaceRules.Change("k", null, "1")), 1, 2)
assertTrue(one.contains("k: — → 1"))
}
@Test
fun `arrays print their content`() {
assertEquals("[1, 2]", VendorSurfaceRules.render(intArrayOf(1, 2)))
assertEquals("[0.5]", VendorSurfaceRules.render(floatArrayOf(0.5f)))
assertEquals("null", VendorSurfaceRules.render(null))
}
}
@@ -0,0 +1,25 @@
package com.evsuite.hardware.saic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The charge limit read refuses what the car could never have been set to. */
class SaicChargeLimitTest {
@Test fun `the code SWI68 answers is not a percent`() {
assertNull(SaicCharging.chargeLimitOrNull(5))
}
@Test fun `the range the car accepts reads through, ends included`() {
assertEquals(40, SaicCharging.chargeLimitOrNull(40))
assertEquals(80, SaicCharging.chargeLimitOrNull(80))
assertEquals(100, SaicCharging.chargeLimitOrNull(100))
}
@Test fun `outside it, and unread, is unknown`() {
assertNull(SaicCharging.chargeLimitOrNull(39))
assertNull(SaicCharging.chargeLimitOrNull(101))
assertNull(SaicCharging.chargeLimitOrNull(null))
}
}
@@ -0,0 +1,84 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class BatteryDigestTest {
private fun day(
day: String,
health: Double? = 94.0,
band: Double? = 3.0,
calibration: CalibrationVerdict? = CalibrationVerdict.SETTLED,
) = BatteryDigest(
day = day,
healthPercent = health,
healthBandPercent = band,
healthWindows = if (health == null) 0 else 5,
calibration = calibration,
daysSinceFullCharge = 4.0,
hoursAboveHighSoc = 10.0,
equivalentFullCycles = 12.0,
chargeCount = 7,
)
@Test
fun `movement inside the band is not a change`() {
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 92.0)).isEmpty())
}
@Test
fun `movement past the band is one change`() {
val change = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", health = 90.0)).single()
assertEquals(DigestChange.HealthMoved(94.0, 90.0, 3.0), change)
}
@Test
fun `a band that narrows by a point is said, less is rounding`() {
val narrowed = BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.0))
assertEquals(listOf(DigestChange.HealthBandNarrowed(3.0, 2.0, 94.0)), narrowed)
assertTrue(BatteryDigest.changes(day("2026-09-28"), day("2026-09-29", band = 2.5)).isEmpty())
}
@Test
fun `the first estimate is news, and the first day has nothing to compare`() {
val first = BatteryDigest.changes(day("2026-09-28", health = null, band = null), day("2026-09-29"))
assertEquals(listOf(DigestChange.HealthBandNarrowed(null, 3.0, 94.0)), first)
assertTrue(BatteryDigest.changes(null, day("2026-09-29")).isEmpty())
}
@Test
fun `a calibration verdict that changes is a change`() {
val change = BatteryDigest.changes(
day("2026-09-28"),
day("2026-09-29", calibration = CalibrationVerdict.WATCH),
).single() as DigestChange.CalibrationChanged
assertEquals(CalibrationVerdict.WATCH, change.to)
}
@Test
fun `the store keeps one record a day, a bounded year, and returns the day before`() {
val dir = Files.createTempDirectory("digest").toFile()
val store = BatteryDigestStore(File(dir, BatteryDigestStore.FILE_NAME), maxDays = 2)
assertNull(store.upsert(day("2026-09-27")))
assertEquals("2026-09-27", store.upsert(day("2026-09-28"))?.day)
assertEquals("2026-09-27", store.upsert(day("2026-09-28", health = 80.0))?.day)
store.upsert(day("2026-09-29"))
assertEquals(listOf("2026-09-28", "2026-09-29"), store.read().map { it.day })
assertEquals(80.0, store.read().first().healthPercent!!, 1e-9)
dir.deleteRecursively()
}
@Test
fun `an unreadable file is set aside, not trusted`() {
val dir = Files.createTempDirectory("digest").toFile()
val file = File(dir, BatteryDigestStore.FILE_NAME).apply { writeText("{not json") }
assertTrue(BatteryDigestStore(file).read().isEmpty())
assertTrue(dir.listFiles()!!.any { it.name.contains("quarantine") })
dir.deleteRecursively()
}
}
@@ -72,6 +72,16 @@ class BatteryLedgerTest {
assertTrue(recorder.observe(snapshot(atMs = 1_000L, soc = 50f, chargingStatus = 1)))
}
@Test fun `a reading taken on the factory clock earns nothing`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
val factoryClock = 1_546_214_400_000L - NOW_MS // 2018-12-31, as the woken head unit reads
assertFalse(recorder.observe(snapshot(atMs = factoryClock, soc = 80f)))
assertTrue(recorder.observe(snapshot(atMs = 0L, soc = 80f)))
assertEquals(listOf(NOW_MS), store.read().map { it.atMs })
}
@Test fun `the integrals are cumulative and bounded by the same five seconds a trip uses`() {
val store = BatteryLedgerStore(File(tempDirectory(), BatteryLedgerStore.FILE_NAME))
val recorder = BatteryLedgerRecorder(store)
@@ -134,7 +144,7 @@ class BatteryLedgerTest {
consumedKwh: Float? = null,
regeneratedKwh: Float? = null,
) = EnergySnapshot(
timestampMs = atMs,
timestampMs = NOW_MS + atMs,
firmware = FirmwareInfo.Gen.SWI68,
socPercent = soc,
rangeKm = null,
@@ -156,4 +166,9 @@ class BatteryLedgerTest {
)
private fun tempDirectory(): File = Files.createTempDirectory("ledger").toFile()
private companion object {
/** 2026-10-11, past the recorder's clock floor. */
const val NOW_MS = 1_791_728_806_645L
}
}
@@ -0,0 +1,95 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeDurationTest {
/** A watched charge from [from] to [to] %, one step per point, [minutesOf] minutes for each. */
private fun charge(from: Int, to: Int, outside: Double? = 15.0, minutesOf: (Int) -> Double) = ChargeEnergy(
session = BatteryChargeSession(0L, 1L, from.toDouble(), to.toDouble(), 0.0, 0.0, outside),
watched = true,
maxEntryGapMs = 0L,
packDeltaKwh = null,
meanPowerKw = null,
counterBehaviour = ChargeCounterBehaviour.RESET,
consumedDeltaKwh = null,
regeneratedDeltaKwh = null,
movedKm = 0.0,
chargingStatuses = emptyList(),
steps = (from until to).map {
ChargePowerStep(it.toDouble(), it + 1.0, (minutesOf(it) * 60_000).toLong(), 7.0)
},
minOutsideTempCelsius = outside,
maxOutsideTempCelsius = outside,
)
/** Three minutes a point below 80 %, six above: the taper. */
private val tapered = { soc: Int -> if (soc < 80) 3.0 else 6.0 }
@Test
fun `duration sums the bands on the path, and the last stretch is its own figure`() {
val history = listOf(charge(20, 100, minutesOf = tapered))
val estimate = (ChargeDuration.estimate(history, 50.0, 100, null) as ChargeDurationResult.Ready).estimate
assertEquals(30 * 3.0 + 20 * 6.0, estimate.minutes, 1e-6)
assertEquals(120.0, estimate.lastStretchMinutes!!, 1e-6)
assertFalse(estimate.similarTemperature)
}
@Test
fun `the range is the fastest and the slowest past charge`() {
val history = listOf(
charge(40, 80) { 2.0 },
charge(40, 80) { 3.0 },
charge(40, 80) { 5.0 },
)
val estimate = (ChargeDuration.estimate(history, 60.0, 80, null) as ChargeDurationResult.Ready).estimate
assertEquals(60.0, estimate.minutes, 1e-6)
assertEquals(40.0, estimate.lowMinutes, 1e-6)
assertEquals(100.0, estimate.highMinutes, 1e-6)
assertNull(estimate.lastStretchMinutes)
}
@Test
fun `a band nobody watched is refused, never extrapolated`() {
val refused = ChargeDuration.estimate(listOf(charge(20, 80, minutesOf = tapered)), 50.0, 100, null)
assertEquals(ChargeDurationResult.Refused(ChargeDurationRefusal.MISSING_BAND, 80), refused)
assertEquals(
ChargeDurationResult.Refused(ChargeDurationRefusal.NO_WATCHED_CHARGE),
ChargeDuration.estimate(emptyList(), 50.0, 100, null),
)
assertEquals(
ChargeDurationResult.Refused(ChargeDurationRefusal.AT_TARGET),
ChargeDuration.estimate(emptyList(), 80.0, 80, null),
)
}
@Test
fun `a similar outside temperature is preferred, and all charges are the fallback`() {
val history = listOf(
charge(40, 80, outside = 0.0) { 5.0 },
charge(40, 80, outside = 20.0) { 3.0 },
)
val cold = (ChargeDuration.estimate(history, 60.0, 80, 2.0) as ChargeDurationResult.Ready).estimate
assertTrue(cold.similarTemperature)
assertEquals(100.0, cold.minutes, 1e-6)
val hot = (ChargeDuration.estimate(history, 60.0, 80, 35.0) as ChargeDurationResult.Ready).estimate
assertFalse(hot.similarTemperature)
assertEquals(20 * 5.0, hot.minutes, 1e-6) // the median of two is the upper one
}
@Test
fun `the curve check needs three past charges and flags only slower than all of them`() {
val two = listOf(charge(80, 90) { 6.0 }, charge(80, 90) { 7.0 })
assertNull(ChargeDuration.curveCheck(two, 80, 20.0))
val three = two + charge(80, 90) { 8.0 }
assertTrue(ChargeDuration.curveCheck(three, 80, 9.0)!!.slower)
val usual = ChargeDuration.curveCheck(three, 80, 7.5)!!
assertFalse(usual.slower)
assertEquals(6.0, usual.usualLowMinutesPerPoint, 1e-9)
assertEquals(3, usual.pastCharges)
}
}
@@ -0,0 +1,237 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChargeEnergyTest {
@Test fun `a watched charge carries its power and the sign the integral moved`() {
// Three quarters of an hour at sixteen points an hour, with the pack pair integral
// falling by 8,25 kWh: the declared convention — positive leaves the battery — holding
// across a charge, and 11 kW of it.
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
entry(15 * MINUTE, 44f, pack = 97.25, odometer = 10_000f),
entry(30 * MINUTE, 48f, pack = 94.5, odometer = 10_000f),
entry(45 * MINUTE, 52f, pack = 91.75, odometer = 10_000f),
)
val report = analyse(entries)
assertEquals(1, report.charges.size)
val charge = report.charges.single()
assertTrue(charge.watched)
assertTrue(charge.plugged)
assertEquals(-8.25, charge.packDeltaKwh!!, 1e-6)
assertEquals(11.0, charge.meanPowerKw!!, 1e-6)
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
assertEquals(charge, report.lastPluggedCharge)
}
@Test fun `an integral that rises during a charge is reported as the other convention`() {
val entries = listOf(
entry(0L, 40f, pack = 10.0),
entry(15 * MINUTE, 45f, pack = 17.0),
entry(30 * MINUTE, 50f, pack = 24.0),
)
assertEquals(ChargePackSign.ENERGY_ROSE, analyse(entries).packSign)
}
@Test fun `two charges that disagree settle nothing`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0),
entry(15 * MINUTE, 45f, pack = 96.5),
entry(30 * MINUTE, 50f, pack = 93.0),
// A drive in between, so what follows is a second session rather than the same one.
entry(45 * MINUTE, 30f, pack = 99.0),
entry(60 * MINUTE, 35f, pack = 102.5),
entry(75 * MINUTE, 40f, pack = 106.0),
)
assertEquals(ChargePackSign.CONTRADICTORY, analyse(entries).packSign)
}
@Test fun `a charge the app slept through carries no power and no sign`() {
// The recorder earns an entry every quarter of an hour while it is running, so a
// six-hour silence is the head unit having been off — and the integral did not move
// during it, which would otherwise read as a charge that took no energy at all.
val entries = listOf(
entry(0L, 30f, pack = 100.0),
entry(6 * HOUR, 90f, pack = 100.0),
)
val report = analyse(entries)
val charge = report.charges.single()
assertFalse(charge.watched)
assertNull(charge.meanPowerKw)
assertEquals(ChargePackSign.UNREADABLE, charge.sign)
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
}
@Test fun `a counter that goes backwards across a charge is the car resetting it`() {
val entries = listOf(
entry(0L, 40f, consumed = 12f, regenerated = 3f),
entry(15 * MINUTE, 55f, consumed = 12f, regenerated = 3f),
entry(30 * MINUTE, 70f, consumed = 0f, regenerated = 0f),
)
val report = analyse(entries)
assertEquals(ChargeCounterBehaviour.RESET, report.counterBehaviour)
assertEquals(-12.0, report.charges.single().consumedDeltaKwh!!, 1e-6)
}
@Test fun `counters read at both ends and unchanged are static, never absent`() {
val entries = listOf(
entry(0L, 40f, consumed = 12f, regenerated = 3f),
entry(20 * MINUTE, 70f, consumed = 12f, regenerated = 3f),
)
assertEquals(ChargeCounterBehaviour.STATIC, analyse(entries).counterBehaviour)
}
@Test fun `a car with no counters at all says absent rather than static`() {
val entries = listOf(entry(0L, 40f), entry(20 * MINUTE, 70f))
assertEquals(ChargeCounterBehaviour.ABSENT, analyse(entries).counterBehaviour)
}
@Test fun `a reset seen once outranks a charge that ended before the reset`() {
val entries = listOf(
entry(0L, 40f, consumed = 9f),
entry(15 * MINUTE, 60f, consumed = 9f),
entry(30 * MINUTE, 30f, consumed = 14f),
entry(45 * MINUTE, 70f, consumed = 0f),
)
assertEquals(ChargeCounterBehaviour.RESET, analyse(entries).counterBehaviour)
}
@Test fun `a rise while the odometer moves is regeneration, never the last charge`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f),
entry(20 * MINUTE, 48f, pack = 96.0, odometer = 10_020f),
)
val report = analyse(entries)
val charge = report.charges.single()
assertEquals(20.0, charge.movedKm!!, 1e-6)
assertFalse(charge.plugged)
assertNull(report.lastPluggedCharge)
// The sign is still evidence: energy entering the pack is energy entering the pack.
assertEquals(ChargePackSign.ENERGY_FELL, report.packSign)
}
@Test fun `a night the head unit slept through is a charge even when the drive home was not recorded`() {
// The reported case: plugged in overnight, battery page empty the next morning. The last
// entry before sleep was written before the unrecorded drive home, so the odometer moved
// between the two ends and the old rule read fifty points as a descent.
val entries = listOf(
entry(0L, 38f, pack = 100.0, odometer = 10_000f, temp = 12f),
entry(11 * HOUR, 88f, pack = 100.0, odometer = 10_014f, temp = 7f),
)
val charge = analyse(entries).lastPluggedCharge!!
assertFalse(charge.watched)
assertTrue(charge.plugged)
assertNull(charge.meanPowerKw)
assertTrue(charge.steps.isEmpty())
assertEquals(7.0, charge.minOutsideTempCelsius!!, 1e-6)
assertEquals(12.0, charge.maxOutsideTempCelsius!!, 1e-6)
}
@Test fun `the car saying it charges is enough without an odometer`() {
val entries = listOf(
entry(0L, 40f, status = 1),
entry(10 * MINUTE, 43f, status = 1),
)
assertTrue(analyse(entries).charges.single().plugged)
}
@Test fun `a small rise across a gap while the car moved stays unclaimed`() {
val entries = listOf(
entry(0L, 40f, odometer = 10_000f),
entry(3 * HOUR, 46f, odometer = 10_030f),
)
assertNull(analyse(entries).lastPluggedCharge)
}
@Test fun `a watched charge carries its curve, its peak and its taper by band`() {
// 7 kW up to 80 %, then the pack takes 3,5 kW above it.
val entries = listOf(
entry(0L, 77f, pack = 100.0, odometer = 10_000f),
entry(6 * MINUTE, 78f, pack = 99.3, odometer = 10_000f),
entry(12 * MINUTE, 79f, pack = 98.6, odometer = 10_000f),
entry(18 * MINUTE, 80f, pack = 97.9, odometer = 10_000f),
entry(30 * MINUTE, 81f, pack = 97.2, odometer = 10_000f),
)
val report = analyse(entries)
val charge = report.charges.single()
assertEquals(4, charge.steps.size)
assertEquals(7.0, charge.peakPowerKw!!, 1e-6)
val bands = report.powerBySocBand()
assertEquals(listOf(70, 80), bands.map { it.fromPercent })
assertEquals(7.0, bands[0].meanPowerKw, 1e-6)
assertEquals(3.5, bands[1].meanPowerKw, 1e-6)
assertTrue(report.describe().any { it.startsWith("soc_band 80-90 mean_power_kw=3.50") })
}
@Test fun `the description names every question and stays one line per charge`() {
val entries = listOf(
entry(0L, 40f, pack = 100.0, odometer = 10_000f, status = 2),
entry(20 * MINUTE, 55f, pack = 86.0, odometer = 10_000f, status = 2),
)
val lines = analyse(entries).describe()
assertTrue(lines.any { it == "pack_pair_sign=ENERGY_FELL" })
assertTrue(lines.any { it == "vehicle_counters=ABSENT" })
assertTrue(lines.any { it == "charging_status_values=2" })
assertEquals(1, lines.count { it.startsWith("charge ") })
}
@Test fun `an empty ledger describes itself without inventing a verdict`() {
val report = ChargeEnergyAnalyzer().analyse(emptyList(), emptyList())
assertEquals(ChargePackSign.UNREADABLE, report.packSign)
assertEquals(ChargeCounterBehaviour.ABSENT, report.counterBehaviour)
assertEquals(listOf("charges=0"), report.describe())
}
/** The sessions are the exposure report's, never re-segmented — the analyzer's own rule. */
private fun analyse(entries: List<BatteryLedgerEntry>): ChargeEnergyReport {
val sessions = BatteryExposure().analyse(entries)?.sessions.orEmpty()
return ChargeEnergyAnalyzer().analyse(entries, sessions)
}
private fun entry(
atMs: Long,
soc: Float,
pack: Double? = null,
consumed: Float? = null,
regenerated: Float? = null,
odometer: Float? = null,
status: Int? = null,
temp: Float? = null,
) = BatteryLedgerEntry(
atMs = atMs,
socPercent = soc,
odometerKm = odometer,
vehicleConsumedKwh = consumed,
vehicleRegeneratedKwh = regenerated,
packEnergyKwh = pack,
chargingStatus = status,
outsideTempCelsius = temp,
)
private companion object {
const val MINUTE = 60_000L
const val HOUR = 3_600_000L
}
}
@@ -0,0 +1,72 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DrivingStressTest {
/**
* Ten minutes at one sample every ten seconds, the charge falling two points. Minutes 0, 3
* and 6 pull 60 kW, minute 9 regenerates 10 kW, the rest cruise at 20 kW.
*/
private fun track(
soc: Float?,
outside: Float?,
sign: Float = 1f,
power: (Int) -> Float? = { i ->
when (i / 6) {
0, 3, 6 -> 60f
9 -> -10f
else -> 20f
}
},
) = (0 until 60).map { i ->
TripSample(
atMs = i * 10_000L,
speedKmh = 50f,
batteryPowerKw = power(i)?.times(sign),
socPercent = soc?.minus(i / 30f),
outsideTempCelsius = outside,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
@Test
fun `hard power in the cold is counted, and cold regeneration summed`() {
val stress = DrivingStress.of(track(soc = 60f, outside = -5f))!!
assertEquals(3.0, stress.stressedMinutes, 1e-9)
assertEquals(50 * 10.0 / 3600.0, stress.coldRegeneratedKwh!!, 1e-9)
}
@Test
fun `the trip orients its own power sign`() {
assertEquals(DrivingStress.of(track(soc = 60f, outside = -5f)), DrivingStress.of(track(60f, -5f, sign = -1f)))
}
@Test
fun `a warm drive at a good charge is a measured zero`() {
assertEquals(DrivingStress(0.0, 0.0), DrivingStress.of(track(soc = 60f, outside = 15f)))
}
@Test
fun `low charge alone is enough, and an unread temperature leaves the regeneration unknown`() {
val stress = DrivingStress.of(track(soc = 19f, outside = null))!!
assertEquals(3.0, stress.stressedMinutes, 1e-9)
assertNull(stress.coldRegeneratedKwh)
}
@Test
fun `unknown is never zero`() {
// Hard moments at a good charge with no temperature: they cannot be decided.
assertNull(DrivingStress.of(track(soc = 60f, outside = null)))
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { null }))
assertNull(DrivingStress.of(track(soc = null, outside = -5f)))
// A third of the power unread.
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { i -> if (i % 3 == 0) null else 20f }))
}
}
@@ -0,0 +1,289 @@
package com.evsuite.hardware.telemetry
import com.evsuite.hardware.FirmwareInfo
import com.evsuite.hardware.VehicleSpeedEvidence
import com.evsuite.hardware.telemetry.model.EnergyModelEnvelope
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class EcoDrivingTest {
private val generation = FirmwareInfo.Gen.SWI68
private val evidence = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.CURRENT)
/** Predicts 18.0 % per 100 km at 100 km/h and 20 °C, with a band of 0.98. */
private val model = SocConsumptionModel(
speedEvidence = evidence,
rollingPercentPer100Km = 10.0,
aeroPercentPer100KmPerSpeedSquared = 0.0008,
thermalPercentPer100KmPerDegree = 0.2,
residualRmsePercentPer100Km = 0.5,
segmentCount = SocConsumptionFitter.MIN_SEGMENTS,
envelope = EnergyModelEnvelope(20.0, 160.0, -10.0, 35.0),
)
/** 50 km in half an hour, so the mean speed the model is asked about is 100 km/h. */
private fun trip(
startSoc: Float? = 90f,
endSoc: Float? = 81f,
distanceKm: Double = 50.0,
durationMs: Long = 1_800_000L,
speedEvidence: VehicleSpeedEvidence? = evidence,
) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = durationMs,
durationMs = durationMs,
distanceKm = distanceKm,
startSocPercent = startSoc,
endSocPercent = endSoc,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
speedEvidence = speedEvidence,
)
private fun verdict(
monitor: EcoDrivingMonitor = EcoDrivingMonitor(),
trip: EnergyTripSummary? = trip(),
model: SocConsumptionModel? = this.model,
tempCelsius: Float? = 20f,
) = monitor.verdict(model, trip, tempCelsius, generation)
/** A steady cruise, one sample a second, from which no lever can fire. */
private fun cruise(speedKmh: Float, seconds: Int, fromMs: Long = 0L): EcoDrivingMonitor {
val monitor = EcoDrivingMonitor()
(0..seconds).forEach { monitor.add(fromMs + it * 1_000L, speedKmh) }
return monitor
}
@Test
fun `a drive below the expectation is better than it`() {
// 7 % over 50 km is 14 per 100 km, below the 18.0 the fit expects by more than its band.
val result = verdict(trip = trip(endSoc = 83f))
assertEquals(EcoBand.BETTER, result.band.value)
assertEquals(14.0, result.observedPercentPer100Km!!, 1e-9)
assertEquals(18.0, result.expectedPercentPer100Km!!, 1e-9)
assertTrue(result.deltaPercent!! < 0.0)
assertEquals(100.0, result.meanSpeedKmh!!, 1e-9)
}
@Test
fun `a drive inside the fit's own band is typical`() {
assertEquals(EcoBand.TYPICAL, verdict().band.value)
}
@Test
fun `a drive above the expectation is worse than it`() {
val result = verdict(trip = trip(endSoc = 78f))
assertEquals(EcoBand.WORSE, result.band.value)
assertTrue(result.deltaPercent!! > 0.0)
}
@Test
fun `the band widens with the fit's residual rather than a threshold of our own`() {
// The same 24 per 100 km that is WORSE against a tight fit is TYPICAL against a loose
// one: a model that knows little must not be allowed to accuse the driver.
val loose = model.copy(residualRmsePercentPer100Km = 7.0)
assertEquals(EcoBand.TYPICAL, verdict(trip = trip(endSoc = 78f), model = loose).band.value)
}
@Test
fun `a sixth over the expectation is worse, not typical, against a two-point residual`() {
// 21 per 100 km against 18: the 95 % band (±3.9) called this TYPICAL, one residual does not.
val fit = model.copy(residualRmsePercentPer100Km = 2.0)
assertEquals(EcoBand.WORSE, verdict(trip = trip(endSoc = 79.5f), model = fit).band.value)
}
@Test
fun `no model is a refusal, not a verdict`() {
val result = verdict(model = null)
assertNull(result.band.value)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
}
@Test
fun `a speed outside the fitted envelope is the model's refusal, carried through`() {
// 200 km/h over the 160 the envelope saw.
val result = verdict(trip = trip(distanceKm = 100.0))
assertNull(result.band.value)
assertEquals(UnavailableReason.MODEL_NOT_TRAINED, result.band.reason)
assertEquals(200.0, result.meanSpeedKmh!!, 1e-9)
}
@Test
fun `a distance too short to divide by is refused`() {
val result = verdict(trip = trip(distanceKm = 1.0, durationMs = 36_000L))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `a charge drop below the gauge's own step is refused`() {
val result = verdict(trip = trip(endSoc = 89f))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `a distance from the superseded speed conversion is refused`() {
val stale = VehicleSpeedEvidence(generation, VehicleSpeedEvidence.MPS_TIMES_3_6_V1)
val result = verdict(trip = trip(speedEvidence = stale))
assertEquals(UnavailableReason.INSUFFICIENT_SAMPLES, result.band.reason)
}
@Test
fun `no outside temperature is a missing signal, not a comfortable assumption`() {
val result = verdict(tempCelsius = null)
assertEquals(UnavailableReason.SIGNAL_ABSENT, result.band.reason)
}
@Test
fun `the cruise lever quotes the fit rather than a rule of thumb`() {
val advice = verdict(monitor = cruise(110f, 120)).advice
assertEquals(EcoLever.CRUISE_SPEED, advice?.lever)
assertEquals(110.0, advice!!.fromSpeedKmh!!, 1e-9)
assertEquals(100.0, advice.toSpeedKmh!!, 1e-9)
// predict(110) is 19.68 and predict(100) is 18.0: 8.5 % less.
assertEquals(8.54, advice.savingPercent!!, 0.01)
assertNull(advice.harshSharePercent)
}
@Test
fun `a town speed leaves the cruise lever alone`() {
assertNull(verdict(monitor = cruise(50f, 120)).advice)
}
@Test
fun `the steadiness lever fires from the speed channel alone`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 0f
// Five stop-and-go cycles: 6 s of hard acceleration, then 18 s of cruising.
repeat(5) {
repeat(6) { speed += 10f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(18) { monitor.add(atMs, speed); atMs += 1_000L }
speed = 0f
}
val advice = verdict(monitor = monitor, model = null).advice
assertEquals(EcoLever.STEADINESS, advice?.lever)
assertTrue(advice!!.harshSharePercent!! >= EcoDrivingMonitor.MIN_HARSH_SHARE_PERCENT)
assertNull(advice.savingPercent)
}
@Test
fun `a calm drive is measured, not waiting`() {
val monitor = EcoDrivingMonitor()
(0..30).forEach { monitor.add(it * 1_000L, 50f) }
assertNull(monitor.harshSharePercent())
(31..90).forEach { monitor.add(it * 1_000L, 50f) }
assertEquals(0.0, monitor.harshSharePercent()!!, 1e-9)
assertNull(monitor.steadiness())
}
@Test
fun `hard braking counts like hard acceleration`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 0f
// Gentle launches (0.69 m/s²) and abrupt stops (2.8 m/s²): only the braking is harsh.
repeat(5) {
repeat(24) { speed += 2.5f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(6) { speed -= 10f; monitor.add(atMs, speed); atMs += 1_000L }
}
// 6 hard seconds in every 30: the trip review's `DrivingStyle` reads the same 20 %.
assertEquals(20.0, monitor.harshSharePercent()!!, 0.5)
}
@Test
fun `a sampling gap is a gap, not an acceleration`() {
val monitor = EcoDrivingMonitor()
(0..70).forEach { monitor.add(it * 1_000L, 50f) }
// The app was asleep for half a minute and the car is now at 120: unknown motion, and
// reading it as one step would invent 3.9 m/s² out of the process having been paused.
monitor.add(100_000L, 120f)
assertNull(verdict(monitor = monitor, model = null).advice)
}
@Test
fun `replay accepts the stored track cadence without relaxing live gaps`() {
val samples = (0..24).map { index ->
TripSample(
atMs = index * 5_500L,
speedKmh = if (index % 2 == 0) 0f else 60f,
batteryPowerKw = null,
socPercent = null,
outsideTempCelsius = null,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
assertEquals(EcoLever.STEADINESS, EcoDrivingMonitor.forReplay().replay(samples).steadiness()?.lever)
assertNull(EcoDrivingMonitor().replay(samples).steadiness())
}
@Test
fun `a window too short to judge says nothing`() {
assertNull(verdict(monitor = cruise(110f, 30)).advice)
}
@Test
fun `the cruise lever wins when both are measurable`() {
val monitor = EcoDrivingMonitor()
var atMs = 0L
var speed = 100f
// Motorway, and driven unevenly: both levers have something to say and only one is said.
repeat(5) {
repeat(4) { speed += 8f; monitor.add(atMs, speed); atMs += 1_000L }
repeat(20) { monitor.add(atMs, speed); atMs += 1_000L }
speed = 100f
}
assertEquals(EcoLever.CRUISE_SPEED, verdict(monitor = monitor).advice?.lever)
}
@Test
fun `replaying a stored track is the same monitor the dashboard fed`() {
val samples = (0..120).map { index ->
TripSample(
atMs = index * 1_000L,
speedKmh = 110f,
batteryPowerKw = null,
socPercent = null,
outsideTempCelsius = 20f,
cabinTempCelsius = null,
batteryTempCelsius = null,
climatePowerOn = null,
climateAcOn = null,
climateFanLevel = null,
)
}
val replayed = EcoDrivingMonitor().replay(samples)
assertEquals(
verdict(monitor = cruise(110f, 120)).advice,
verdict(monitor = replayed).advice,
)
}
@Test
fun `describe names the refusal it made`() {
val lines = verdict(model = null).describe()
assertTrue(lines.any { it == "band=unavailable(MODEL_NOT_TRAINED)" })
assertTrue(lines.any { it == "observed_percent_per_100km=unavailable" })
assertTrue(lines.any { it == "advice=none" })
}
@Test
fun `describe states the verdict and its inputs`() {
val lines = verdict(monitor = cruise(110f, 120)).describe()
assertTrue(lines.any { it == "band=TYPICAL" })
assertTrue(lines.any { it == "observed_percent_per_100km=18.00" })
assertTrue(lines.any { it == "expected_percent_per_100km=18.00" })
assertNotNull(lines.firstOrNull { it.startsWith("advice=CRUISE_SPEED") })
}
}
@@ -0,0 +1,61 @@
package com.evsuite.hardware.telemetry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class EcoScoreTest {
private fun trip(
distanceKm: Double = 10.0,
brakings: Int? = 3,
harshMs: Long? = 60_000L,
movingMs: Long? = 1_200_000L,
) = EnergyTripSummary(
startedAtMs = 0L,
endedAtMs = 1_200_000L,
durationMs = 1_200_000L,
distanceKm = distanceKm,
startSocPercent = 80f,
endSocPercent = 78f,
consumedKwh = null,
regeneratedKwh = null,
distanceAvailable = true,
movingMs = movingMs,
harshMs = harshMs,
harshAccelerations = 2,
harshBrakings = brakings,
)
private fun verdict(delta: Double) = EcoVerdict(
band = com.evsuite.hardware.telemetry.Provenanced.estimated(EcoBand.TYPICAL, 1.0),
deltaPercent = delta,
)
@Test fun `a calm town drive at the usual consumption sits in the sixties, not near 100`() {
// 3 hard stops per 10 km, 5 % of moving time hard, consumption exactly as expected.
val score = EcoScore.of(trip(), verdict(0.0))!!
assertEquals(50, score.anticipation)
assertEquals(80, score.smoothness)
assertEquals(63, score.efficiency)
assertEquals(63, score.total)
}
@Test fun `a part that cannot be measured drops out instead of scoring zero or full`() {
val score = EcoScore.of(trip(), verdict = null)!!
assertNull(score.efficiency)
// (50 × 0.35 + 80 × 0.25) / 0.60
assertEquals(63, score.total)
}
@Test fun `a trip recorded before the counters existed has no score at all`() {
assertNull(EcoScore.of(trip(brakings = null, harshMs = null, movingMs = null), null))
}
@Test fun `scales clamp at both ends`() {
val best = EcoScore.of(trip(brakings = 0, harshMs = 0L), verdict(-40.0))!!
assertEquals(100, best.total)
val worst = EcoScore.of(trip(brakings = 20, harshMs = 600_000L), verdict(60.0))!!
assertEquals(0, worst.total)
}
}
@@ -31,6 +31,17 @@ class EnergyTelemetryTest {
assertEquals(18f, snapshot.batteryPowerKw)
}
@Test fun `one pedal reaches the track as the car's own code, and an unknown code stays unknown`() {
val signals = FakeSignals(80f, null, null).apply { mode = 9; regen = 6 }
val sample = TripSample.of(EnergyTelemetryReader(signals).read(1_000L))
assertEquals(6, sample.regenLevelRaw)
assertEquals(com.evsuite.hardware.model.RegenLevel.ONE_PEDAL,
com.evsuite.hardware.model.RegenLevel.ofRaw(sample.regenLevelRaw))
assertEquals(9, sample.driveModeRaw)
assertNull(com.evsuite.hardware.model.DriveMode.ofRaw(sample.driveModeRaw))
}
@Test fun `candidate probes exist only in the explicit evidence read`() {
val source = FakeSignals(soc = 80f, range = 200f, power = 18f)
source.probes = listOf(
@@ -70,6 +81,50 @@ class EnergyTelemetryTest {
assertEquals(0.001666, result.regeneratedKwh!!, 0.000001)
}
@Test fun `a trip started on the factory clock is dated from the corrected one`() {
val factory = 1_546_300_800_000L
val synced = 1_792_323_000_000L
val trip = EnergyTripAccumulator(factory, 80f)
trip.add(snapshot(factory, 30f, 5f))
trip.add(snapshot(factory + 1_000L, 30f, 5f))
trip.add(snapshot(synced, 30f, 5f))
trip.add(snapshot(synced + 1_000L, 30f, 5f))
val result = trip.snapshot(synced + 1_000L)
assertEquals(2_000L, result.durationMs)
assertEquals(synced - 1_000L, result.startedAtMs)
}
@Test fun `lifting off into regeneration is not a hard stop`() {
val trip = EnergyTripAccumulator(0L, 80f)
// 7 km/h a second is about 1.9 m/s², what regeneration on High does with no brake pedal.
listOf(50f, 43f, 36f, 29f, 22f, 15f).forEachIndexed { second, speed ->
trip.add(snapshot(second * 1_000L, speed, -10f))
}
val result = trip.snapshot(5_000L)
assertEquals(0, result.harshBrakings)
assertEquals(0L, result.harshMs)
}
@Test fun `a hard stretch counts once, braking and accelerating apart, at the live rate`() {
val trip = EnergyTripAccumulator(0L, 80f)
listOf(0f, 30f, 60f, 60f, 40f, 20f, 20f, 20f, 0f).forEachIndexed { second, speed ->
trip.add(
snapshot(second * 1_000L, speed, 5f)
.copy(driveModeRaw = if (second < 6) 4 else 2, regenLevelRaw = 6)
)
}
val result = trip.snapshot(8_000L)
assertEquals(1, result.harshAccelerations)
assertEquals(2, result.harshBrakings)
assertEquals(8_000L, result.movingMs)
assertEquals(5_000L, result.harshMs)
assertEquals(4, result.driveModeRaw)
assertEquals(6, result.regenLevelRaw)
}
@Test fun `trip keeps distance unknown without a usable speed interval`() {
val trip = EnergyTripAccumulator(0L, 80f)
trip.add(snapshot(0L, null, 18f))
@@ -124,6 +179,10 @@ class EnergyTelemetryTest {
private val power: Float?,
) : EnergySignalSource {
var probes: List<TelemetryEvidenceProbe> = emptyList()
var mode: Int? = null
var regen: Int? = null
override fun driveModeRaw() = mode
override fun regenLevelRaw() = regen
override fun firmware() = FirmwareInfo.Gen.SWI68
override fun socPercent() = soc
override fun rangeKm() = range
@@ -65,6 +65,20 @@ class TelemetryEvidenceTest {
assertEquals(1, parked.samples)
}
@Test fun `the widest window is a quantity, recorded as the snapshot carries it`() {
val recorder = TelemetryEvidenceRecorder()
recorder.record(snapshot(at = 0L, power = null).copy(widestWindowPercent = 40))
recorder.record(snapshot(at = 1_000L, power = null))
val windows = recorder.evidence()
.single { it.signal == TelemetryEvidenceRecorder.WINDOWS_WIDEST_PERCENT }
assertEquals("windows.widestPercent", windows.signal)
assertEquals(SignalKind.NUMERIC, windows.kind)
assertEquals(1, windows.samples)
assertEquals(1, windows.nulls)
assertEquals(40.0, windows.max!!, 0.0)
}
@Test fun `the update period follows value changes, not sampling ticks`() {
val recorder = TelemetryEvidenceRecorder()
// One second between reads, five seconds between the values the vehicle publishes.
@@ -30,11 +30,12 @@ class TripDetectorTest {
assertNull(movingAgain.event)
}
@Test fun `a genuine confirmed end closes the trip`() {
@Test fun `a genuine confirmed end closes the trip before the head unit powers off`() {
val detector = recordingDetector()
detector.add(snapshot(6_000L, 0f, parked = false))
val stopped = sustainStop(detector, 7_000L, 126_000L, parked = true)
assertNull(detector.add(snapshot(7_000L, 0f, parked = true)).event)
val stopped = detector.add(snapshot(8_000L, 0f, parked = true))
assertEquals(TripDetector.State.IDLE, stopped.state)
assertEquals(TripDetector.Event.STOP, stopped.event)
@@ -88,6 +88,25 @@ class TripHistoryStoreTest {
assertNull(restored.batteryTempCelsius)
assertNull(restored.climateAcOn)
assertNull(restored.climateFanLevel)
assertNull(restored.widestWindowPercent)
}
@Test fun `a sample stored before the window field reads it as unknown`() {
val directory = tempDirectory()
val target = File(directory, "trips.json")
val store = EnergyTripHistoryStore(target)
val sample = TripSample(
atMs = 10L, speedKmh = 42f, batteryPowerKw = null, socPercent = null,
outsideTempCelsius = null, cabinTempCelsius = null, batteryTempCelsius = null,
climatePowerOn = null, climateAcOn = null, climateFanLevel = null,
widestWindowPercent = 30,
)
assertTrue(store.append(summary(1L), listOf(sample)))
target.writeText(target.readText().replace(",\"widestWindowPercent\":30", ""))
val restored = EnergyTripHistoryStore(target).read().single().samples!!.single()
assertEquals(42f, restored.speedKmh)
assertNull(restored.widestWindowPercent)
}
@Test fun `battery power evidence survives a history round trip`() {
@@ -57,6 +57,26 @@ class EnergyAttributionTest {
assertEquals(0.0, result.reconciliationErrorKwh, 1e-9)
}
@Test fun `window-open intervals keep their own residual, out of the climate one`() {
val result = ready(
calculate(powerKw = 12f, climateOn = false, consumedKwh = 12.0, windowPercent = 40),
)
val windows = result.residuals.single()
assertEquals(ResidualContext.WINDOWS_OPEN, windows.context)
assertEquals(ResidualFinding.DISTINGUISHABLE, windows.finding)
assertEquals(2.0, windows.estimate.valueKwh, 1e-6)
assertEquals(0.0, result.reconciliationErrorKwh, 1e-9)
}
@Test fun `a closed window leaves the climate grouping as it was`() {
val result = ready(
calculate(powerKw = 12f, climateOn = true, consumedKwh = 12.0, windowPercent = 0),
)
assertEquals(ResidualContext.CLIMATE_ACTIVE, result.residuals.single().context)
}
@Test fun `missing model evidence stays unavailable`() {
val result = EnergyAttributionCalculator.calculate(trip(12f, true, 12.0), null)
@@ -66,10 +86,22 @@ class EnergyAttributionTest {
)
}
private fun calculate(powerKw: Float, climateOn: Boolean, consumedKwh: Double) =
EnergyAttributionCalculator.calculate(trip(powerKw, climateOn, consumedKwh), model)
private fun calculate(
powerKw: Float,
climateOn: Boolean,
consumedKwh: Double,
windowPercent: Int? = null,
) = EnergyAttributionCalculator.calculate(
trip(powerKw, climateOn, consumedKwh, windowPercent),
model,
)
private fun trip(powerKw: Float, climateOn: Boolean, consumedKwh: Double): StoredTrip {
private fun trip(
powerKw: Float,
climateOn: Boolean,
consumedKwh: Double,
windowPercent: Int? = null,
): StoredTrip {
val samples = (0..60).map { minute ->
TripSample(
atMs = minute * 60_000L,
@@ -82,6 +114,7 @@ class EnergyAttributionTest {
climatePowerOn = climateOn,
climateAcOn = climateOn,
climateFanLevel = if (climateOn) 4 else 0,
widestWindowPercent = windowPercent,
)
}
return StoredTrip(
@@ -91,12 +91,11 @@ class EnergyModelTest {
}
@Test fun `bounded month-sized fit completes inside host budget`() {
val base = syntheticTrip().samples.orEmpty()
val samples = List(EnergyModelTrainer.MAX_TRAINING_SAMPLES) { base[it % base.size] }
val trip = storedTrip(samples)
val trip = syntheticTrip()
val trips = List(EnergyModelTrainer.MAX_TRAINING_SAMPLES / SYNTHETIC_WINDOWS + 1) { trip }
lateinit var result: EnergyModelTrainingResult
val elapsedMs = measureTimeMillis { result = trainer.fit(listOf(trip), evidence) }
val elapsedMs = measureTimeMillis { result = trainer.fit(trips, evidence) }
assertNotNull((result as EnergyModelTrainingResult.Ready).model)
assertEquals(
@@ -106,13 +105,74 @@ class EnergyModelTest {
assertTrue("fit took ${elapsedMs}ms", elapsedMs < HOST_TRAINING_BUDGET_MS)
}
@Test fun `stop-and-go town driving trains once a dozen kilometres are recorded`() {
// Every sample of an acceleration costs several times what a cruise does, and a fit
// made per sample judged that spread as model error and refused the town for good.
// Two afternoons two degrees apart: no evidence about weather, which must not stop it.
val trips = listOf(24.0, 26.0).map { temp ->
storedTrip(urbanTrack(listOf(25.0, 35.0, 45.0, 55.0), temp))
}
val model = ready(trainer.fit(trips, evidence))
assertEquals(0.0, model.thermalKwhPer100KmPerDegree, 0.0)
assertTrue(model.envelope.minSpeedKmh < 30.0 && model.envelope.maxSpeedKmh > 50.0)
// Town consumption carries the accelerations: above the cruise-only truth, still sane.
val town = model.predict(40.0, 25.0).value!!
assertTrue("predicted $town", town in consumption(40.0, 25.0)..40.0)
}
/**
* Stop, accelerate for 15 s, cruise for a minute, brake for 15 s, repeated at each speed,
* sampled every 5 s as the recorder does. Acceleration pays the kinetic energy of a
* 1700 kg car at 90 % efficiency; braking returns 60 % of it.
*/
private fun urbanTrack(speeds: List<Double>, tempCelsius: Double): List<TripSample> {
val out = ArrayList<TripSample>()
var atMs = 0L
fun add(speedKmh: Double, powerKw: Double) {
out += TripSample(
atMs, speedKmh.toFloat(), powerKw.toFloat(), 80f, tempCelsius.toFloat(),
null, null, null, null, null,
)
atMs += 5_000L
}
for (cruise in speeds) {
val cruiseMs = cruise / 3.6
val accel = cruiseMs / 15.0
val cruisePower = consumption(cruise, tempCelsius) * cruise / 100.0
// Enough cycles for five kilometres at this speed.
val cycleKm = cruise * (60.0 + 15.0) / 3600.0
repeat(kotlin.math.ceil(5.0 / cycleKm).toInt()) {
repeat(4) { add(0.0, 1.0) }
for (step in 1..3) {
val v = cruiseMs * step / 3.0
add(v * 3.6, cruisePower * step / 3.0 + MASS_KG * accel * v / 900.0)
}
repeat(12) { add(cruise, cruisePower) }
for (step in 2 downTo 0) {
val v = cruiseMs * step / 3.0
add(v * 3.6, -0.6 * MASS_KG * accel * v / 1000.0)
}
}
}
return out
}
/** One steady kilometre per speed and temperature, each after a hole that ends a window. */
private fun syntheticTrip(): StoredTrip {
var atMs = 0L
val samples = buildList {
repeat(2) { repetition ->
for (temp in listOf(-5.0, 5.0, 15.0, 25.0, 35.0)) {
for (speed in 30..130 step 10) {
val shiftedSpeed = speed + repetition * 0.1
add(sample(shiftedSpeed, temp))
val intervals = kotlin.math.ceil(3_600.0 / (5.0 * shiftedSpeed)).toInt()
// One interval spare, so float rounding cannot leave the window short.
repeat(intervals + 2) {
add(sample(atMs, shiftedSpeed, temp))
atMs += 5_000L
}
atMs += 600_000L
}
}
}
@@ -139,10 +199,10 @@ class EnergyModelTest {
samples = samples,
)
private fun sample(speedKmh: Double, tempCelsius: Double): TripSample {
private fun sample(atMs: Long, speedKmh: Double, tempCelsius: Double): TripSample {
val powerKw = consumption(speedKmh, tempCelsius) * speedKmh / 100.0
return TripSample(
atMs = speedKmh.toLong(),
atMs = atMs,
speedKmh = speedKmh.toFloat(),
batteryPowerKw = powerKw.toFloat(),
socPercent = 80f,
@@ -164,5 +224,9 @@ class EnergyModelTest {
companion object {
private const val HOST_TRAINING_BUDGET_MS = 2_000L
private const val MASS_KG = 1_700.0
/** Two passes over five temperatures and eleven speeds. */
private const val SYNTHETIC_WINDOWS = 110
}
}
@@ -150,6 +150,24 @@ class SocConsumptionModelTest {
assertNull(model.predict(110.0, -5.0).value)
}
@Test
fun `weather that costs nothing falls back to two coefficients instead of refusing`() {
// The 10 °C drives cost less than the 20 °C ones, so a thermal term comes out negative.
val result = SocConsumptionFitter().fit(
listOf(
trip(drive(90.0, 20.0, 21.0, startAtMs = 0L)),
trip(drive(130.0, 20.0, 31.0, startAtMs = 100_000_000L)),
trip(drive(90.0, 10.0, 16.0, startAtMs = 200_000_000L)),
trip(drive(130.0, 10.0, 26.0, startAtMs = 300_000_000L)),
),
generation,
)
val model = (result as SocConsumptionFitResult.Ready).model
assertEquals(0.0, model.thermalPercentPer100KmPerDegree, 1e-12)
assertTrue(model.predict(130.0, 15.0).value!! > model.predict(90.0, 15.0).value!!)
assertNull(model.predict(110.0, -5.0).value)
}
@Test
fun `a hole in the track is not driven through`() {
// Two minutes of missing samples across which the car covered kilometres nobody
@@ -190,6 +208,24 @@ class SocConsumptionModelTest {
assertTrue(segments.isEmpty())
}
@Test
fun `a gauge in tenths trains on a town drive that a whole-percent gauge cannot`() {
// Nine kilometres at 40 km/h for 15 %/100 km: 1.35 % of charge, under two whole points.
val town = drive(40.0, 20.0, 15.0, distanceKm = 9.0)
fun gauge(step: Float) = town.map {
it.copy(socPercent = kotlin.math.round(it.socPercent!! / step) * step)
}
val tenths = ArrayList<SocConsumptionFitter.Segment>()
SocConsumptionFitter().collect(gauge(0.1f), tenths)
val whole = ArrayList<SocConsumptionFitter.Segment>()
SocConsumptionFitter().collect(gauge(1f), whole)
// Cut at two kilometres, the shortest segment a stretch may be, and still near 15.
assertEquals(4, tenths.size)
assertTrue("$tenths", tenths.all { it.distanceKm >= 2.0 && it.percentPer100Km in 10.0..20.0 })
assertTrue(whole.isEmpty())
}
@Test
fun `describe tells the two INSUFFICIENT_SAMPLES gates apart`() {
// The reason code is the same word for both, and the two ask the driver for opposite
@@ -221,4 +257,28 @@ class SocConsumptionModelTest {
assertTrue(skipped, skipped.contains("segments=0/"))
assertTrue(skipped, !skipped.contains("span="))
}
@Test
fun `describe says whether the solve past the gates produced a model`() {
// Past both gates the line used to stop, so a bundle could not tell a trained model
// from a solve that refused one.
val ready = SocConsumptionFitter().describe(history(), generation)
assertTrue(ready, ready.startsWith("used=4/4 segments="))
assertTrue(ready, Regex(""" fit=ready rmse=\d+\.\d{2}$""").containsMatchIn(ready))
// Faster costs less: a negative aero term, which the fit refuses by name.
val backwards = listOf(
trip(drive(90.0, 20.0, 26.0, distanceKm = 120.0, startAtMs = 0L)),
trip(drive(130.0, 20.0, 16.0, distanceKm = 120.0, startAtMs = 100_000_000L)),
)
assertEquals(
SocConsumptionFitResult.Unavailable(UnavailableReason.MODEL_NOT_TRAINED),
SocConsumptionFitter().fit(backwards, generation),
)
val refused = SocConsumptionFitter().describe(backwards, generation)
assertTrue(refused, refused.endsWith("span=40/20 km/h fit=refused(aero)"))
val strict = SocConsumptionFitter(maxResidualRmse = -1.0).describe(history(), generation)
assertTrue(strict, Regex(""" fit=refused\(rmse=\d+\.\d{2}\)$""").containsMatchIn(strict))
}
}