Skip to content

Commit b2b5984

Browse files
malysclaude
andcommitted
feat(validation): arm every open question before the drive, not after it
CP-055. Eleven tickets end "verified on the JVM, never on the car", and each of them names a different question. Answered one at a time, each costs a drive, a parking space and an evening; the owner has one vehicle and one life. So the questions get answered together, and the only way that works is if everything is armed before departure and records itself. Nothing here can be operated while driving, and that is a requirement rather than a convenience: an instrument that needs a tap at 130 km/h is a hazard, so it was not built. The recording sits at the decisions themselves — the permission result, the position or its absence, the routing response, its sections, the charger search including each way out of it, the what-if rows or its refusal, and the consumption fit — because that is where the truth is, and a screen that has to be visited is a screen someone forgets. One artifact, eight named blocks, against the letter of the ticket's scope. The exporter bundles at most eight evidence files and keeps the newest; eight validation files would evict the guidance trace, the signal statistics and the trip record from the very bundle they are meant to travel in. Every question keeps its own block, and they share a file. An empty block is an answer. `ValidationQuestion` carries what would have made each probe fire, so a question that recorded nothing says so and says why — "no chargers came back" and "the search never ran" close different tickets, and a blank would not tell them apart. The file is written even when the toggle was off, because "it was off" explains every empty block and a missing file explains nothing. Question 2 can only be read at the start of a process: once any screen has asked for location, "was it granted without a prompt" is no longer observable. So arming happens in `ChargePilotApp`, before anything else, and the checklist says to arm and then restart. Stable is a no-op whose `record` never invokes its lambda, so a stable build does not even construct the strings. Binder calls run on their own thread: a head unit that answers slowly is a finding, one that hangs the UI thread of a moving car is not. What leaves the car is stated in the artifact's own notes. No API key, no typed destination, no origin coordinate. It does carry the names of roads on the route driven and the raw bytes of the navigation transactions CP-051 asks about, which may hold the destination set in the car's own navigation — so the checklist says to pick a destination worth sharing. The alternative was a probe that cannot answer the question it exists for. `analysis/CP-055_validation_drive.md` is written for someone who has not read this repository, including the owner six weeks from now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 98b2513 commit b2b5984

16 files changed

Lines changed: 775 additions & 9 deletions

File tree

‎CHANGELOG.md‎

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

77
### Added
88

