Skip to content

Commit 35ac454

Browse files
malysclaude
andcommitted
fix(chargepilot): consumption models learn from town driving
Bumps EVHardware to the per-kilometre power fit and the gauge-step charge segmentation. The local model test now builds steady kilometres, since sampleCount counts kilometre windows rather than 5 s samples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d87b91e commit 35ac454

3 files changed

Lines changed: 38 additions & 25 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
4949

5050
### Fixed
5151

52+
- **The consumption models learn from town driving.** EVHardware now fits the power model on
53+
one point per kilometre instead of per 5 s sample, so stop-and-go trips train it, and the
54+
charge model cuts segments at the SWI68 gauge's 0.1 % step instead of waiting for 2 %. Twelve
55+
kilometres at two speeds are enough for the speed comparison and the energy breakdown to
56+
answer from a model.
5257
- **Finished-trip eco advice no longer drops persisted samples.** Trip tracks keep the first
5358
telemetry frame after each five-second boundary, commonly around 5.3 seconds apart. Replay used
5459
the stricter five-second live gap and discarded those intervals; it now uses the stored-track

‎app/src/test/java/com/evsuite/chargepilot/LocalEnergyModelTest.kt‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.evsuite.hardware.telemetry.StoredTrip
77
import com.evsuite.hardware.telemetry.TripSample
88
import java.nio.file.Files
99
import kotlin.math.abs
10+
import kotlin.math.ceil
1011
import org.junit.Assert.assertEquals
1112
import org.junit.Test
1213

@@ -19,56 +20,63 @@ class LocalEnergyModelTest {
1920
fun `new bounded history replaces model and failed fit keeps last good model`() {
2021
val directory = Files.createTempDirectory("chargepilot-local-model").toFile()
2122
try {
22-
val firstTrip = trip(startedAtMs = 1L, sampleCount = 60)
23+
val firstTrip = trip(startedAtMs = 1L, kilometres = 12)
2324
val first = checkNotNull(
2425
LocalEnergyModel.loadOrTrain(directory, listOf(firstTrip), evidence)
2526
)
26-
assertEquals(60, first.sampleCount)
27+
// One sample per kilometre window: twelve is the least a model is trained from.
28+
assertEquals(12, first.sampleCount)
2729

28-
val secondTrip = trip(startedAtMs = 2L, sampleCount = 80)
30+
val secondTrip = trip(startedAtMs = 2L, kilometres = 18)
2931
val refreshed = checkNotNull(
3032
LocalEnergyModel.loadOrTrain(
3133
directory,
3234
listOf(secondTrip, firstTrip),
3335
evidence,
3436
)
3537
)
36-
assertEquals(140, refreshed.sampleCount)
38+
assertEquals(30, refreshed.sampleCount)
3739

3840
val fallback = checkNotNull(
3941
LocalEnergyModel.loadOrTrain(directory, emptyList(), evidence)
4042
)
41-
assertEquals(140, fallback.sampleCount)
43+
assertEquals(30, fallback.sampleCount)
4244
} finally {
4345
directory.deleteRecursively()
4446
}
4547
}
4648

47-
private fun trip(startedAtMs: Long, sampleCount: Int): StoredTrip {
48-
val samples = List(sampleCount) { index ->
49-
val speed = 30.0 + index % 6 * 10.0
50-
val temperature = if ((index / 6) % 2 == 0) 10.0 else 20.0
49+
/** [kilometres] steady kilometres, six speeds and two temperatures, a hole after each. */
50+
private fun trip(startedAtMs: Long, kilometres: Int): StoredTrip {
51+
var atMs = startedAtMs
52+
val samples = (0 until kilometres).flatMap { km ->
53+
val speed = 30.0 + km % 6 * 10.0
54+
val temperature = if ((km / 6) % 2 == 0) 10.0 else 20.0
5155
val consumption = 10.0 + 0.0005 * speed * speed +
5256
0.1 * abs(temperature - 20.0)
53-
TripSample(
54-
atMs = startedAtMs + index * 5_000L,
55-
speedKmh = speed.toFloat(),
56-
batteryPowerKw = (consumption * speed / 100.0).toFloat(),
57-
socPercent = null,
58-
outsideTempCelsius = temperature.toFloat(),
59-
cabinTempCelsius = null,
60-
batteryTempCelsius = null,
61-
climatePowerOn = null,
62-
climateAcOn = null,
63-
climateFanLevel = null,
64-
)
57+
// One interval past the kilometre, so rounding cannot leave the window short.
58+
val count = ceil(3_600.0 / (5.0 * speed)).toInt() + 2
59+
List(count) { index ->
60+
TripSample(
61+
atMs = atMs + index * 5_000L,
62+
speedKmh = speed.toFloat(),
63+
batteryPowerKw = (consumption * speed / 100.0).toFloat(),
64+
socPercent = null,
65+
outsideTempCelsius = temperature.toFloat(),
66+
cabinTempCelsius = null,
67+
batteryTempCelsius = null,
68+
climatePowerOn = null,
69+
climateAcOn = null,
70+
climateFanLevel = null,
71+
)
72+
}.also { atMs += count * 5_000L + 600_000L }
6573
}
6674
return StoredTrip(
6775
summary = EnergyTripSummary(
6876
startedAtMs = startedAtMs,
69-
endedAtMs = startedAtMs + sampleCount * 5_000L,
70-
durationMs = sampleCount * 5_000L,
71-
distanceKm = 10.0,
77+
endedAtMs = atMs,
78+
durationMs = atMs - startedAtMs,
79+
distanceKm = kilometres.toDouble(),
7280
startSocPercent = 80f,
7381
endSocPercent = 78f,
7482
consumedKwh = 2.0,

0 commit comments

Comments
 (0)