Skip to content

Commit 4429357

Browse files
committed
feat(telemetry): the road ahead goes up, and the forecast now knows
CP-031 refused a grade source because every one on offer described the road behind the car. A route's elevation profile describes the road ahead, which is the only kind a forecast can use — Toulouse to Alès crosses the Cévennes, and a plan that does not know that is wrong in the direction that strands people. Physics, not a fit: potential energy for a stated mass, a stated drivetrain efficiency up and a deliberately pessimistic regeneration efficiency down, each a declared constant inside a 40 % band rather than a parameter tuned against data this project does not have. A descent gives charge back, always less than the same climb spent. ChargeStopPlan folds the term into the rate, so a missing profile is null and produces exactly the plan it produced yesterday — never a silent zero that claims the road is flat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9caeaaa commit 4429357

4 files changed

Lines changed: 247 additions & 4 deletions

File tree

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

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.evsuite.hardware.telemetry
22

3+
import kotlin.math.abs
4+
35
/**
46
* Whether a route needs a charging stop, and in how many kilometres.
57
*
@@ -56,24 +58,29 @@ object ChargeStopPlan {
5658
* @param routeKm the whole remaining route.
5759
* @param rate charge spent per kilometre, with its own uncertainty.
5860
* @param reservePercent the charge the driver refuses to go below.
61+
* @param grade what the route's climb and descent cost, from [RouteGrade], or null when no
62+
* elevation profile came back. Null is not zero: a plan with no profile is exactly the
63+
* plan this made before profiles existed.
5964
*/
6065
fun of(
6166
socPercent: Double?,
6267
routeKm: Double?,
6368
rate: SocRate?,
6469
reservePercent: Double = DEFAULT_RESERVE_PERCENT,
70+
grade: RouteGrade.Cost? = null,
6571
): Plan {
6672
if (socPercent == null || !socPercent.isFinite()) return Plan.Refused(Reason.NO_CHARGE)
6773
if (routeKm == null || !routeKm.isFinite() || routeKm <= 0.0) {
6874
return Plan.Refused(Reason.NO_ROUTE)
6975
}
7076
if (rate == null || rate.percentPerKm <= 0.0) return Plan.Refused(Reason.NO_RATE)
7177

72-
val band = 2.0 * rate.uncertaintyPercentPerKm * routeKm
78+
val effective = withGrade(rate, routeKm, grade)
79+
val band = 2.0 * effective.uncertaintyPercentPerKm * routeKm
7380
if (band > MAX_BAND_PERCENT) return Plan.Refused(Reason.BAND_TOO_WIDE)
7481

7582
val spendable = socPercent - reservePercent
76-
val needed = rate.percentPerKm * routeKm
83+
val needed = effective.percentPerKm * routeKm
7784
if (spendable >= needed) {
7885
return Plan.NoStop(
7986
arrivalPercent = socPercent - needed,
@@ -85,9 +92,10 @@ object ChargeStopPlan {
8592
// Pessimistic on purpose: the distance the car reaches if it spends at the top of the
8693
// band. Being told to charge earlier than strictly necessary is a cost of minutes;
8794
// being told to charge later is a cost of a tow truck.
88-
val worstRate = rate.percentPerKm + rate.uncertaintyPercentPerKm
95+
val worstRate = effective.percentPerKm + effective.uncertaintyPercentPerKm
8996
val reachKm = (spendable / worstRate).coerceAtLeast(0.0)
90-
val bestRate = (rate.percentPerKm - rate.uncertaintyPercentPerKm).coerceAtLeast(1e-6)
97+
val bestRate =
98+
(effective.percentPerKm - effective.uncertaintyPercentPerKm).coerceAtLeast(1e-6)
9199
val optimisticKm = spendable / bestRate
92100
return Plan.Stop(
93101
afterKm = reachKm,
@@ -96,4 +104,25 @@ object ChargeStopPlan {
96104
bandPercent = band,
97105
)
98106
}
107+
108+
/**
109+
* The rate with the climb folded into it, so the rest of the arithmetic never learns that
110+
* grade exists.
111+
*
112+
* The profile is a total and not a position: the col may be at kilometre 10 or at 400 and
113+
* this spreads it evenly over the route either way, which makes a stop before a late col
114+
* slightly early and one after an early col slightly late. Early is the safe direction and
115+
* the error is small next to the rate's own band; the upgrade, when a route needs it, is a
116+
* per-segment profile rather than two cumulative numbers.
117+
*/
118+
private fun withGrade(rate: SocRate, routeKm: Double, grade: RouteGrade.Cost?): SocRate {
119+
if (grade == null) return rate
120+
return rate.copy(
121+
// Floored, not clamped away: a descent long enough to make the net rate negative
122+
// would otherwise plan a car that gains charge for ever.
123+
percentPerKm = (rate.percentPerKm + grade.percent / routeKm).coerceAtLeast(1e-6),
124+
uncertaintyPercentPerKm = rate.uncertaintyPercentPerKm +
125+
abs(grade.uncertaintyPercent) / routeKm,
126+
)
127+
}
99128
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import kotlin.math.abs
4+
5+
/**
6+
* What the climbing costs, and how little of it comes back.
7+
*
8+
* CP-031 refused a grade source on 2026-09-01 and was right to: the vehicle publishes none, a
9+
* barometric reference is unproven, power-derived grade is circular, and sampled GPS altitude
10+
* describes the road *behind* the car. CP-047 changed the input rather than the argument — a
11+
* route carries an elevation profile of the road **ahead**, which is the only kind a forecast
12+
* can use. Toulouse to Alès crosses the Cévennes, and a forecast that does not know that is
13+
* wrong in the direction that strands people.
14+
*
15+
* This is physics, not a fit. Potential energy for a stated mass, a stated drivetrain
16+
* efficiency on the way up and a stated regeneration efficiency on the way down — every one of
17+
* them a declared constant with a band, none of them a parameter tuned against data this
18+
* project does not have. When there are trips over known profiles a fitted coefficient can
19+
* replace it; until then a fitted number would only be a guess wearing a measurement's clothes.
20+
*
21+
* **Regeneration is not free.** A descent returns charge, but less than the same climb spent,
22+
* because the round trip pays the drivetrain twice and pays the brakes wherever the descent is
23+
* steeper than the car's drag. That asymmetry is the whole point: a route over a col comes back
24+
* costing more than the flat one, never the same and never less.
25+
*/
26+
object RouteGrade {
27+
28+
/**
29+
* The car and what is in it, in kilograms.
30+
*
31+
* MG4 Long Range kerb mass is about 1685 kg on the manufacturer's specification; a driver
32+
* and their luggage is the rest. A specification describes the model and not this car, so
33+
* this is a stated assumption carried in [RELATIVE_UNCERTAINTY] rather than a measurement —
34+
* see `AGENTS.md` on specification references.
35+
*/
36+
const val MASS_KG = 1750.0
37+
38+
const val GRAVITY_M_PER_S2 = 9.80665
39+
40+
/** Battery to wheels going up: motor, inverter and reduction gear, all of them lossy. */
41+
const val DRIVETRAIN_EFFICIENCY = 0.85
42+
43+
/**
44+
* Wheels back to battery going down, and deliberately pessimistic.
45+
*
46+
* The chain loses as much as the way up, and on top of that some of the descent is spent on
47+
* drag rather than recovered, and a steep one reaches the regeneration limit and goes to
48+
* the friction brakes. Overstating this is how a forecast promises charge that never
49+
* arrives, so it is understated instead.
50+
*/
51+
const val REGENERATION_EFFICIENCY = 0.60
52+
53+
/**
54+
* How wrong the three constants above may be, together.
55+
*
56+
* Mass is a specification plus a guess at the load, and neither efficiency was measured on
57+
* this car. Wide on purpose: a narrow band on numbers nobody measured is a false precision,
58+
* and this one feeds a stop decision.
59+
*/
60+
const val RELATIVE_UNCERTAINTY = 0.40
61+
62+
private const val JOULES_PER_KWH = 3_600_000.0
63+
64+
/**
65+
* What the profile costs in points of charge, with its band.
66+
*
67+
* Negative on a route that descends more than it climbs, which is a real effect and is not
68+
* clamped away — a car crossing the Alps towards the sea does arrive with more than the flat
69+
* arithmetic says.
70+
*/
71+
data class Cost(val percent: Double, val uncertaintyPercent: Double)
72+
73+
/**
74+
* The grade term, or null when there is nothing to say.
75+
*
76+
* Null rather than zero, always: a silent zero grade term is a claim that the road is flat,
77+
* and this cannot tell a flat road from a missing profile.
78+
*
79+
* @param ascentMetres cumulative climb over the route, metres.
80+
* @param descentMetres cumulative descent over the route, metres.
81+
* @param capacity the pack the charge is a percentage of; without it there is no bridge
82+
* between joules and state of charge.
83+
*/
84+
fun of(
85+
ascentMetres: Double?,
86+
descentMetres: Double?,
87+
capacity: BatteryCapacityConfig?,
88+
): Cost? {
89+
if (ascentMetres == null || descentMetres == null || capacity == null) return null
90+
if (!ascentMetres.isFinite() || !descentMetres.isFinite()) return null
91+
if (ascentMetres < 0.0 || descentMetres < 0.0) return null
92+
93+
val potentialPerMetreKwh = MASS_KG * GRAVITY_M_PER_S2 / JOULES_PER_KWH
94+
val spentKwh = potentialPerMetreKwh * ascentMetres / DRIVETRAIN_EFFICIENCY
95+
val recoveredKwh = potentialPerMetreKwh * descentMetres * REGENERATION_EFFICIENCY
96+
97+
val soc = capacity.socPercentForEnergy(spentKwh - recoveredKwh)
98+
val percent = soc.value ?: return null
99+
if (!percent.isFinite()) return null
100+
// The capacity's own band on top of the constants', added rather than combined in
101+
// quadrature: they are not independent errors and the wider answer is the safe one.
102+
val band = abs(percent) * RELATIVE_UNCERTAINTY + (soc.uncertainty ?: 0.0)
103+
return Cost(percent, band)
104+
}
105+
}

‎lib/src/test/java/com/evsuite/hardware/telemetry/ChargeStopPlanTest.kt‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,51 @@ class ChargeStopPlanTest {
7474
)
7575
}
7676

77+
@Test
78+
fun `a flat route plans exactly as it did before grade existed`() {
79+
val flat = RouteGrade.of(0.0, 0.0, BatteryCapacityConfig(61.7, 100.0))
80+
assertEquals(
81+
ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04)),
82+
ChargeStopPlan.of(90.0, 100.0, rate(0.4, 0.04), grade = flat),
83+
)
84+
}
85+
86+
@Test
87+
fun `a col between here and there is charge that has to be spent`() {
88+
val pack = BatteryCapacityConfig(61.7, 100.0)
89+
val plain = ChargeStopPlan.of(90.0, 200.0, rate(0.3, 0.02)) as ChargeStopPlan.Plan.NoStop
90+
val overACol = ChargeStopPlan.of(
91+
90.0, 200.0, rate(0.3, 0.02),
92+
grade = RouteGrade.of(1200.0, 400.0, pack),
93+
) as ChargeStopPlan.Plan.NoStop
94+
95+
assertTrue("the climb costs charge", overACol.arrivalPercent < plain.arrivalPercent)
96+
assertTrue("and it is less certain", overACol.bandPercent > plain.bandPercent)
97+
98+
// Downhill the other way, and the same route returns some of it.
99+
val backDown = ChargeStopPlan.of(
100+
90.0, 200.0, rate(0.3, 0.02),
101+
grade = RouteGrade.of(400.0, 1200.0, pack),
102+
) as ChargeStopPlan.Plan.NoStop
103+
assertTrue(backDown.arrivalPercent > plain.arrivalPercent)
104+
assertTrue(
105+
"the descent gives back less than the climb took",
106+
backDown.arrivalPercent - plain.arrivalPercent <
107+
plain.arrivalPercent - overACol.arrivalPercent,
108+
)
109+
}
110+
111+
@Test
112+
fun `a climb brings the stop forward rather than leaving it where it was`() {
113+
val pack = BatteryCapacityConfig(61.7, 100.0)
114+
val plain = ChargeStopPlan.of(45.0, 300.0, rate(0.4, 0.02)) as ChargeStopPlan.Plan.Stop
115+
val overACol = ChargeStopPlan.of(
116+
45.0, 300.0, rate(0.4, 0.02),
117+
grade = RouteGrade.of(1200.0, 0.0, pack),
118+
) as ChargeStopPlan.Plan.Stop
119+
assertTrue(overACol.afterKm < plain.afterKm)
120+
}
121+
77122
@Test
78123
fun `a car already below its reserve is told to stop immediately`() {
79124
val plan = ChargeStopPlan.of(8.0, 50.0, rate(0.4, 0.01))
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNotNull
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
9+
class RouteGradeTest {
10+
11+
private val pack = BatteryCapacityConfig(usableCapacityKwhWhenNew = 61.7, stateOfHealthPercent = 100.0)
12+
13+
@Test
14+
fun `a kilometre of climb costs what the physics says, near enough to check by hand`() {
15+
// 1750 kg over 1000 m is 17,2 MJ = 4,77 kWh at the wheels, 5,61 kWh from the pack at
16+
// 85 %, which is 9,1 % of 61,7 kWh.
17+
val cost = RouteGrade.of(1000.0, 0.0, pack)
18+
assertNotNull(cost)
19+
assertEquals(9.1, cost!!.percent, 0.2)
20+
assertTrue("the band carries the constants", cost.uncertaintyPercent > cost.percent * 0.4)
21+
}
22+
23+
@Test
24+
fun `a flat route costs nothing, and says so as a number rather than as silence`() {
25+
val cost = RouteGrade.of(0.0, 0.0, pack)
26+
assertEquals(0.0, cost!!.percent, 1e-9)
27+
}
28+
29+
@Test
30+
fun `a descent returns charge, but never as much as the same climb spent`() {
31+
val down = RouteGrade.of(0.0, 1000.0, pack)!!
32+
val up = RouteGrade.of(1000.0, 0.0, pack)!!
33+
assertTrue("a descent returns charge", down.percent < 0.0)
34+
assertTrue("regeneration is not free", -down.percent < up.percent)
35+
}
36+
37+
@Test
38+
fun `a round trip over a col is not a free lunch`() {
39+
val roundTrip = RouteGrade.of(1000.0, 1000.0, pack)!!
40+
val climbOnly = RouteGrade.of(1000.0, 0.0, pack)!!
41+
assertTrue("a col costs something both ways", roundTrip.percent > 0.0)
42+
assertTrue("but less than climbing twice", roundTrip.percent < climbOnly.percent)
43+
// Two thirds of a kWh per hundred metres of col, there and back. If this ever comes out
44+
// at zero the efficiencies have been made equal and the arithmetic has stopped being
45+
// physics.
46+
assertEquals(4.4, roundTrip.percent, 0.3)
47+
}
48+
49+
@Test
50+
fun `no profile is null, and null is not a flat road`() {
51+
assertNull(RouteGrade.of(null, 0.0, pack))
52+
assertNull(RouteGrade.of(0.0, null, pack))
53+
assertNull(RouteGrade.of(100.0, 100.0, null))
54+
assertNull(RouteGrade.of(Double.NaN, 0.0, pack))
55+
assertNull(RouteGrade.of(-10.0, 0.0, pack))
56+
}
57+
58+
@Test
59+
fun `a smaller pack means the same col costs a larger share of it`() {
60+
val small = RouteGrade.of(1000.0, 0.0, BatteryCapacityConfig(40.0, 100.0))!!
61+
val large = RouteGrade.of(1000.0, 0.0, pack)!!
62+
assertTrue(small.percent > large.percent)
63+
}
64+
}

0 commit comments

Comments
 (0)