9+
- **One drive answers eight questions, on the unstable channel only.** Eleven tickets ended
10+
"verified on a laptop, never on the car", and each named a different question; answered one
11+
at a time they would cost eight evenings. There is now one toggle on the evidence screen
12+
that arms every remaining question at once, and each of them records itself from the
13+
decision it is about — the permission result, the routing response and its sections, the
14+
charger search, the what-if rows or its refusal, the model fit, and the car's own navigation
15+
transactions. Nothing needs a tap while driving, because an instrument that needs one at
16+
130 km/h is a hazard. The diagnostic export carries a single `validation-*.json` in which
17+
every question has a named block, including the questions nothing was recorded for: those
18+
say what would have made them fire, since "no chargers came back" and "the search never ran"
19+
close different tickets. The file carries no API key, no typed destination and no origin
20+
coordinate; it does carry road names and the navigation parcel bytes, and it says so in its
21+
own notes. `analysis/CP-055_validation_drive.md` is the checklist, written to be followed by
22+
someone who has not read the source. Stable contains none of it.
23+
924
- **The car's numbers are the driver's now, not a specification sheet's.** Usable capacity when
1025
new, state of health, the charger power floor and the reserve to arrive with were constants in
1126
the source, each with a comment admitting it should not be. There is a parked-only screen for
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# CP-055 — The validation drive
2+
3+
Date written: 2026-09-05
4+
Status: instrument built, drive not yet done
5+
6+
One drive answers eight questions. They accumulated because eleven tickets could be finished
7+
on a laptop and verified nowhere else, and answering them one at a time would cost eight
8+
evenings and eight parking spaces. Everything below is armed before departure and records
9+
itself; nothing on this page asks anyone to operate a screen while the car is moving.
10+
11+
**Read this page alone.** It does not assume the repository has been read, and it should still
12+
work six weeks from now.
13+
14+
## What you need
15+
16+
- The **unstable** build of EVChargePilot installed on the head unit. With the car connected,
17+
`mise run run-unstable` from the repository builds it, installs it and launches it; it sits
18+
beside the stable app rather than replacing it. The stable build contains none of this — no
19+
probe, no toggle, no recording — by construction.
20+
- A **USB stick**, formatted so the head unit can write to it.
21+
- The **routing key** and the **charger key** already saved in the app (Charging stop →
22+
*Routing key*). Without them the route and charger questions cannot fire.
23+
- The **vehicle figures** saved (Charging stop → *Vehicle*): usable capacity, state of health,
24+
minimum charger power, reserve. The defaults are an MG4 Comfort at 100 % health.
25+
- Trip history that already contains a few drives. Question 6 asks whether the consumption
26+
model can be fitted, and a model is fitted from past drives, not from this one alone.
27+
28+
## Before leaving, parked
29+
30+
1. Open the app. On the dashboard, check that **Automatic trips** is on, or start a trip by
31+
hand with **Start trip**. A drive nobody recorded teaches question 6 nothing.
32+
2. Go to **View diagnostics** → **Open evidence capture** → **Arm validation drive**. The line
33+
under the button must read *Validation mode on*.
34+
3. **Close the app completely and reopen it.** This matters: question 2 asks whether Android
35+
grants fine location without ever showing a prompt, and that can only be observed at the
36+
start of a process, before any screen of the app has asked for anything. Arming, then
37+
restarting, is what produces a clean reading.
38+
4. In the **car's own navigation** — not this app — enter a destination and start guidance.
39+
That is what question 1 needs: the transactions being probed can only carry a destination if
40+
the car has one.
41+
5. In the app, open **Charging stop**, type a destination and press **Search**, then pick a
42+
result. Choose somewhere far enough that the plan says a stop is needed; if the battery is
43+
nearly full, pick somewhere further away. A plan with no stop never searches for a charger,
44+
and question 5 stays blank.
45+
6. Note the charge percentage and the outside temperature the dashboard shows. Two lines in a
46+
notebook are worth an hour of guessing later.
47+
48+
**Choose a destination you are willing to share.** The bundle records the names of the roads on
49+
the route and the raw bytes of the car's own destination transactions. It leaves the car on a
50+
USB stick. Use a town, a service area, a shop — not home.
51+
52+
## The drive
53+
54+
The shape matters more than the exact roads:
55+
56+
- **A long motorway leg**, 60 km or more, held at a steady speed between 110 and 130 km/h.
57+
Question 6 needs the charge gauge to move several times — it steps about 2 % every 4 km — and
58+
the model needs to have seen more than one speed across the history it is fitted from.
59+
- **A departmental section**, 15 km or more, at 70 to 90 km/h. Question 3 asks whether the
60+
routing sections look like real roads, and a route made only of motorway cannot show that.
61+
- **One planned charge stop** on the way, from step 5 above. You do not have to actually
62+
charge; the question is whether usable chargers come back for that stretch of road.
63+
64+
While driving, do nothing. The screen can be off. Every probe is already armed, and none of
65+
them needs a tap.
66+
67+
## On arrival, parked
68+
69+
1. Open **Charging stop** once more and search the same destination again. This is what refits
70+
the consumption model with the drive you have just done, and that refit is question 6's
71+
answer.
72+
2. Plug in the USB stick.
73+
3. **View diagnostics** → **Export to USB** → pick the stick. Wait for the confirmation naming
74+
the file.
75+
4. Take the stick out. Nothing else is needed; the app keeps nothing that has to be cleaned up.
76+
77+
## What lands on the stick
78+
79+
One zip. Inside it, `evidence/validation-SWI68-…json` is this drive's answers: eight blocks,
80+
one per question, each naming the ticket it belongs to. Alongside it are the guidance trace,
81+
the signal statistics and the trip record, which the export has always carried.
82+
83+
A block that is empty says why it is empty — either "validation mode was off" or the condition
84+
that would have made it fire. That is deliberate: "no chargers came back" and "the search never
85+
ran" close different tickets, and a blank would not tell them apart.
86+
87+
The file contains no API key, no destination text typed into the app, and no coordinate of
88+
where the car was. It does contain road names and the destination bytes described above.
89+
90+
## What is being asked
91+
92+
| # | Ticket | Question |
93+
| --- | --- | --- |
94+
| 1 | CP-051 | Do the navigation service's transactions 38 and 39 carry the destination the driver already entered in the car? |
95+
| 2 | CP-043 | Is fine location granted at startup without a prompt, because vehicle speed shares its permission group? |
96+
| 3 | CP-049 | Do real routing steps produce sections that look like roads? |
97+
| 4 | CP-049 | Does the routing instance return a second route when asked for alternatives? |
98+
| 5 | CP-048 | Does Open Charge Map return usable chargers along a French motorway? |
99+
| 6 | CP-052 | Do the charge segments the consumption model needs appear on a real drive? |
100+
| 7 | CP-049 | Does the route what-if produce rows on this firmware, or a refusal? |
101+
| 8 | CP-046 | Is a refused or missing location survivable from end to end? |
102+
103+
## Afterwards
104+
105+
Turn the toggle off (**Disarm validation drive**) once the bundle is safely off the car. Then
106+
the answers go back into the tickets above: each one closes against this bundle, or says what
107+
it still does not know.

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

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,12 @@ class ChargeStopActivity : AppCompatActivity() {
101101
// The result map is not the answer: on this platform a permission from an already held
102102
// group can be granted without a prompt, and a coarse-only grant is a refusal of what
103103
// was asked for. What counts is whether fine location is held now.
104-
if (place != null && LocationSource.hasPrecise(this)) route(place) else render()
104+
val granted = LocationSource.hasPrecise(this)
105+
ValidationProbe.record(ValidationQuestion.LOCATION_FALLBACK) {
106+
"permission result: fine=$granted, and the request " +
107+
if (place != null) "continues the route that asked for it" else "had nothing waiting"
108+
}
109+
if (place != null && granted) route(place) else render()
105110
}
106111

