Skip to content

Commit 2f9a7ff

Browse files
malysclaude
andcommitted
feat(model): fit consumption from state of charge, in percent
CP-052. SWI68 publishes no battery power — three bundles, 51 samples, zero readings, already recorded in CarPropertyEvidence — so the kWh model can never train on this car and the screens built on it are permanently blank. That file already drew the conclusion: a forecast here comes from state of charge. The unit is the decision. Turning a state of charge into kWh needs a pack capacity, and the only capacity available is a specification sheet — an unmeasured multiplier in front of every number the model would produce. Fitting in percent per 100 km removes it: charge is published by the car, distance is the speed integral the app already gates on VehicleSpeedEvidence, and every consumer downstream already thinks in percent. Nothing is converted, so nothing has to be assumed. Training points are segments, not samples. A state of charge moves in steps of about a percent, which is four kilometres of motorway, so a per-sample reading is quantisation and a fit on it would fit the quantiser. A segment closes when the charge has actually moved by two points. A gap in the track ends the segment instead of being bridged, because the kilometres nobody recorded would otherwise be charged to the ones on either side. A history with one temperature in it fits two coefficients and keeps the narrow envelope, so it refuses the cold rather than inventing a thermal term. The solver is now shared with the kWh trainer rather than copied, and the envelope tolerates a micro-unit at its edges: refusing to predict the exact speed a fit was trained on, because a mean landed on 90.000000000000011, is a floating-point artifact and not a statement about what the model knows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0c1e095 commit 2f9a7ff

6 files changed

Lines changed: 613 additions & 58 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ All notable changes to this project are documented here. Format follows
88

99
### Added
1010

11+
- **A consumption model for a car that publishes no battery power — which is this car.**
12+
`CarPropertyEvidence` has recorded since 2026-09-04 that SWI68 never publishes battery power:
13+
three diagnostic bundles, 51 samples, zero readings. The consequence was that the kWh model
14+
could never train on it, and three finished screens stayed blank. `SocConsumptionModel` fits
15+
the same shape — rolling plus aero times speed squared plus a thermal term — in **percent of
16+
charge per 100 km**, from the state of charge the car does publish and the distance the speed
17+
integral already gives. No pack capacity enters it, which matters because the capacity here is
18+
a specification sheet rather than a measurement: fitting in percent removes an unmeasured
19+
multiplier from every number instead of carrying it. Training points are whole segments of
20+
road, accumulated until the charge has moved by two points, because a state of charge read
21+
sample by sample is quantisation and a fit built on it would be fitting the quantiser. A hole
22+
in the track ends a segment rather than being driven through, a distance recorded under a
23+
conversion no longer believed contributes nothing, and a history that has only seen one
24+
temperature fits two coefficients instead of inventing a third.
25+
1126
- **The col between here and there costs charge, and the forecast knows it.** CP-031 refused a
1227
grade source on good grounds — no vehicle signal, no proven barometric reference, and
1328
power-derived grade is circular — but every candidate it weighed described the road *behind*

‎lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModel.kt‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,26 @@ data class EnergyModelEnvelope(
1616
) {
1717
fun contains(speedKmh: Double, outsideTempCelsius: Double): Boolean =
1818
speedKmh.isFinite() && outsideTempCelsius.isFinite() &&
19-
speedKmh in minSpeedKmh..maxSpeedKmh &&
20-
outsideTempCelsius in minOutsideTempCelsius..maxOutsideTempCelsius
19+
speedKmh in (minSpeedKmh - EDGE_TOLERANCE)..(maxSpeedKmh + EDGE_TOLERANCE) &&
20+
outsideTempCelsius in
21+
(minOutsideTempCelsius - EDGE_TOLERANCE)..(maxOutsideTempCelsius + EDGE_TOLERANCE)
2122

2223
internal fun isValid(): Boolean =
2324
minSpeedKmh.isFinite() && maxSpeedKmh.isFinite() && minSpeedKmh > 0.0 &&
2425
minSpeedKmh <= maxSpeedKmh &&
2526
minOutsideTempCelsius.isFinite() && maxOutsideTempCelsius.isFinite() &&
2627
minOutsideTempCelsius <= maxOutsideTempCelsius
28+
29+
private companion object {
30+
/**
31+
* The bounds are means of measured intervals, so an edge lands on 90.000000000000011
32+
* rather than on 90. Refusing to predict the very speed the fit was trained on, by a
33+
* ten-thousandth of a millionth of a km/h, is a floating-point artifact and not a
34+
* statement about what the model knows. A micro-degree and a micro-km/h are nothing
35+
* physically; anything wider would be a claim.
36+
*/
37+
const val EDGE_TOLERANCE = 1e-6
38+
}
2739
}
2840

