Skip to content

Commit ff3e978

Browse files
malysclaude
andcommitted
feat(chargepilot): CP-086 open windows in the review, the advice and the details
A Windows tile in the Details climate grid (widest opening, %). The trip review adds a Windows finding when the window-open residual is distinguishable and above the cabin's minimum share, with the distance driven open above 60 km/h; the trip-end advice and the general advice card count it. Breakdown, attribution, diagnostic report and trip export carry the field. EVHardware to 9b0b15c. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e1753f0 commit ff3e978

14 files changed

Lines changed: 155 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
66

77
### Added
88

9+
- **Open windows, measured and counted (CP-086).** A Windows tile in the Details climate grid
10+
shows the widest opening in percent. The trip review adds a Windows finding when the window-open
11+
residual is distinguishable and above the cabin's minimum share: its kWh, its share, and the
12+
distance driven open above 60 km/h. The trip-end advice in P and the general advice card count
13+
it, and the energy breakdown, the trip attribution, the diagnostic report and the trip export
14+
carry it.
15+
916
- **Charges — a list of every charge, and what the watched ones measured (CP-082).** The battery
1017
page gains a *Charge history* button. The screen lists every charge newest first — start and
1118
end, charge before and after, duration, mean points per hour, outside temperature range and,

‎app/src/main/java/com/evsuite/chargepilot/ArrivalForecastActivity.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ class ArrivalForecastActivity : PrimaryNavigationActivity() {
247247
)
248248
bind(binding.rangeValue, R.string.label_range, readings.range, PATTERN_DISTANCE)
249249
renderClimate(readings.climate, value.firmware)
250+
bindWith(binding.windowsValue, R.string.label_windows, Provenanced.measured(value.widestWindowPercent)) {
251+
getString(R.string.windows_value, it)
252+
}
250253
bind(binding.tripDistanceValue, R.string.label_distance, readings.tripDistance, PATTERN_DISTANCE)
251254
bind(binding.tripEnergyValue, R.string.label_energy_used, readings.tripEnergy, PATTERN_ENERGY)
252255
bind(binding.tripRegenValue, R.string.label_regenerated, readings.tripRegen, PATTERN_ENERGY)