107112
private val connection = object : ServiceConnection {
@@ -183,8 +188,23 @@ class ChargeStopActivity : AppCompatActivity() {
183188
// power and the kWh model therefore never trains on it (CP-052). Refitted on each
184189
// load rather than stored: it is a few thousand samples of arithmetic on a worker
185190
// thread, and a cache would only add a way for the two to disagree.
186-
model = (SocConsumptionFitter().fit(trips, generation) as? SocConsumptionFitResult.Ready)
187-
?.model
191+
val fit = SocConsumptionFitter().fit(trips, generation)
192+
model = (fit as? SocConsumptionFitResult.Ready)?.model
193+
ValidationProbe.record(ValidationQuestion.SOC_SEGMENTS) {
194+
val head = "trips=${trips.size} samples=${trips.sumOf { it.samples?.size ?: 0 }} " +
195+
"rate=${rate?.let { r -> probeFormat(r.percentPerKm, "%.3f") + " %/km" } ?: "none"}"
196+
when (fit) {
197+
is SocConsumptionFitResult.Ready -> fit.model.let { m ->
198+
"$head fit=ready segments=${m.segmentCount} " +
199+
"speed=${probeFormat(m.envelope.minSpeedKmh, "%.0f")}..${
200+
probeFormat(m.envelope.maxSpeedKmh, "%.0f")} km/h " +
201+
"temp=${probeFormat(m.envelope.minOutsideTempCelsius, "%.0f")}..${
202+
probeFormat(m.envelope.maxOutsideTempCelsius, "%.0f")} C " +
203+
"rmse=${probeFormat(m.residualRmsePercentPer100Km, "%.2f")} %/100 km"
204+
}
205+
is SocConsumptionFitResult.Unavailable -> "$head fit=unavailable(${fit.reason})"
206+
}
207+
}
188208
}
189209

