|
| 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 | +) |
0 commit comments