‎app/src/main/java/com/evsuite/chargepilot/DiagnosticSnapshotFormatter.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ internal object DiagnosticSnapshotFormatter {
3434
"climate.fan_level_max=${climate.fanLevelMax.value()}",
3535
"climate.driver_target_c=${climate.driverTargetCelsius.value()}",
3636
"climate.passenger_target_c=${climate.passengerTargetCelsius.value()}",
37+
"windows.widest_percent=${snapshot.widestWindowPercent.value()}",
3738
"tire.front_left_kpa=${tires.frontLeftKpa.value()}",
3839
"tire.front_right_kpa=${tires.frontRightKpa.value()}",
3940
"tire.rear_left_kpa=${tires.rearLeftKpa.value()}",

‎app/src/main/java/com/evsuite/chargepilot/EcoTripAdvice.kt‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ sealed interface EcoTip {
8383
data class EcoMode(val current: DriveMode?) : EcoTip
8484
data class SnowMode(val outsideTempCelsius: Int) : EcoTip
8585
data class Cabin(val finding: EcoFinding.Cabin) : EcoTip
86+
data class Windows(val finding: EcoFinding.Windows) : EcoTip
8687
}
8788

8889
/** The end-of-trip advice. [band] null when the fit refused; the tips may still stand. */
@@ -104,9 +105,11 @@ data class HistoryAdvice(
104105
val motorwayBasis: SpeedWhatIfBasis?,
105106
val motorwayTrips: Int,
106107
val cabinMeanPercent: Double?,
108+
val windowsMeanPercent: Double?,
107109
) {
108110
val hasAdvice: Boolean
109-
get() = heavierTrips > 0 || style != null || motorwaySaving != null || cabinMeanPercent != null
111+
get() = heavierTrips > 0 || style != null || motorwaySaving != null ||
112+
cabinMeanPercent != null || windowsMeanPercent != null
110113
}
111114

112115
/**
@@ -146,6 +149,7 @@ object EcoTripAdvice {
146149
cold <= SNOW_MAX_OUTSIDE_CELSIUS
147150
) add(EcoTip.SnowMode(cold.roundToInt()))
148151
findings.filterIsInstance<EcoFinding.Cabin>().forEach { add(EcoTip.Cabin(it)) }
152+
findings.filterIsInstance<EcoFinding.Windows>().forEach { add(EcoTip.Windows(it)) }
149153
}.take(MAX_TIPS)
150154
return TripAdvice(
151155
band = band,
@@ -195,6 +199,7 @@ object EcoTripAdvice {
195199
var style = DrivingStyle.NONE
196200
val motorway = ArrayList<EcoFinding.MotorwaySpeed>()
197201
val cabin = ArrayList<Double>()
202+
val windows = ArrayList<Double>()
198203
for (trip in recent) {
199204
style += DrivingStyle.measure(trip.samples.orEmpty())
200205
val ready = reviews[trip.summary.startedAtMs] as? EcoTripReview.Ready ?: continue
@@ -208,6 +213,7 @@ object EcoTripAdvice {
208213
when (finding) {
209214
is EcoFinding.MotorwaySpeed -> motorway += finding
210215
is EcoFinding.Cabin -> cabin += finding.sharePercent
216+
is EcoFinding.Windows -> windows += finding.sharePercent
211217
is EcoFinding.Steadiness -> Unit
212218
}
213219
}
@@ -228,6 +234,7 @@ object EcoTripAdvice {
228234
motorwayTrips = sameBasis.size,
229235
// One cabin-heavy trip is weather; two are a habit worth naming.
230236
cabinMeanPercent = cabin.takeIf { it.size >= 2 }?.average(),
237+
windowsMeanPercent = windows.takeIf { it.size >= 2 }?.average(),
231238
)
232239
}
233240

@@ -308,6 +315,9 @@ class EcoAdviceText(context: Context) {
308315
advice.cabinMeanPercent?.let {
309316
lines += app.getString(R.string.eco_history_cabin, it.roundToInt())
310317
}
318+
advice.windowsMeanPercent?.let {
319+
lines += app.getString(R.string.eco_history_windows, it.roundToInt())
320+
}
311321
return lines
312322
}
313323

@@ -329,7 +339,7 @@ class EcoAdviceText(context: Context) {
329339
is EcoTip.EcoMode -> app.getString(R.string.eco_clause_eco_mode)
330340
is EcoTip.SlowerMotorway ->
331341
app.getString(R.string.eco_clause_motorway, tip.finding.referenceSpeedKmh)
332-
is EcoTip.SnowMode, is EcoTip.Cabin -> null
342+
is EcoTip.SnowMode, is EcoTip.Cabin, is EcoTip.Windows -> null
333343
}
334344

335345
private fun sentence(tip: EcoTip): String = when (tip) {
@@ -351,5 +361,11 @@ class EcoAdviceText(context: Context) {
351361
)
352362
is EcoTip.Cabin ->
353363
app.getString(R.string.trip_eco_finding_cabin, tip.finding.kwh, tip.finding.sharePercent)
364+
is EcoTip.Windows -> app.getString(
365+
R.string.trip_eco_finding_windows,
366+
tip.finding.kwh,
367+
tip.finding.sharePercent,
368+
tip.finding.openDistanceKm,
369+
)
354370
}
355371
}

‎app/src/main/java/com/evsuite/chargepilot/EcoTripReview.kt‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ sealed interface EcoFinding {
4040
val sharePercent: Double,
4141
) : EcoFinding
4242

43+
/**
44+
* CP-086. The window-open intervals' fitted share of this trip, and how far the car went
45+
* open above [EcoTripReviewer.WINDOWS_SPEED_KMH], where the drag is what an open window costs.
46+
*/
47+
data class Windows(
48+
val kwh: Double,
49+
val uncertaintyKwh: Double,
50+
val sharePercent: Double,
51+
val openDistanceKm: Double,
52+
) : EcoFinding
53+
4354
/** The share of moving time spent accelerating hard, from the track's speed channel. */
4455
data class Steadiness(val harshSharePercent: Double) : EcoFinding
4556
}
@@ -70,7 +81,7 @@ sealed interface EcoTripReview {
7081
* **Ranked by how strong the evidence is, not by a common cost.** A saving in kilowatt-hours, a
7182
* fitted cabin share and a percentage of a drive have no exchange rate, and inventing one to sort
7283
* them would invent the most important of the three. Order is therefore fixed and stated: the
73-
* measured saving first, the fitted share next, the style share last.
84+
* measured saving first, the fitted shares next (cabin, then windows), the style share last.
7485
*
7586
* **What it refuses.** A model that has not trained, an attribution that could not reconcile, a
7687
* what-if with no motorway in the trip, a track that was never recorded — each drops its own
@@ -85,6 +96,13 @@ object EcoTripReviewer {
8596
/** Below this the cabin is inside the fit's own error and saying so would be noise. */
8697
const val MIN_CABIN_SHARE_PERCENT = 5.0
8798

99+
/** CP-086. Below this an open window is air, above it the drag starts to count. */
100+
const val WINDOWS_SPEED_KMH = 60.0
101+
102+
/** Two samples further apart than this are a gap in the track, not a distance driven. */
103+
private const val MAX_SAMPLE_GAP_MS = 120_000L
104+
private const val MILLIS_PER_HOUR = 3_600_000.0
105+
88106
fun review(
89107
trip: StoredTrip,
90108
socModel: SocConsumptionModel?,
@@ -102,6 +120,7 @@ object EcoTripReviewer {
102120
val findings = listOfNotNull(
103121
motorway(whatIf),
104122
cabin(attribution),
123+
windows(trip, attribution),
105124
steadiness(monitor),
106125
).take(MAX_FINDINGS)
107126
if (verdict.band.value == null && findings.isEmpty()) {
@@ -140,6 +159,11 @@ object EcoTripReviewer {
140159
" kwh=${format(finding.kwh)}" +
141160
" uncertainty_kwh=${format(finding.uncertaintyKwh)}" +
142161
" share_percent=${format(finding.sharePercent)}"
162+
is EcoFinding.Windows -> "finding=WINDOWS" +
163+
" kwh=${format(finding.kwh)}" +
164+
" uncertainty_kwh=${format(finding.uncertaintyKwh)}" +
165+
" share_percent=${format(finding.sharePercent)}" +
166+
" open_km_above_60=${format(finding.openDistanceKm)}"
143167
is EcoFinding.Steadiness ->
144168
"finding=STEADINESS harsh_share_percent=${format(finding.harshSharePercent)}"
145169
}
@@ -191,6 +215,47 @@ object EcoTripReviewer {
191215
)
192216
}
193217

218+
/**
219+
* CP-086. The same test as [cabin], on the window-open residual: distinguishable and above the
220+
* same minimum share, or nothing.
221+
*/
222+
private fun windows(trip: StoredTrip, attribution: EnergyAttributionResult?): EcoFinding.Windows? {
223+
val ready = attribution as? EnergyAttributionResult.Ready ?: return null
224+
val open = ready.attribution.residuals.filter {
225+
it.context == ResidualContext.WINDOWS_OPEN &&
226+
it.finding == ResidualFinding.DISTINGUISHABLE
227+
}
228+
if (open.isEmpty()) return null
229+
val kwh = open.sumOf { it.estimate.valueKwh }
230+
val total = ready.attribution.totalConsumedKwh.value ?: return null
231+
if (total <= 0.0) return null
232+
val share = kwh / total * 100.0
233+
if (share < MIN_CABIN_SHARE_PERCENT) return null
234+
return EcoFinding.Windows(
235+
kwh = kwh,
236+
uncertaintyKwh = open.sumOf { it.estimate.uncertaintyKwh },
237+
sharePercent = share,
238+
openDistanceKm = openFastDistanceKm(trip),
239+
)
240+
}
241+
242+
/** Trapezoid distance over the intervals open and above the speed at both ends. */
243+
private fun openFastDistanceKm(trip: StoredTrip): Double =
244+
trip.samples.orEmpty().zipWithNext().sumOf { (previous, current) ->
245+
val durationMs = current.atMs - previous.atMs
246+
val from = previous.speedKmh?.toDouble()
247+
val to = current.speedKmh?.toDouble()
248+
val open = (previous.widestWindowPercent ?: 0) > 0 &&
249+
(current.widestWindowPercent ?: 0) > 0
250+
if (!open || from == null || to == null || durationMs !in 1..MAX_SAMPLE_GAP_MS ||
251+
from <= WINDOWS_SPEED_KMH || to <= WINDOWS_SPEED_KMH
252+
) {
253+
0.0
254+
} else {
255+
(from + to) / 2.0 * durationMs / MILLIS_PER_HOUR
256+
}
257+
}
258+
194259
/**
195260
* Whatever the share, once the track holds a minute of movement. A calm drive is a result
196261
* too: below the live advice threshold it used to drop out, and a calm drive with no fitted

‎app/src/main/java/com/evsuite/chargepilot/EnergyBreakdownActivity.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ class EnergyBreakdownActivity : AppCompatActivity() {
299299
ResidualContext.CLIMATE_ACTIVE -> R.string.energy_breakdown_residual_active
300300
ResidualContext.CLIMATE_INACTIVE -> R.string.energy_breakdown_residual_inactive
301301
ResidualContext.CLIMATE_UNKNOWN -> R.string.energy_breakdown_residual_unknown
302+
ResidualContext.WINDOWS_OPEN -> R.string.energy_breakdown_residual_windows
302303
}
303304

304305
private fun residualValue(value: ResidualAttribution): String = when (value.finding) {

‎app/src/main/java/com/evsuite/chargepilot/TripExporter.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ class TripExporter(
171171
addNullable("climateAcOn", sample.climateAcOn)
172172
sample.climateFanLevel?.let { addProperty("climateFanLevel", it) }
173173
?: add("climateFanLevel", JsonNull.INSTANCE)
174+
sample.widestWindowPercent?.let { addProperty("widestWindowPercent", it) }
175+
?: add("widestWindowPercent", JsonNull.INSTANCE)
174176
}
175177

176178
private fun JsonObject.addFiniteOrNull(name: String, value: Double?) {

‎app/src/main/java/com/evsuite/chargepilot/TripHistoryActivity.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,12 @@ class TripHistoryActivity : PrimaryNavigationActivity() {
564564
)
565565
is EcoFinding.Cabin ->
566566
getString(R.string.trip_eco_finding_cabin, finding.kwh, finding.sharePercent)
567+
is EcoFinding.Windows -> getString(
568+
R.string.trip_eco_finding_windows,
569+
finding.kwh,
570+
finding.sharePercent,
571+
finding.openDistanceKm,
572+
)
567573
is EcoFinding.Steadiness -> getString(
568574
R.string.trip_eco_finding_steadiness,
569575
finding.harshSharePercent.roundToInt(),
@@ -623,6 +629,7 @@ class TripHistoryActivity : PrimaryNavigationActivity() {
623629
ResidualContext.CLIMATE_ACTIVE -> R.string.trip_attribution_climate_active
624630
ResidualContext.CLIMATE_INACTIVE -> R.string.trip_attribution_climate_inactive
625631
ResidualContext.CLIMATE_UNKNOWN -> R.string.trip_attribution_climate_unknown
632+
ResidualContext.WINDOWS_OPEN -> R.string.trip_attribution_windows_open
626633
},
627634
)
628635
return getString(

‎app/src/main/res/layout/activity_arrival_forecast.xml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@
119119
<TextView style="@style/Text.EV.ReadoutLabel" android:text="@string/label_climate_passenger_target" />
120120
<TextView android:id="@+id/climatePassengerTargetValue" style="@style/Text.EV.ClimateValue" />
121121
</LinearLayout>
122+
<!-- CP-086. The widest opening: an open window is a consumption factor, like
123+
the climate beside it. -->
124+
<LinearLayout style="@style/Widget.EV.ClimateReadout" android:layout_weight="1">
125+
<TextView style="@style/Text.EV.ReadoutLabel" android:text="@string/label_windows" />
126+
<TextView android:id="@+id/windowsValue" style="@style/Text.EV.ClimateValue" />
127+
</LinearLayout>
122128
</LinearLayout>
123129

124130
<View

0 commit comments

Comments
 (0)