190210
private fun search() {
@@ -237,6 +257,9 @@ class ChargeStopActivity : AppCompatActivity() {
237257
return
238258
}
239259
if (!LocationSource.hasPrecise(this)) {
260+
ValidationProbe.record(ValidationQuestion.LOCATION_FALLBACK) {
261+
"route asked without a fine grant: the prompt is being requested"
262+
}
240263
pending = place
241264
locationPermission.launch(
242265
arrayOf(
@@ -247,6 +270,12 @@ class ChargeStopActivity : AppCompatActivity() {
247270
return
248271
}
249272
val origin = LocationSource.lastKnown(this)
273+
ValidationProbe.record(ValidationQuestion.LOCATION_FALLBACK) {
274+
// Age and altitude, never the position itself: this file leaves the car.
275+
origin?.let {
276+
"fix held: age=${it.ageMs / 1000}s altitude=${it.altitudeMetres != null}"
277+
} ?: "fine grant held but no fix within ${LocationSource.MAX_AGE_MS / 1000}s: refused"
278+
}
250279
if (origin == null) {
251280
announce(getString(R.string.charge_stop_no_position))
252281
return
@@ -270,6 +299,23 @@ class ChargeStopActivity : AppCompatActivity() {
270299
val routes = (result as? RoutingTransport.Result.Ok)
271300
?.let { OrsDirections.parse(it.body) }.orEmpty()
272301
val route = routes.firstOrNull()
302+
ValidationProbe.record(ValidationQuestion.ROUTE_ALTERNATIVES) {
303+
"asked alternatives=$ALTERNATIVES, " + when (result) {
304+
is RoutingTransport.Result.Ok ->
305+
"answered ${result.body.length} chars, parsed ${routes.size} route(s)"
306+
is RoutingTransport.Result.Refused -> "refused(${result.reason})"
307+
}
308+
}
309+
ValidationProbe.record(ValidationQuestion.ROUTE_SECTIONS) {
310+
route?.let { r ->
311+
"route ${probeFormat(r.distanceKm, "%.1f")} km in " +
312+
"${probeFormat(r.durationMinutes, "%.0f")} min, ${r.sections.size} section(s): " +
313+
r.sections.take(VALIDATION_SECTIONS).joinToString(" | ") { section ->
314+
"${section.road ?: "unnamed"} ${probeFormat(section.distanceKm, "%.1f")} km " +
315+
"@${section.impliedSpeedKmh?.let { v -> probeFormat(v, "%.0f") } ?: "?"}"
316+
}
317+
} ?: "no route parsed"
318+
}
273319
// The road ahead, which is the only elevation a forecast can use: CP-031 refused
274320
// the altitude behind the car and that refusal stands.
275321
val grade = route?.let {
@@ -309,6 +355,21 @@ class ChargeStopActivity : AppCompatActivity() {
309355
RouteWhatIf.alternative(best, it, effective, settings.pack)
310356
}
311357
}
358+
ValidationProbe.record(ValidationQuestion.WHAT_IF) {
359+
val body = when (whatIf) {
360+
null -> "no route, so no what-if"
361+
is RouteWhatIf.Result.Unavailable -> "unavailable(${whatIf.reason})"
362+
is RouteWhatIf.Result.Ready -> "ready rows=${whatIf.options.size} " +
363+
"headline=${whatIf.headline?.speedKmh?.toString() ?: "none"} " +
364+
whatIf.options.joinToString(" | ") { option ->
365+
"${option.speedKmh}: ${probeFormat(option.affectedKm, "%.0f")} km " +
366+
"${probeFormat(option.delayMinutes, "%.0f")} min " +
367+
"${probeFormat(option.savedPercentLow, "%.1f")}..${
368+
probeFormat(option.savedPercentHigh, "%.1f")} %"
369+
}
370+
}
371+
"$body | second road=${alternative?.viaLabel ?: "none"}"
372+
}
312373
runOnUiThread {
313374
if (isFinishing || isDestroyed) return@runOnUiThread
314375
render(place, route, plan, effective, charger, grade, whatIf, alternative)
@@ -358,16 +419,41 @@ class ChargeStopActivity : AppCompatActivity() {
358419
rate: SocRate?,
359420
settings: VehicleSettings.Values,
360421
): Found? {
361-
val credentials = RoutingCredentials.readCharger(this) ?: return null
422+
val credentials = RoutingCredentials.readCharger(this) ?: run {
423+
ValidationProbe.record(ValidationQuestion.CHARGERS) { "no charger key configured" }
424+
return null
425+
}
362426
val window = RouteGeometry.window(
363427
route.points,
364428
(afterKm - OpenChargeMap.WINDOW_KM).coerceAtLeast(0.0),
365429
afterKm,
366430
)
367-
if (window.size < 2) return null
431+
if (window.size < 2) {
432+
ValidationProbe.record(ValidationQuestion.CHARGERS) {
433+
"the ${OpenChargeMap.WINDOW_KM} km window before km " +
434+
"${probeFormat(afterKm, "%.0f")} held ${window.size} route point(s): no search"
435+
}
436+
return null
437+
}
368438
val result = chargers.get(credentials, OpenChargeMap.PATH, OpenChargeMap.query(window))
369-
val body = (result as? RoutingTransport.Result.Ok)?.body ?: return null
370-
return OpenChargeMap.parse(body, settings.minChargerPowerKw)
439+
val body = (result as? RoutingTransport.Result.Ok)?.body ?: run {
440+
ValidationProbe.record(ValidationQuestion.CHARGERS) {
441+
"search refused(${(result as? RoutingTransport.Result.Refused)?.reason})"
442+
}
443+
return null
444+
}
445+
val found = OpenChargeMap.parse(body, settings.minChargerPowerKw)
446+
ValidationProbe.record(ValidationQuestion.CHARGERS) {
447+
val powers = found.mapNotNull { it.powerKw }
448+
"window ends at km ${probeFormat(afterKm, "%.0f")}, " +
449+
"min ${probeFormat(settings.minChargerPowerKw, "%.0f")} kW: " +
450+
"${found.size} charger(s), power " +
451+
(powers.minOrNull()?.let { low ->
452+
"${probeFormat(low, "%.0f")}..${probeFormat(powers.max(), "%.0f")} kW"
453+
} ?: "unpublished") +
454+
", operational=${found.count { it.operational == true }}"
455+
}
456+
return found
371457
.mapNotNull { charger ->
372458
val alongKm = RouteGeometry.distanceAlongKm(
373459
route.points,
@@ -636,6 +722,10 @@ class ChargeStopActivity : AppCompatActivity() {
636722
message = null
637723
}
638724

725+
/** Probe text is read at a desk, not by a driver: point decimals, whatever the locale. */
726+
private fun probeFormat(value: Double, pattern: String): String =
727+
String.format(Locale.ROOT, pattern, value)
728+
639729
private fun format(value: Double, pattern: String): String =
640730
String.format(Locale.getDefault(), pattern, value)
641731

@@ -655,5 +745,7 @@ class ChargeStopActivity : AppCompatActivity() {
655745
*/
656746
const val ALTERNATIVES = 2
657747

748+
/** Enough sections for a validation line to show the shape of a route, not all of it. */
749+
const val VALIDATION_SECTIONS = 8
658750
}
659751
}

0 commit comments

Comments
 (0)