Skip to content

Commit d98b669

Browse files
malysclaude
andcommitted
feat(telemetry): notice when the plan stops being the trip
A plan chosen at the kerb is a prediction, and predictions go wrong on the road. PlanDrift compares what a drive is actually spending per kilometre against what its plan assumed, and says what that costs on arrival. It costs nothing to run. Charge and the odometer are already read once a second, so the whole of it is a division: no request, no quota, no network. The alarm is hard to trip on purpose. Both instruments quantise, so the measurement carries a band of its own, and drift is that band failing to overlap the plan's. On top of that the arrival has to actually fall under the reserve — a driver going faster than the row they picked and still arriving at 30 % does not need telling, and a companion that speaks then is one nobody reads when it matters. ChargeStopPlan.effectiveRate becomes public: the comparison has to be against the rate the plan was really made with, climb folded in, and a second copy of that fold would have drifted from the one that planned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 12fc55e commit d98b669

4 files changed

Lines changed: 314 additions & 2 deletions

File tree

‎CHANGELOG.md‎

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

99
### Added
1010

11+
- **`PlanDrift` — whether the plan the driver chose is still the plan they are driving.** Charge
12+
and the odometer are already read once a second and the plan was written down when it was
13+
picked, so noticing that a trip has stopped matching it is a division: no request, no quota, no
14+
network. The alarm is deliberately hard to trip. It fires only when the rate actually being
15+
spent has left the band the plan admitted to *and* the arrival no longer clears the reserve,
16+
because a driver going faster than the row they chose and still arriving at 30 % does not need
17+
telling, and a companion that speaks then is one nobody reads when it matters. Both instruments
18+
quantise — a gauge that steps a whole percent, an odometer that answers whole kilometres — so
19+
the measurement carries its own band, which is also why it refuses under 15 km of road.
20+
- `ChargeStopPlan.effectiveRate` is public. It was the private fold of the climb into the rate;
21+
`PlanDrift` has to compare a drive against the rate the plan was *actually* made with, and a
22+
second copy of that arithmetic would have drifted away from the one that planned.
23+
1124
- **`NavigationHandoff` — the first write this library builds towards the car.** Everything else
1225
here reads. This composes a `geo:` URI so a route the driver has chosen can be handed to the
1326
car's own navigation instead of retyped, and it is separated from the sending because the

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ object ChargeStopPlan {
7575
}
7676
if (rate == null || rate.percentPerKm <= 0.0) return Plan.Refused(Reason.NO_RATE)
7777

78-
val effective = withGrade(rate, routeKm, grade)
78+
val effective = effectiveRate(rate, routeKm, grade)
7979
val band = 2.0 * effective.uncertaintyPercentPerKm * routeKm
8080
if (band > MAX_BAND_PERCENT) return Plan.Refused(Reason.BAND_TOO_WIDE)
8181

