Skip to content

Commit 9caeaaa

Browse files
malysclaude
andcommitted
feat(telemetry): answer whether a route needs a charging stop
CP-042 was described in one sentence — "do I need to charge on this trip, and in how many kilometres" — and that sentence is arithmetic, not a planner. No charging curve, no multi-stop search, no cost function: ABRP does that and does it better. What the car cannot tell its driver is the one thing this answers. It plans to a reserve rather than to zero. A forecast that says the car arrives at 3 % is not saying the trip works, it is saying the trip works if nothing goes wrong. The reserve is a stated number the driver can change instead of an optimism hidden in the arithmetic. When a stop is needed, the distance is computed at the top of the SocRate band rather than at its middle. Being told to charge earlier than strictly necessary costs minutes; being told to charge later costs a tow truck. Uncertainty refuses. The band grows with distance, and past some distance "you will arrive between 5 % and 45 %" is not a plan — a refusal is visible, a wide answer rendered as a plan is not. The threshold is looser than the arrival forecast's, because "you will need to charge" survives more uncertainty than a printed arrival figure. Refs: CP-042 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 02dca34 commit 9caeaaa

2 files changed

Lines changed: 183 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
/**
4+
* Whether a route needs a charging stop, and in how many kilometres.
5+
*
6+
* This is the sentence the whole project was described by — *"il faudra recharger une fois dans
7+
* N km"* — and it is arithmetic, not a planner. There is deliberately no optimisation here: no
8+
* charging curve, no multi-stop search, no cost function. ABRP does that and does it better;
9+
* what the car cannot tell its driver is the one thing this answers.
10+
*
11+
* It plans to a **reserve**, never to zero. A forecast that says the car arrives at 3 % is not
12+
* saying the trip works, it is saying the trip works if nothing goes wrong, and something goes
13+
* wrong. The reserve is a stated number the driver can change rather than an optimism built into
14+
* the arithmetic where nobody can see it.
15+
*
16+
* Uncertainty is the reason this refuses. [SocRate] carries a band, the band grows with
17+
* distance, and past some distance "you will arrive between 5 % and 45 %" is not a plan. The
18+
* refusal is visible; a wide answer rendered as a plan is not.
19+
*/
20+
object ChargeStopPlan {
21+
22+
/** What a careful driver keeps in the pack. Not a physical limit — a stated choice. */
23+
const val DEFAULT_RESERVE_PERCENT = 10.0
24+
25+
/**
26+
* Wider than this and the plan is refused. Looser than [ArrivalSocForecast.MAX_BAND_PERCENT]
27+
* because a stop decision survives more uncertainty than a printed arrival figure: "you will
28+
* need to charge" holds even when "you will arrive at 12 %" does not.
29+
*/
30+
const val MAX_BAND_PERCENT = 25.0
31+
32+
sealed interface Plan {
33+
/** The route completes with the reserve intact. */
34+
data class NoStop(
35+
val arrivalPercent: Double,
36+
val marginPercent: Double,
37+
val bandPercent: Double,
38+
) : Plan
39+
40+
/** A stop is needed, this far along the route. */
41+
data class Stop(
42+
val afterKm: Double,
43+
val bandKm: Double,
44+
val shortfallKm: Double,
45+
val bandPercent: Double,
46+
) : Plan
47+
48+
/** Nothing worth acting on, and the reason. */
49+
data class Refused(val reason: Reason) : Plan
50+
}
51+
52+
enum class Reason { NO_CHARGE, NO_ROUTE, NO_RATE, BAND_TOO_WIDE }
53+
54+
/**
55+
* @param socPercent charge now.
56+
* @param routeKm the whole remaining route.
57+
* @param rate charge spent per kilometre, with its own uncertainty.
58+
* @param reservePercent the charge the driver refuses to go below.
59+
*/
60+
fun of(
61+
socPercent: Double?,
62+
routeKm: Double?,
63+
rate: SocRate?,
64+
reservePercent: Double = DEFAULT_RESERVE_PERCENT,
65+
): Plan {
66+
if (socPercent == null || !socPercent.isFinite()) return Plan.Refused(Reason.NO_CHARGE)
67+
if (routeKm == null || !routeKm.isFinite() || routeKm <= 0.0) {
68+
return Plan.Refused(Reason.NO_ROUTE)
69+
}
70+
if (rate == null || rate.percentPerKm <= 0.0) return Plan.Refused(Reason.NO_RATE)
71+
72+
val band = 2.0 * rate.uncertaintyPercentPerKm * routeKm
73+
if (band > MAX_BAND_PERCENT) return Plan.Refused(Reason.BAND_TOO_WIDE)
74+
75+
val spendable = socPercent - reservePercent
76+
val needed = rate.percentPerKm * routeKm
77+
if (spendable >= needed) {
78+
return Plan.NoStop(
79+
arrivalPercent = socPercent - needed,
80+
marginPercent = spendable - needed,
81+
bandPercent = band,
82+
)
83+
}
84+
85+
// Pessimistic on purpose: the distance the car reaches if it spends at the top of the
86+
// band. Being told to charge earlier than strictly necessary is a cost of minutes;
87+
// being told to charge later is a cost of a tow truck.
88+
val worstRate = rate.percentPerKm + rate.uncertaintyPercentPerKm
89+
val reachKm = (spendable / worstRate).coerceAtLeast(0.0)
90+
val bestRate = (rate.percentPerKm - rate.uncertaintyPercentPerKm).coerceAtLeast(1e-6)
91+
val optimisticKm = spendable / bestRate
92+
return Plan.Stop(
93+
afterKm = reachKm,
94+
bandKm = (optimisticKm - reachKm).coerceAtLeast(0.0),
95+
shortfallKm = routeKm - reachKm,
96+
bandPercent = band,
97+
)
98+
}
99+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 ChargeStopPlanTest {
8+
9+
private fun rate(percentPerKm: Double, uncertainty: Double) = SocRate(
10+
percentPerKm = percentPerKm,
11+
uncertaintyPercentPerKm = uncertainty,
12+
source = SocRate.Source.TRIP_HISTORY,
13+
sampleCount = 8,
14+
)
15+
16+
@Test
17+
fun `a short route on a full pack needs no stop`() {
18+
val plan = ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04))
19+
assertTrue(plan is ChargeStopPlan.Plan.NoStop)
20+
plan as ChargeStopPlan.Plan.NoStop
21+
assertEquals(50.0, plan.arrivalPercent, 0.001)
22+
assertEquals(40.0, plan.marginPercent, 0.001)
23+
}
24+
25+
@Test
26+
fun `the reserve is what makes a marginal trip need a stop`() {
27+
// 100 km at 0.4 %/km spends 40 %; from 45 % that arrives at 5, under a 10 % reserve.
28+
val withoutReserve = ChargeStopPlan.of(45.0, 100.0, rate(0.4, 0.02), reservePercent = 0.0)
29+
val withReserve = ChargeStopPlan.of(45.0, 100.0, rate(0.4, 0.02))
30+
assertTrue(withoutReserve is ChargeStopPlan.Plan.NoStop)
31+
assertTrue(withReserve is ChargeStopPlan.Plan.Stop)
32+
}
33+
34+
@Test
35+
fun `the stop distance is pessimistic, not the midpoint`() {
36+
// 35 % spendable at 0.4 +- 0.04 %/km: the midpoint reaches 87,5 km, the top of the
37+
// band only 79,5. The driver is told the shorter one.
38+
val plan = ChargeStopPlan.of(45.0, 200.0, rate(0.4, 0.04))
39+
assertTrue(plan is ChargeStopPlan.Plan.Stop)
40+
plan as ChargeStopPlan.Plan.Stop
41+
assertEquals(79.5, plan.afterKm, 0.5)
42+
assertTrue("pessimistic", plan.afterKm < 35.0 / 0.4)
43+
assertEquals(200.0 - plan.afterKm, plan.shortfallKm, 0.001)
44+
}
45+
46+
@Test
47+
fun `a long route with a loose rate is refused rather than shown wide`() {
48+
val plan = ChargeStopPlan.of(80.0, 600.0, rate(0.4, 0.05))
49+
assertEquals(
50+
ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.BAND_TOO_WIDE),
51+
plan,
52+
)
53+
}
54+
55+
@Test
56+
fun `the same route with a tight rate is planned`() {
57+
val plan = ChargeStopPlan.of(80.0, 600.0, rate(0.4, 0.01))
58+
assertTrue(plan is ChargeStopPlan.Plan.Stop)
59+
}
60+
61+
@Test
62+
fun `missing inputs each name themselves`() {
63+
assertEquals(
64+
ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_CHARGE),
65+
ChargeStopPlan.of(null, 100.0, rate(0.4, 0.01)),
66+
)
67+
assertEquals(
68+
ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_ROUTE),
69+
ChargeStopPlan.of(80.0, null, rate(0.4, 0.01)),
70+
)
71+
assertEquals(
72+
ChargeStopPlan.Plan.Refused(ChargeStopPlan.Reason.NO_RATE),
73+
ChargeStopPlan.of(80.0, 100.0, null),
74+
)
75+
}
76+
77+
@Test
78+
fun `a car already below its reserve is told to stop immediately`() {
79+
val plan = ChargeStopPlan.of(8.0, 50.0, rate(0.4, 0.01))
80+
assertTrue(plan is ChargeStopPlan.Plan.Stop)
81+
plan as ChargeStopPlan.Plan.Stop
82+
assertEquals(0.0, plan.afterKm, 0.001)
83+
}
84+
}

0 commit comments

Comments
 (0)