Skip to content

Commit 10e5876

Browse files
malysclaude
andcommitted
feat(telemetry): CP-090 driving battery stress per trip and month
Hard discharge at low charge or in the cold, and cold regeneration, measured per trip with the power sign oriented by the trip's own falling charge; unknown stays null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8edb356 commit 10e5876

3 files changed

Lines changed: 187 additions & 0 deletions

File tree

‎CHANGELOG.md‎

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

4949
### Added
5050

51+
- **What a drive asked of the pack at the moments it minds most (CP-090).** `DrivingStress.of`
52+
measures, per trip, the minutes of hard discharge (above 60 % of the trip's own peak) below
53+
20 % charge or below 0 °C outside, and the energy regenerated below 0 °C. The pack power sign
54+
is unvalidated (RI-002), so each trip orients itself from its falling charge; a trip that
55+
cannot, or with more than a tenth of its time undecidable, gives null rather than zero.
56+
`DrivingStress.monthly` sums the trips that could say, per local month. Measured quantities,
57+
never a wear figure.
5158
- **How long the rest of a charge takes (CP-089).** `ChargeDuration.estimate` sums, band by
5259
10-point band, the minutes per point the driver's own watched charges took: the median, and the
5360
fastest and slowest charge as the range. Charges at a similar outside temperature (±8 °C) are
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import java.time.Instant
4+
import java.time.YearMonth
5+
import java.time.ZoneId
6+
7+
/**
8+
* What a drive asked of the pack at the moments a pack minds most (CP-090): hard discharge at a
9+
* low charge or in the cold, and regeneration pushed into a cold pack.
10+
*
11+
* Measured quantities only, never a wear figure. "Hard" is the trip's own: discharge above
12+
* [HARD_SHARE] of that trip's peak, so a gentle driver's hardest moments count as well as a
13+
* heavy one's.
14+
*
15+
* [TripSample.batteryPowerKw]'s sign is unvalidated on this car (RI-002), so each trip orients
16+
* itself: the charge fell, so the way the power integral moved over the trip is discharge. A trip
17+
* whose charge did not fall, or whose integral did not move, cannot say which way is which and
18+
* gives no figure.
19+
*/
20+
data class DrivingStress(
21+
/** Minutes of hard discharge below [LOW_SOC_PERCENT] charge or below [COLD_CELSIUS] outside. */
22+
val stressedMinutes: Double,
23+
/** Energy regenerated below [COLD_CELSIUS] outside; null when the temperature was never read. */
24+
val coldRegeneratedKwh: Double?,
25+
) {
26+
companion object {
27+
28+
/**
29+
* Null when the trip cannot say: no orientation, or more than [MAX_UNDECIDED_SHARE] of
30+
* its time with an unread power, or a hard moment with an unread charge or temperature.
31+
*/
32+
fun of(samples: List<TripSample>): DrivingStress? {
33+
val socs = samples.mapNotNull { it.socPercent }
34+
if (socs.size < 2 || socs.first() - socs.last() < MIN_SOC_DROP) return null
35+
val segments = samples.zipWithNext().filter { (a, b) -> b.atMs - a.atMs in 1..MAX_GAP_MS }
36+
val net = segments.sumOf { (a, b) -> (a.batteryPowerKw ?: 0f) * hours(a, b) }
37+
val orientation = when {
38+
net >= MIN_NET_KWH -> 1.0
39+
net <= -MIN_NET_KWH -> -1.0
40+
else -> return null
41+
}
42+
val peak = samples.mapNotNull { it.batteryPowerKw }.maxOfOrNull { it * orientation } ?: return null
43+
if (peak <= 0.0) return null
44+
45+
var totalMs = 0L
46+
var undecidedMs = 0L
47+
var stressedMs = 0L
48+
var coldKnown = false
49+
var coldRegen = 0.0
50+
segments.forEach { (a, b) ->
51+
val ms = b.atMs - a.atMs
52+
totalMs += ms
53+
// An unread power could have been a hard one.
54+
val power = a.batteryPowerKw ?: run { undecidedMs += ms; return@forEach }
55+
val discharge = power * orientation
56+
val cold = a.outsideTempCelsius?.let { it < COLD_CELSIUS }
57+
val low = a.socPercent?.let { it < LOW_SOC_PERCENT }
58+
if (cold != null) coldKnown = true
59+
if (cold == true && discharge < 0.0) coldRegen += -discharge * hours(a, b)
60+
if (discharge <= HARD_SHARE * peak) return@forEach
61+
when {
62+
cold == true || low == true -> stressedMs += ms
63+
cold == null || low == null -> undecidedMs += ms
64+
}
65+
}
66+
if (totalMs == 0L || undecidedMs > MAX_UNDECIDED_SHARE * totalMs) return null
67+
return DrivingStress(stressedMs / MINUTE_MS, coldRegen.takeIf { coldKnown })
68+
}
69+
70+
/** Per local month, over the trips that could say; a month none could say is absent. */
71+
fun monthly(trips: List<StoredTrip>, zone: ZoneId): List<MonthlyDrivingStress> = trips
72+
.mapNotNull { trip -> of(trip.samples.orEmpty())?.let { trip.summary.startedAtMs to it } }
73+
.groupBy { (atMs, _) -> YearMonth.from(Instant.ofEpochMilli(atMs).atZone(zone)) }
74+
.map { (month, list) ->
75+
val regen = list.mapNotNull { it.second.coldRegeneratedKwh }
76+
MonthlyDrivingStress(
77+
month = month,
78+
stressedMinutes = list.sumOf { it.second.stressedMinutes },
79+
coldRegeneratedKwh = regen.takeIf { it.isNotEmpty() }?.sum(),
80+
trips = list.size,
81+
)
82+
}
83+
.sortedBy { it.month }
84+
85+
private fun hours(a: TripSample, b: TripSample) = (b.atMs - a.atMs) / HOUR_MS
86+
87+
const val HARD_SHARE = 0.6
88+
const val LOW_SOC_PERCENT = 20f
89+
const val COLD_CELSIUS = 0f
90+
/** A trip that spent less than a point cannot orient its power. */
91+
const val MIN_SOC_DROP = 1f
92+
const val MIN_NET_KWH = 0.1
93+
/** A longer hole in the track is a pause, not a stretch of known power. */
94+
const val MAX_GAP_MS = 5 * 60_000L
95+
/** A dropped reading here and there is not a blind trip; more than this is. */
96+
const val MAX_UNDECIDED_SHARE = 0.1
97+
private const val MINUTE_MS = 60_000.0
98+
private const val HOUR_MS = 3_600_000.0
99+
}
100+
}
101+
102+
data class MonthlyDrivingStress(
103+
val month: YearMonth,
104+
val stressedMinutes: Double,
105+
val coldRegeneratedKwh: Double?,
106+
/** Trips that could say; the others are not counted as zero. */
107+
val trips: Int,
108+
)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.evsuite.hardware.telemetry
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNull
5+
import org.junit.Test
6+
7+
class DrivingStressTest {
8+
9+
/**
10+
* Ten minutes at one sample every ten seconds, the charge falling two points. Minutes 0, 3
11+
* and 6 pull 60 kW, minute 9 regenerates 10 kW, the rest cruise at 20 kW.
12+
*/
13+
private fun track(
14+
soc: Float?,
15+
outside: Float?,
16+
sign: Float = 1f,
17+
power: (Int) -> Float? = { i ->
18+
when (i / 6) {
19+
0, 3, 6 -> 60f
20+
9 -> -10f
21+
else -> 20f
22+
}
23+
},
24+
) = (0 until 60).map { i ->
25+
TripSample(
26+
atMs = i * 10_000L,
27+
speedKmh = 50f,
28+
batteryPowerKw = power(i)?.times(sign),
29+
socPercent = soc?.minus(i / 30f),
30+
outsideTempCelsius = outside,
31+
cabinTempCelsius = null,
32+
batteryTempCelsius = null,
33+
climatePowerOn = null,
34+
climateAcOn = null,
35+
climateFanLevel = null,
36+
)
37+
}
38+
39+
@Test
40+
fun `hard power in the cold is counted, and cold regeneration summed`() {
41+
val stress = DrivingStress.of(track(soc = 60f, outside = -5f))!!
42+
assertEquals(3.0, stress.stressedMinutes, 1e-9)
43+
assertEquals(50 * 10.0 / 3600.0, stress.coldRegeneratedKwh!!, 1e-9)
44+
}
45+
46+
@Test
47+
fun `the trip orients its own power sign`() {
48+
assertEquals(DrivingStress.of(track(soc = 60f, outside = -5f)), DrivingStress.of(track(60f, -5f, sign = -1f)))
49+
}
50+
51+
@Test
52+
fun `a warm drive at a good charge is a measured zero`() {
53+
assertEquals(DrivingStress(0.0, 0.0), DrivingStress.of(track(soc = 60f, outside = 15f)))
54+
}
55+
56+
@Test
57+
fun `low charge alone is enough, and an unread temperature leaves the regeneration unknown`() {
58+
val stress = DrivingStress.of(track(soc = 19f, outside = null))!!
59+
assertEquals(3.0, stress.stressedMinutes, 1e-9)
60+
assertNull(stress.coldRegeneratedKwh)
61+
}
62+
63+
@Test
64+
fun `unknown is never zero`() {
65+
// Hard moments at a good charge with no temperature: they cannot be decided.
66+
assertNull(DrivingStress.of(track(soc = 60f, outside = null)))
67+
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { null }))
68+
assertNull(DrivingStress.of(track(soc = null, outside = -5f)))
69+
// A third of the power unread.
70+
assertNull(DrivingStress.of(track(soc = 60f, outside = -5f) { i -> if (i % 3 == 0) null else 20f }))
71+
}
72+
}

0 commit comments

Comments
 (0)