@@ -114,8 +114,11 @@ object ChargeStopPlan {
114114
* slightly early and one after an early col slightly late. Early is the safe direction and
115115
* the error is small next to the rate's own band; the upgrade, when a route needs it, is a
116116
* per-segment profile rather than two cumulative numbers.
117+
*
118+
* Public because [PlanDrift] has to compare a drive against the rate the plan was actually
119+
* made with, and a second copy of this fold would drift away from the one that planned.
117120
*/
118-
private fun withGrade(rate: SocRate, routeKm: Double, grade: RouteGrade.Cost?): SocRate {
121+
fun effectiveRate(rate: SocRate, routeKm: Double, grade: RouteGrade.Cost?): SocRate {
119122
if (grade == null) return rate
120123
return rate.copy(
121124
// Floored, not clamped away: a descent long enough to make the net rate negative
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
/**
4+
* Whether the plan the driver chose at the kerb is still the plan they are driving.
5+
*
6+
* A plan is a prediction, and predictions go wrong on the road: a headwind, a cold pack, traffic,
7+
* a driver going faster than the row they picked. The moment worth a line is the one where the
8+
* charge left on arrival has fallen under the reserve while there is still a charger behind the
9+
* car rather than ahead of it, and nobody notices that by watching a percentage move.
10+
*
11+
* **This costs nothing.** Charge and the odometer are already being read once a second, and the
12+
* plan was written down when it was chosen. There is no request here, no quota and no network —
13+
* the whole of it is a division. Only a *re-proposal* costs a request, and that happens on a tap
14+
* somewhere else.
15+
*
16+
* **The alarm sits outside the model's own band, never on a point.** The rate a drive is actually
17+
* spending is measured through two quantised instruments — a gauge that steps a whole percent and
18+
* an odometer that answers whole kilometres — so it arrives with a band of its own, and the plan
19+
* arrived with one too. Drift is the two bands failing to overlap. Anything narrower would fire on
20+
* a hill.
21+
*
22+
* **A drift that costs nothing is not reported.** Driving faster than the row that was chosen is
23+
* the driver's business, and a companion that says so while still arriving at 30 % is a companion
24+
* that gets ignored by the time it matters. So [Verdict.Short] needs both halves: the spending
25+
* left the plan's band *and* the arrival no longer clears the reserve.
26+
*/
27+
object PlanDrift {
28+
29+
/**
30+
* Below this the instruments' own steps dominate whatever they measured.
31+
*
32+
* At 15 km a whole percent of gauge is already 0,07 %/km of band — about a third of what this
33+
* car spends — and under it the measurement says less than the plan it would be contradicting.
34+
*/
35+
const val MIN_DISTANCE_KM = 15.0
36+
37+
/** The finest step the charge gauge publishes. Two readings differ by at most this. */
38+
const val SOC_RESOLUTION_PERCENT = 1.0
39+
40+
/** The adapter's odometer answers whole kilometres, so a difference of them carries one. */
41+
const val ODOMETER_RESOLUTION_KM = 1.0
42+
43+
/**
44+
* The plan as it was handed to the car, frozen at the moment the driver chose it.
45+
*
46+
* @param legKm the leg being driven — to the charging stop where the plan has one, and to the
47+
* destination where it does not. Never the whole route past a stop: arriving under the
48+
* reserve at a destination the plan says to charge before is not drift, it is the plan.
49+
* @param plannedRatePercentPerKm the rate the plan was made with, climb already folded in by
50+
* [ChargeStopPlan.effectiveRate].
51+
* @param reservePercent the charge the driver refuses to go below.
52+
*/
53+
data class Followed(
54+
val legKm: Double,
55+
val socAtDeparturePercent: Double,
56+
val plannedRatePercentPerKm: Double,
57+
val plannedUncertaintyPercentPerKm: Double,
58+
val reservePercent: Double,
59+
)
60+
61+
/** Why there is nothing to say, kept apart because the reasons read differently. */
62+
enum class Reason {
63+
/** Nothing was chosen, or it was forgotten. */
64+
NO_PLAN,
65+
66+
/** The gauge is unreadable. */
67+
NO_CHARGE,
68+
69+
/** Nothing can say how far this car has come since the plan. */
70+
NO_DISTANCE,
71+
72+
/** Too little road behind the car for the measurement to mean anything. */
73+
TOO_SOON,
74+
75+
/** The car has more charge than it left with; the departure figure no longer anchors. */
76+
CHARGED_EN_ROUTE,
77+
78+
/** The leg is behind the car. */
79+
ARRIVED,
80+
}
81+
82+
/** What the drive is actually doing, whatever the verdict on it. */
83+
data class Reading(
84+
val drivenKm: Double,
85+
val remainingKm: Double,
86+
/** Charge per kilometre this drive has actually spent. */
87+
val observedPercentPerKm: Double,
88+
/** Charge left at the end of the leg if the rest is spent like the part behind. */
89+
val arrivalPercent: Double,
90+
/** Half-width on [arrivalPercent]. */
91+
val bandPercent: Double,
92+
)
93+
94+
sealed interface Verdict {
95+
/** The plan is still true: the spending is inside its band, or it costs the arrival nothing. */
96+
data class Holding(val reading: Reading) : Verdict
97+
98+
/**
99+
* The plan stopped being true.
100+
*
101+
* @param shortfallPercent how far under the reserve the pessimistic edge of the arrival
102+
* falls. Always positive, and it is what a slower speed has to give back.
103+
*/
104+
data class Short(val reading: Reading, val shortfallPercent: Double) : Verdict
105+
106+
data class Unavailable(val reason: Reason) : Verdict
107+
}
108+
109+
/**
110+
* @param drivenKm road covered since [plan] was chosen — an odometer difference, not a trip.
111+
* @param remainingKm the car's own remaining distance where the head unit is guiding on this
112+
* leg, which is the better number because it knows about the road actually taken. Null
113+
* falls back to the leg's own arithmetic.
114+
*/
115+
fun check(
116+
plan: Followed?,
117+
drivenKm: Double?,
118+
socNowPercent: Double?,
119+
remainingKm: Double? = null,
120+
): Verdict {
121+
if (plan == null || !plan.legKm.isFinite() || plan.legKm <= 0.0) {
122+
return Verdict.Unavailable(Reason.NO_PLAN)
123+
}
124+
if (plan.plannedRatePercentPerKm <= 0.0) return Verdict.Unavailable(Reason.NO_PLAN)
125+
if (socNowPercent == null || !socNowPercent.isFinite() || socNowPercent !in 0.0..100.0) {
126+
return Verdict.Unavailable(Reason.NO_CHARGE)
127+
}
128+
if (drivenKm == null || !drivenKm.isFinite() || drivenKm < 0.0) {
129+
return Verdict.Unavailable(Reason.NO_DISTANCE)
130+
}
131+
if (drivenKm < MIN_DISTANCE_KM) return Verdict.Unavailable(Reason.TOO_SOON)
132+
133+
val spent = plan.socAtDeparturePercent - socNowPercent
134+
// A gauge that rose by a point down a long descent is a gauge, not a charging session.
135+
// Anything past its own step means the car was plugged in and the departure figure
136+
// stopped anchoring anything.
137+
if (spent < -SOC_RESOLUTION_PERCENT) return Verdict.Unavailable(Reason.CHARGED_EN_ROUTE)
138+
139+
val remaining = remainingKm?.takeIf { it.isFinite() } ?: (plan.legKm - drivenKm)
140+
if (remaining <= 0.0) return Verdict.Unavailable(Reason.ARRIVED)
141+
142+
val observed = (spent / drivenKm).coerceAtLeast(0.0)
143+
// Both instruments quantise, and both errors land on the same division. Added rather
144+
// than combined in quadrature: they are two systematic steps, not two noise sources,
145+
// and the wide reading is the one that refuses to raise an alarm it cannot support.
146+
val observedBand =
147+
(SOC_RESOLUTION_PERCENT + observed * ODOMETER_RESOLUTION_KM) / drivenKm
148+
149+
val arrival = socNowPercent - observed * remaining
150+
val band = observedBand * remaining
151+
val reading = Reading(drivenKm, remaining, observed, arrival, band)
152+
153+
val outsideBand =
154+
observed - plan.plannedRatePercentPerKm >
155+
plan.plannedUncertaintyPercentPerKm + observedBand
156+
val shortfall = plan.reservePercent - (arrival - band)
157+
return if (outsideBand && shortfall > 0.0) {
158+
Verdict.Short(reading, shortfall)
159+
} else {
160+
Verdict.Holding(reading)
161+
}
162+
}
163+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
class PlanDriftTest {
8+
9+
/** 300 km at 0,20 %/km ± 0,02: leaves at 90 %, plans to arrive at 30 %, reserve 10 %. */
10+
private val plan = PlanDrift.Followed(
11+
legKm = 300.0,
12+
socAtDeparturePercent = 90.0,
13+
plannedRatePercentPerKm = 0.20,
14+
plannedUncertaintyPercentPerKm = 0.02,
15+
reservePercent = 10.0,
16+
)
17+
18+
/** Charge left after [km] at [ratePerKm], as the gauge would read it. */
19+
private fun socAfter(km: Double, ratePerKm: Double) =
20+
plan.socAtDeparturePercent - km * ratePerKm
21+
22+
@Test
23+
fun `a drive spending what the plan assumed is holding`() {
24+
val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.20))
25+
val holding = verdict as PlanDrift.Verdict.Holding
26+
assertEquals(0.20, holding.reading.observedPercentPerKm, 1e-9)
27+
assertEquals(200.0, holding.reading.remainingKm, 1e-9)
28+
assertEquals(30.0, holding.reading.arrivalPercent, 1e-9)
29+
}
30+
31+
@Test
32+
fun `spending inside the plan's own band raises nothing`() {
33+
// 0,215 %/km against a plan of 0,20 ± 0,02, with the measurement's own band on top:
34+
// the two overlap, so there is no drift to report however the arrival lands.
35+
val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.215))
36+
assertTrue(verdict.toString(), verdict is PlanDrift.Verdict.Holding)
37+
}
38+
39+
@Test
40+
fun `spending twice the band past the plan, and short of the reserve, drifts`() {
41+
// 0,26 %/km: 0,06 past the plan, about twice the two bands added. Arrival lands at 4 %.
42+
val verdict = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = socAfter(100.0, 0.26))
43+
val short = verdict as PlanDrift.Verdict.Short
44+
assertEquals(0.26, short.reading.observedPercentPerKm, 1e-9)
45+
assertEquals(12.0, short.reading.arrivalPercent, 1e-9)
46+
assertTrue(short.shortfallPercent > 0.0)
47+
// The shortfall is measured at the pessimistic edge, so it is wider than 10 − 12.
48+
assertEquals(plan.reservePercent - (12.0 - short.reading.bandPercent), short.shortfallPercent, 1e-9)
49+
}
50+
51+
@Test
52+
fun `spending past the plan but still arriving well clear of the reserve is not reported`() {
53+
// Same 0,26 %/km, but only 60 km of leg left to spend it on: the arrival still clears
54+
// the reserve, and a companion that speaks here is one nobody reads when it matters.
55+
val near = plan.copy(legKm = 100.0)
56+
val verdict = PlanDrift.check(near, drivenKm = 40.0, socNowPercent = socAfter(40.0, 0.26))
57+
assertTrue(verdict.toString(), verdict is PlanDrift.Verdict.Holding)
58+
}
59+
60+
@Test
61+
fun `the car's own remaining distance wins over the leg's arithmetic`() {
62+
val verdict = PlanDrift.check(
63+
plan,
64+
drivenKm = 100.0,
65+
socNowPercent = socAfter(100.0, 0.20),
66+
remainingKm = 240.0,
67+
)
68+
val holding = verdict as PlanDrift.Verdict.Holding
69+
assertEquals(240.0, holding.reading.remainingKm, 1e-9)
70+
assertEquals(70.0 - 240.0 * 0.20, holding.reading.arrivalPercent, 1e-9)
71+
}
72+
73+
@Test
74+
fun `too little road behind the car says so rather than measuring`() {
75+
val verdict = PlanDrift.check(plan, drivenKm = 5.0, socNowPercent = 88.0)
76+
assertEquals(
77+
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.TOO_SOON),
78+
verdict,
79+
)
80+
}
81+
82+
@Test
83+
fun `a charge en route is refused, a descent of one point is not`() {
84+
assertEquals(
85+
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.CHARGED_EN_ROUTE),
86+
PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = 95.0),
87+
)
88+
// 91 % after 100 km: one point above departure, which is the gauge's own step.
89+
val recovered = PlanDrift.check(plan, drivenKm = 100.0, socNowPercent = 91.0)
90+
val holding = recovered as PlanDrift.Verdict.Holding
91+
assertEquals(0.0, holding.reading.observedPercentPerKm, 1e-9)
92+
}
93+
94+
@Test
95+
fun `no plan, no charge, no distance and an arrival each say which`() {
96+
assertEquals(
97+
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_PLAN),
98+
PlanDrift.check(null, 100.0, 60.0),
99+
)
100+
assertEquals(
101+
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_CHARGE),
102+
PlanDrift.check(plan, 100.0, null),
103+
)
104+
assertEquals(
105+
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.NO_DISTANCE),
106+
PlanDrift.check(plan, null, 60.0),
107+
)
108+
assertEquals(
109+
PlanDrift.Verdict.Unavailable(PlanDrift.Reason.ARRIVED),
110+
PlanDrift.check(plan, 320.0, 20.0),
111+
)
112+
}
113+
114+
@Test
115+
fun `the measurement's band shrinks as the road behind it grows`() {
116+
val early = PlanDrift.check(plan, 20.0, socAfter(20.0, 0.20)) as PlanDrift.Verdict.Holding
117+
val late = PlanDrift.check(plan, 200.0, socAfter(200.0, 0.20)) as PlanDrift.Verdict.Holding
118+
// Same rate, same route: the only thing that changed is how much evidence there is.
119+
assertTrue(
120+
"${early.reading.bandPercent} vs ${late.reading.bandPercent}",
121+
early.reading.bandPercent > late.reading.bandPercent,
122+
)
123+
}
124+
125+
@Test
126+
fun `the rate the plan was made with is the one with the climb already in it`() {
127+
// What the app hands over: the planner's own fold, not a second copy of it.
128+
val rate = SocRate(0.20, 0.02, SocRate.Source.TRIP_HISTORY, sampleCount = 8)
129+
val effective = ChargeStopPlan.effectiveRate(rate, 300.0, RouteGrade.Cost(6.0, 1.5))
130+
assertEquals(0.22, effective.percentPerKm, 1e-9)
131+
assertEquals(0.025, effective.uncertaintyPercentPerKm, 1e-9)
132+
}
133+
}

0 commit comments

Comments
 (0)