2941
/**

‎lib/src/main/java/com/evsuite/hardware/telemetry/model/EnergyModelTrainer.kt‎

Lines changed: 1 addition & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class EnergyModelTrainer(
4040
trips: List<StoredTrip>,
4141
evidence: BatteryPowerEvidence,
4242
): EnergyModelTrainingResult {
43-
val equations = NormalEquations()
43+
val equations = LeastSquares3()
4444
var minSpeed = Double.POSITIVE_INFINITY
4545
var maxSpeed = Double.NEGATIVE_INFINITY
4646
var minTemp = Double.POSITIVE_INFINITY
@@ -124,61 +124,6 @@ class EnergyModelTrainer(
124124
return accepted
125125
}
126126

127-
private class NormalEquations {
128-
private val matrix = Array(FEATURES) { DoubleArray(FEATURES) }
129-
private val vector = DoubleArray(FEATURES)
130-
131-
fun add(speedKmh: Double, outsideTempCelsius: Double, consumption: Double) {
132-
val speedSquared = speedKmh * speedKmh
133-
val temperatureOffset = abs(outsideTempCelsius - EnergyModel.COMFORT_TEMP_CELSIUS)
134-
for (row in 0 until FEATURES) {
135-
val rowValue = feature(row, speedSquared, temperatureOffset)
136-
vector[row] += rowValue * consumption
137-
for (column in 0 until FEATURES) {
138-
matrix[row][column] += rowValue *
139-
feature(column, speedSquared, temperatureOffset)
140-
}
141-
}
142-
}
143-
144-
private fun feature(index: Int, speedSquared: Double, temperatureOffset: Double) =
145-
when (index) {
146-
0 -> 1.0
147-
1 -> speedSquared
148-
else -> temperatureOffset
149-
}
150-
151-
fun solve(): DoubleArray? {
152-
val augmented = Array(FEATURES) { row ->
153-
DoubleArray(FEATURES + 1) { column ->
154-
if (column == FEATURES) vector[row] else matrix[row][column]
155-
}
156-
}
157-
for (column in 0 until FEATURES) {
158-
val pivot = (column until FEATURES).maxBy { abs(augmented[it][column]) }
159-
if (abs(augmented[pivot][column]) < MIN_PIVOT) return null
160-
val swap = augmented[column]
161-
augmented[column] = augmented[pivot]
162-
augmented[pivot] = swap
163-
val divisor = augmented[column][column]
164-
for (index in column..FEATURES) augmented[column][index] /= divisor
165-
for (row in 0 until FEATURES) {
166-
if (row == column) continue
167-
val factor = augmented[row][column]
168-
for (index in column..FEATURES) {
169-
augmented[row][index] -= factor * augmented[column][index]
170-
}
171-
}
172-
}
173-
return DoubleArray(FEATURES) { augmented[it][FEATURES] }
174-
}
175-
176-
companion object {
177-
private const val FEATURES = 3
178-
private const val MIN_PIVOT = 1e-9
179-
}
180-
}
181-
182127
companion object {
183128
const val MIN_TRAINING_SAMPLES = 60
184129
const val MAX_TRAINING_SAMPLES = 50_000
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.evsuite.hardware.telemetry.model
2+
3+
import kotlin.math.abs
4+
5+
/**
6+
* The three-coefficient normal-equations fit both consumption models are built on.
7+
*
8+
* `constant + a × speed² + b × |outside − comfort|` is the same shape whether the left-hand
9+
* side is kWh per 100 km or percent of charge per 100 km, and the arithmetic that solves it
10+
* has no opinion about units. It lives here so there is one of it: a second copy would drift
11+
* from this one on the day someone fixes a pivot and only remembers one file.
12+
*
13+
* O(1) working memory, O(n) in samples. Call from a worker thread.
14+
*/
15+
internal class LeastSquares3(private val features: Int = FEATURES) {
16+
private val matrix = Array(features) { DoubleArray(features) }
17+
private val vector = DoubleArray(features)
18+
19+
init {
20+
require(features in 2..FEATURES) { "two or three features" }
21+
}
22+
23+
fun add(speedKmh: Double, outsideTempCelsius: Double, consumption: Double) {
24+
val speedSquared = speedKmh * speedKmh
25+
val temperatureOffset = abs(outsideTempCelsius - EnergyModel.COMFORT_TEMP_CELSIUS)
26+
for (row in 0 until features) {
27+
val rowValue = feature(row, speedSquared, temperatureOffset)
28+
vector[row] += rowValue * consumption
29+
for (column in 0 until features) {
30+
matrix[row][column] += rowValue *
31+
feature(column, speedSquared, temperatureOffset)
32+
}
33+
}
34+
}
35+
36+
private fun feature(index: Int, speedSquared: Double, temperatureOffset: Double) =
37+
when (index) {
38+
0 -> 1.0
39+
1 -> speedSquared
40+
else -> temperatureOffset
41+
}
42+
43+
/**
44+
* The coefficients, or null when the points do not determine them.
45+
*
46+
* Two features is not a lesser fit, it is a different claim: a driver who has only ever
47+
* recorded trips at one outside temperature has no evidence about temperature, and a
48+
* thermal coefficient invented from that would be read as knowledge. The envelope keeps
49+
* the narrow range, so such a model refuses the cold rather than guessing at it.
50+
*/
51+
fun solve(): DoubleArray? {
52+
val augmented = Array(features) { row ->
53+
DoubleArray(features + 1) { column ->
54+
if (column == features) vector[row] else matrix[row][column]
55+
}
56+
}
57+
for (column in 0 until features) {
58+
val pivot = (column until features).maxBy { abs(augmented[it][column]) }
59+
if (abs(augmented[pivot][column]) < MIN_PIVOT) return null
60+
val swap = augmented[column]
61+
augmented[column] = augmented[pivot]
62+
augmented[pivot] = swap
63+
val divisor = augmented[column][column]
64+
for (index in column..features) augmented[column][index] /= divisor
65+
for (row in 0 until features) {
66+
if (row == column) continue
67+
val factor = augmented[row][column]
68+
for (index in column..features) {
69+
augmented[row][index] -= factor * augmented[column][index]
70+
}
71+
}
72+
}
73+
return DoubleArray(features) { augmented[it][features] }
74+
}
75+
76+
private companion object {
77+
const val FEATURES = 3
78+
const val MIN_PIVOT = 1e-9
79+
}
80+
}

0 commit comments

Comments
 (0)