Skip to content

Commit f98995a

Browse files
malysclaude
andcommitted
feat(chargepilot): CP-078/CP-079 continuous trip and charge learning
Close the unstable loop: automatic trip retention feeds the trip model, battery ledger entries refresh charge-session analysis and health estimates without an activity restart, and stable/unstable default seams split for eco coach and charge learning. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent bbdb2d8 commit f98995a

18 files changed

Lines changed: 320 additions & 45 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,10 @@ Automatic detection is enabled by default. A foreground service owns the sampler
104104
and recording continue when the driver opens another app. On firmware where speed stays
105105
unavailable, ten consecutive misses suspend idle background polling; opening the dashboard
106106
performs a bounded retry. Active trips remain fail-closed and under manual control. The unstable
107-
build keeps automatically detected trips only above 5 km, concentrating the same bounded history
108-
budget on drives useful to the local consumption model. Manual recordings are always kept. A
107+
The unstable build keeps every automatically detected trip with a positive trustworthy
108+
distance. The shared store still caps history at 200 trips and 512 KiB, discarding oldest
109+
sample tracks before oldest summaries. Each successful save refreshes the local model.
110+
Manual recordings are always kept.
109111
trip's reported duration is the time actually covered by usable samples, not wall clock: a suspended
110112
sampler adds nothing to duration, distance or energy, so consumption averages compare values
111113
measured over the same interval.
@@ -154,6 +156,9 @@ self-update.
154156
## Configuration
155157

156158
Automatic trip detection is enabled by default. Its switch, and the manual start/stop action,
159+
160+
Unstable shows the live eco-advice line by default so a fresh install starts learning without a
161+
setup step. Stable keeps that line opt-in. Spoken advice remains opt-in in both channels.
157162
can be changed only while the vehicle reports zero speed. If speed is unavailable, the controls
158163
fail closed and the dashboard explains why.
159164

‎app/src/main/java/com/evsuite/chargepilot/BatteryHealthActivity.kt‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,13 @@ import android.os.Bundle
88
import android.os.IBinder
99
import android.view.View
1010
import com.evsuite.chargepilot.databinding.ActivityBatteryHealthBinding
11-
import com.evsuite.hardware.telemetry.BatteryExposure
1211
import com.evsuite.hardware.telemetry.BatteryExposureReport
13-
import com.evsuite.hardware.telemetry.BatteryLedgerStore
14-
import com.evsuite.hardware.telemetry.CalibrationDrift
1512
import com.evsuite.hardware.telemetry.CalibrationDriftReport
1613
import com.evsuite.hardware.telemetry.CalibrationVerdict
17-
import com.evsuite.hardware.telemetry.ChargeEnergyAnalyzer
1814
import com.evsuite.hardware.telemetry.ChargeEnergyReport
1915
import com.evsuite.hardware.telemetry.SohEnergySource
2016
import com.evsuite.hardware.telemetry.StateOfHealthEstimate
21-
import com.evsuite.hardware.telemetry.StateOfHealthEstimator
2217
import com.evsuite.hardware.telemetry.StateOfHealthResult
23-
import java.io.File
2418
import java.util.Locale
2519
import java.util.concurrent.Executors
2620
import kotlin.math.abs
@@ -49,6 +43,7 @@ class BatteryHealthActivity : PrimaryNavigationActivity() {
4943
}
5044

