@@ -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
0 commit comments