5145
private var recorder: TripRecordingService? = null
46+
private var loadedBatteryRevision = -1
5247
private var bound = false
5348
private var speedKmh: Float? = null
5449
private var speedObservedAtMs: Long? = null
@@ -66,9 +61,20 @@ class BatteryHealthActivity : PrimaryNavigationActivity() {
6661
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
6762
val value = (service as? TripRecordingService.LocalBinder)?.service ?: return
6863
recorder = value
64+
if (ChargeLearningDefaults.AUTO_REFRESH) {
65+
loadedBatteryRevision = value.batteryRevision
66+
load()
67+
}
6968
value.setListener(this@BatteryHealthActivity) { snapshot ->
7069
speedKmh = snapshot.speedKmh
7170
speedObservedAtMs = snapshot.timestampMs
71+
if (
72+
ChargeLearningDefaults.AUTO_REFRESH &&
73+
value.batteryRevision != loadedBatteryRevision
74+
) {
75+
loadedBatteryRevision = value.batteryRevision
76+
load()
77+
}
7278
renderApply()
7379
}
7480
speedKmh = value.latest?.speedKmh
@@ -120,24 +126,18 @@ class BatteryHealthActivity : PrimaryNavigationActivity() {
120126

121127
private fun load() {
122128
disk.execute {
123-
val entries = BatteryLedgerStore(File(filesDir, BatteryLedgerStore.FILE_NAME)).read()
124129
val settings = VehicleSettings.read(this)
125-
val estimate = StateOfHealthEstimator()
126-
.estimate(entries, settings.usableCapacityKwhWhenNew)
127-
val ready = (estimate as? StateOfHealthResult.Ready)?.estimate
128-
val report = CalibrationDrift().analyse(entries, System.currentTimeMillis(), ready)
129-
val exposureReport = BatteryExposure().analyse(entries)
130-
// The sessions come from the exposure report rather than being cut again here, so
131-
// the two blocks on this screen can never disagree about what counts as a charge.
132-
val chargeReport = ChargeEnergyAnalyzer()
133-
.analyse(entries, exposureReport?.sessions.orEmpty())
130+
val learned = LocalBatteryLearning.refresh(
131+
filesDir,
132+
settings.usableCapacityKwhWhenNew,
133+
)
134134
runOnUiThread {
135135
if (isFinishing || isDestroyed) return@runOnUiThread
136136
declared = settings
137-
health = estimate
138-
drift = report
139-
exposure = exposureReport
140-
charge = chargeReport
137+
health = learned.health
138+
drift = learned.drift
139+
exposure = learned.exposure
140+
charge = learned.charge
141141
render()
142142
}
143143
}

‎app/src/main/java/com/evsuite/chargepilot/EcoCoach.kt‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@ import com.evsuite.hardware.telemetry.EcoDrivingMonitor
99
import com.evsuite.hardware.telemetry.EcoLever
1010
import com.evsuite.hardware.telemetry.EcoVerdict
1111
import com.evsuite.hardware.telemetry.EnergySnapshot
12-
import com.evsuite.hardware.telemetry.EnergyTripHistoryStore
1312
import com.evsuite.hardware.telemetry.EnergyTripSession
1413
import com.evsuite.hardware.telemetry.Provenanced
14+
import com.evsuite.hardware.telemetry.StoredTrip
1515
import com.evsuite.hardware.telemetry.UnavailableReason
1616
import com.evsuite.hardware.telemetry.model.SocConsumptionFitResult
1717
import com.evsuite.hardware.telemetry.model.SocConsumptionFitter
1818
import com.evsuite.hardware.telemetry.model.SocConsumptionModel
19-
import java.io.File
2019
import kotlin.math.roundToInt
2120

2221
/**
@@ -53,11 +52,8 @@ class EcoCoach(context: Context) {
5352
private var spokenLine: String? = null
5453
private var lastSpokeAtMs = 0L
5554

56-
/** Reads the trip history and fits CP-052's model. Call on a worker thread. */
57-
fun load() {
58-
val trips = runCatching {
59-
EnergyTripHistoryStore(File(app.filesDir, HISTORY_FILE)).read()
60-
}.getOrDefault(emptyList())
55+
/** Fits CP-052's model from the current bounded history. Call on a worker thread. */
56+
fun load(trips: List<StoredTrip>) {
6157
val fit = SocConsumptionFitter().fit(trips, FirmwareInfo.getGeneration())
6258
model = (fit as? SocConsumptionFitResult.Ready)?.model
6359
}
@@ -198,15 +194,14 @@ class EcoCoach(context: Context) {
198194
private const val SPEAK_COOLDOWN_MS = 120_000L
199195

200196
private const val UTTERANCE_ID = "chargepilot-eco"
201-
private const val HISTORY_FILE = "trips.json"
202197
private const val DASH = "—"
203198

204199
private const val PREFERENCES = "eco_coach"
205200
private const val PREF_ADVICE = "advice_enabled"
206201
private const val PREF_VOICE = "voice_enabled"
207202

208-
/** Off until asked for: a driver who never opens settings gets the tile and silence. */
209-
fun adviceEnabled(context: Context): Boolean = prefs(context).getBoolean(PREF_ADVICE, false)
203+
fun adviceEnabled(context: Context): Boolean =
204+
prefs(context).getBoolean(PREF_ADVICE, EcoCoachDefaults.ADVICE_ENABLED)
210205

211206
fun voiceEnabled(context: Context): Boolean = prefs(context).getBoolean(PREF_VOICE, false)
212207

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.evsuite.chargepilot
2+
3+
import com.evsuite.hardware.telemetry.BatteryExposure
4+
import com.evsuite.hardware.telemetry.BatteryExposureReport
5+
import com.evsuite.hardware.telemetry.BatteryLedgerStore
6+
import com.evsuite.hardware.telemetry.CalibrationDrift
7+
import com.evsuite.hardware.telemetry.CalibrationDriftReport
8+
import com.evsuite.hardware.telemetry.ChargeEnergyAnalyzer
9+
import com.evsuite.hardware.telemetry.ChargeEnergyReport
10+
import com.evsuite.hardware.telemetry.StateOfHealthEstimator
11+
import com.evsuite.hardware.telemetry.StateOfHealthResult
12+
import java.io.File
13+
14+
/** Existing battery analyses evaluated together from one bounded ledger snapshot. */
15+
internal data class BatteryLearningSnapshot(
16+
val entryCount: Int,
17+
val lastEntryAtMs: Long?,
18+
val health: StateOfHealthResult,
19+
val drift: CalibrationDriftReport?,
20+
val exposure: BatteryExposureReport?,
21+
val charge: ChargeEnergyReport,
22+
)
23+
24+
internal object LocalBatteryLearning {
25+
@Volatile
26+
var latest: BatteryLearningSnapshot? = null
27+
private set
28+
29+
/** Reads and analyses the ledger. Call only from a worker thread. */
30+
@Synchronized
31+
fun refresh(
32+
filesDir: File,
33+
usableCapacityKwhWhenNew: Double,
34+
nowMs: Long = System.currentTimeMillis(),
35+
): BatteryLearningSnapshot {
36+
val entries = BatteryLedgerStore(File(filesDir, BatteryLedgerStore.FILE_NAME)).read()
37+
val health = StateOfHealthEstimator().estimate(entries, usableCapacityKwhWhenNew)
38+
val ready = (health as? StateOfHealthResult.Ready)?.estimate
39+
val exposure = BatteryExposure().analyse(entries)
40+
return BatteryLearningSnapshot(
41+
entryCount = entries.size,
42+
lastEntryAtMs = entries.lastOrNull()?.atMs,
43+
health = health,
44+
drift = CalibrationDrift().analyse(entries, nowMs, ready),
45+
exposure = exposure,
46+
charge = ChargeEnergyAnalyzer().analyse(entries, exposure?.sessions.orEmpty()),
47+
).also { latest = it }
48+
}
49+
}

‎app/src/main/java/com/evsuite/chargepilot/LocalEnergyModel.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ object LocalEnergyModel {
1717
): EnergyModel? {
1818
val store = EnergyModelStore(File(filesDir, MODEL_FILE))
1919
val stored = store.read()?.takeIf { it.evidence == evidence }
20-
if (stored != null || evidence == null) return stored
20+
if (evidence == null) return stored
2121
val trained = EnergyModelTrainer().train(trips, evidence.firmware)
22-
if (trained !is EnergyModelTrainingResult.Ready) return null
22+
if (trained !is EnergyModelTrainingResult.Ready) return stored
2323
store.write(trained.model)
2424
return trained.model
2525
}

‎app/src/main/java/com/evsuite/chargepilot/MainActivity.kt‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class MainActivity : PrimaryNavigationActivity() {
6363

6464
/** The recorder owns the sampler; this screen is one of its readers. */
6565
private var recorder: TripRecordingService? = null
66+
private var loadedHistoryRevision = -1
6667
/** Exact normalized service frame; the parked-only actions re-read it rather than the UI. */
6768
@Volatile private var latestSnapshot: EnergySnapshot? = null
6869

@@ -182,7 +183,6 @@ class MainActivity : PrimaryNavigationActivity() {
182183
// The consumption fit reads the whole trip history, so it happens once per visit to
183184
// this screen and only when there is actually a plan being followed.
184185
background.execute { runCatching { drift.load() } }
185-
background.execute { runCatching { eco.load() } }
186186
bindService(
187187
Intent(this, TripRecordingService::class.java), connection, Context.BIND_AUTO_CREATE
188188
)
@@ -209,7 +209,10 @@ class MainActivity : PrimaryNavigationActivity() {
209209
AppLogger.w(TAG, "trip stop ignored: recorder not bound")
210210
return
211211
}
212-
service.stopTrip(::loadRecentTrips)
212+
service.stopTrip {
213+
loadedHistoryRevision = service.historyRevision
214+
loadRecentTrips()
215+
}
213216
// The next drive is judged on its own window, not on the one that just ended.
214217
eco.reset()
215218
service.latest?.let(::render)
@@ -220,6 +223,15 @@ class MainActivity : PrimaryNavigationActivity() {
220223

221224
private fun render(value: EnergySnapshot) {
222225
latestSnapshot = value
226+
recorder?.historyRevision?.let { revision ->
227+
if (loadedHistoryRevision < 0) {
228+
loadedHistoryRevision = revision
229+
} else if (revision != loadedHistoryRevision) {
230+
loadedHistoryRevision = revision
231+
eco.reset()
232+
loadRecentTrips()
233+
}
234+
}
223235
// The calculators are fed the reading as it arrived, unvalidated firmware included.
224236
// A power figure the vehicle publishes is still a power figure; what an unproven
225237
// property costs it is its provenance, not its existence, and DashboardReadings
@@ -592,7 +604,9 @@ class MainActivity : PrimaryNavigationActivity() {
592604
private fun loadRecentTrips() {
593605
runCatching {
594606
background.execute {
595-
val summaries = EnergyTripHistoryStore(File(filesDir, HISTORY_FILE)).readSummaries()
607+
val trips = EnergyTripHistoryStore(File(filesDir, HISTORY_FILE)).read()
608+
eco.load(trips)
609+
val summaries = trips.map { it.summary }
596610
runOnUiThread {
597611
if (isFinishing || isDestroyed) return@runOnUiThread
598612
recentTrips = summaries

‎app/src/main/java/com/evsuite/chargepilot/TripRecordingService.kt‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ class TripRecordingService : Service() {
6767
private lateinit var batteryLedger: BatteryLedgerRecorder
6868
private val detector = TripDetector()
6969
private val pendingHistoryWrites = AtomicInteger()
70+
private val historyRevisionCounter = AtomicInteger()
71+
private val batteryRevisionCounter = AtomicInteger()
7072
private val missingSpeedSamples = AtomicInteger()
7173

7274
private var samplingTask: ScheduledFuture<*>? = null
@@ -88,6 +90,8 @@ class TripRecordingService : Service() {
8890
val missingSpeedSampleCount: Int get() = missingSpeedSamples.get()
8991
val boundClientCount: Int get() = boundClients
9092
val pendingHistoryWriteCount: Int get() = pendingHistoryWrites.get()
93+
val historyRevision: Int get() = historyRevisionCounter.get()
94+
val batteryRevision: Int get() = batteryRevisionCounter.get()
9195

9296
override fun onCreate() {
9397
super.onCreate()
@@ -258,6 +262,16 @@ class TripRecordingService : Service() {
258262
}.getOrDefault(false)
259263
if (!saved) AppLogger.w(TAG, "trip history could not be saved")
260264
if (saved) {
265+
runCatching {
266+
val trips = tripStore.read()
267+
val evidence = trips.firstNotNullOfOrNull {
268+
it.summary.batteryPowerEvidence
269+
}
270+
LocalEnergyModel.loadOrTrain(filesDir, trips, evidence)
271+
}.onFailure {
272+
AppLogger.w(TAG, "local energy model refresh failed: ${it.message}")
273+
}
274+
historyRevisionCounter.incrementAndGet()
261275
AppLogger.i(
262276
TAG,
263277
"trip recording saved; started_epoch_ms=${recorded.summary.startedAtMs}; " +
@@ -310,8 +324,24 @@ class TripRecordingService : Service() {
310324
// charge, a change of charging state, or a quarter of an hour — so this costs one
311325
// comparison on all the other samples. It runs on the sampler thread, which is the
312326
// thread that already owns this file's neighbours.
313-
runCatching { batteryLedger.observe(value) }
327+
val batteryEntryWritten = runCatching { batteryLedger.observe(value) }
314328
.onFailure { AppLogger.w(TAG, "battery ledger write failed: ${it.message}") }
329+
.getOrDefault(false)
330+
if (batteryEntryWritten) {
331+
if (ChargeLearningDefaults.AUTO_REFRESH) {
332+
runCatching {
333+
val settings = VehicleSettings.read(this)
334+
LocalBatteryLearning.refresh(
335+
filesDir,
336+
settings.usableCapacityKwhWhenNew,
337+
value.timestampMs,
338+
)
339+
}.onFailure {
340+
AppLogger.w(TAG, "battery learning refresh failed: ${it.message}")
341+
}
342+
}
343+
batteryRevisionCounter.incrementAndGet()
344+
}
315345
if (automaticDetectionEnabled) {
316346
updateAutomaticMonitorAvailability(value)
317347
val previousState = detector.state
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.evsuite.chargepilot
2+
3+
/** Stable keeps existing on-open battery analysis. */
4+
internal object ChargeLearningDefaults {
5+
const val AUTO_REFRESH = false
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.evsuite.chargepilot
2+
3+
/** Stable preserves CP-074's explicit opt-in. */
4+
internal object EcoCoachDefaults {
5+
const val ADVICE_ENABLED = false
6+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.evsuite.chargepilot
2+
3+
import com.evsuite.hardware.telemetry.BatteryLedgerEntry
4+
import com.evsuite.hardware.telemetry.BatteryLedgerStore
5+
import java.nio.file.Files
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertSame
8+
import org.junit.Test
9+
10+
class LocalBatteryLearningTest {
11+
@Test
12+
fun `growing ledger refreshes derived charge sessions`() {
13+
val directory = Files.createTempDirectory("chargepilot-battery-learning").toFile()
14+
try {
15+
val store = BatteryLedgerStore(
16+
directory.resolve(BatteryLedgerStore.FILE_NAME)
17+
)
18+
store.append(entry(0L, 40f))
19+
store.append(entry(HOUR, 50f))
20+
21+
val first = LocalBatteryLearning.refresh(directory, 61.7, nowMs = HOUR)
22+
assertEquals(2, first.entryCount)
23+
assertEquals(1, first.charge.charges.size)
24+
25+
store.append(entry(2 * HOUR, 45f))
26+
store.append(entry(3 * HOUR, 55f))
27+
28+
val refreshed = LocalBatteryLearning.refresh(directory, 61.7, nowMs = 3 * HOUR)
29+
assertEquals(4, refreshed.entryCount)
30+
assertEquals(2, refreshed.charge.charges.size)
31+
assertSame(refreshed, LocalBatteryLearning.latest)
32+
} finally {
33+
directory.deleteRecursively()
34+
}
35+
}
36+
37+
private fun entry(atMs: Long, socPercent: Float) = BatteryLedgerEntry(
38+
atMs = atMs,
39+
socPercent = socPercent,
40+
odometerKm = 10_000f,
41+
outsideTempCelsius = 20f,
42+
chargingStatus = 1,
43+
chargePortConnected = true,
44+
parked = true,
45+
)
46+
47+
private companion object {
48+
const val HOUR = 3_600_000L
49+
}
50+
}

0 commit comments

Comments
 (0)