@@ -16,6 +16,11 @@ package com.mg4.control.model
1616 * survit à un boîtier qui s'endort pendant la nuit ; la puissance mesurée, elle, n'existe que si
1717 * l'application a pu relever quelque chose.
1818 *
19+ * **Distance d'un trajet** : l'odomètre donne le kilomètre entier, et c'est tout ce que le
20+ * véhicule publie (les trois autres odomètres du firmware ont été sondés le 2026-09-20, tous à
21+ * zéro). La distance fine vient donc de l'intégration de la vitesse par la méthode des trapèzes ;
22+ * l'odomètre reste le garde-fou qui détecte une intégration partielle.
23+ *
1924 * **Survie aux coupures** : l'état courant est exposé par [pendingState] pour être enregistré après
2025 * chaque relevé, et [recover] rouvre ce qui restait en cours au démarrage suivant. Sans ça, tout
2126 * trajet dont la fin coïncide avec l'extinction du boîtier — c'est-à-dire la plupart — serait perdu.
@@ -71,6 +76,9 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
7176 // ── Trajet ──────────────────────────────────────────────────────────
7277 val enCours = trip
7378 if (ready == true ) {
79+ // Calculé avant la recopie : il dit à la fois combien ajouter et si l'intervalle
80+ // comptait, ce qui n'est pas la même chose qu'ajouter zéro.
81+ val pas = enCours?.let { integrationStep(it, snapshot) }
7482 trip = if (enCours == null ) PendingTrip (
7583 startMs = snapshot.timestampMs,
7684 odometerStart = snapshot.odometerKm,
@@ -84,6 +92,7 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
8492 climateLast = snapshot.climateSinceStartKwh,
8593 accessoriesLast = snapshot.accessoriesSinceStartKwh,
8694 regenLast = snapshot.regenSinceStartKwh,
95+ lastSpeedKmh = snapshot.speedKmh,
8796 ) else enCours.copy(
8897 lastMs = snapshot.timestampMs,
8998 odometerLast = snapshot.odometerKm ? : enCours.odometerLast,
@@ -92,8 +101,23 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
92101 climateLast = snapshot.climateSinceStartKwh ? : enCours.climateLast,
93102 accessoriesLast = snapshot.accessoriesSinceStartKwh ? : enCours.accessoriesLast,
94103 regenLast = snapshot.regenSinceStartKwh ? : enCours.regenLast,
104+ integratedKm = enCours.integratedKm + (pas ? : 0f ),
105+ integrated = enCours.integrated || pas != null ,
106+ // Une vitesse manquée ne réinitialise pas le trapèze : le prochain intervalle
107+ // repart de la dernière vitesse connue, ce qui sous-estime sans jamais inventer.
108+ lastSpeedKmh = snapshot.speedKmh ? : enCours.lastSpeedKmh,
95109 )
96110 } else if (ready == false && enCours != null ) {
111+ // Entre le dernier relevé sous contact et celui qui le coupe, la voiture roulait
112+ // encore : sans ce dernier trapèze, chaque trajet perdrait sa fin. Seules la distance
113+ // et l'heure de fin sont reprises — les compteurs d'énergie restent ceux du dernier
114+ // relevé sous contact, dont on sait qu'ils étaient valides.
115+ val fin = integrationStep(enCours, snapshot)
116+ if (fin != null ) trip = enCours.copy(
117+ lastMs = snapshot.timestampMs,
118+ integratedKm = enCours.integratedKm + fin,
119+ integrated = true ,
120+ )
97121 finishTrip()?.let { events + = Event .TripEnded (it) }
98122 }
99123
@@ -127,6 +151,22 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
127151 return events
128152 }
129153
154+ /* *
155+ * Distance parcourue depuis le relevé précédent, par la méthode des trapèzes.
156+ *
157+ * Rend null quand l'intervalle n'est pas mesurable : vitesse inconnue d'un côté ou de l'autre,
158+ * ou trou de plus de [MAX_SAMPLE_GAP_MS] — passé ce délai, prolonger la dernière vitesse connue
159+ * ne serait plus une mesure mais une supposition. L'intervalle est alors simplement perdu, et
160+ * la comparaison avec l'odomètre se chargera de dire que le total n'est plus fiable.
161+ */
162+ private fun integrationStep (p : PendingTrip , s : EnergySnapshot ): Float? {
163+ val avant = p.lastSpeedKmh ? : return null
164+ val apres = s.speedKmh ? : return null
165+ val dt = s.timestampMs - p.lastMs
166+ if (dt <= 0L || dt > MAX_SAMPLE_GAP_MS ) return null
167+ return (avant + apres) / 2f * (dt / 3_600_000f )
168+ }
169+
130170 /* *
131171 * Fin de trajet, à partir du dernier relevé observé. Rend null pour un trajet sans distance ni
132172 * énergie : mettre le contact pour régler la climatisation n'est pas un trajet, et remplirait
@@ -137,7 +177,10 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
137177 trip = null
138178 val distance = diffKm(p.odometerStart, p.odometerLast)
139179 val energy = counterDelta(p.energyStart, p.energyLast) ? : 0f
140- if (distance <= 0 && energy <= 0f ) return null
180+ // La distance intégrée compte dans ce test : un trajet de six cents mètres est un vrai
181+ // trajet, même si l'odomètre n'a pas changé de kilomètre.
182+ val integre = if (p.integrated) p.integratedKm.roundTenth() else null
183+ if (distance <= 0 && energy <= 0f && (integre ? : 0f ) <= 0f ) return null
141184 return Trip (
142185 startMs = p.startMs,
143186 endMs = p.lastMs,
@@ -149,6 +192,7 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
149192 socStart = p.socStart,
150193 socEnd = p.socLast,
151194 outsideTempC = p.tempC,
195+ integratedKm = integre,
152196 )
153197 }
154198
@@ -181,4 +225,13 @@ class StatsTracker(private val capacityKwh: Float = StatsSettings.DEFAULT_CAPACI
181225 if (start == null || end < start) return end
182226 return end - start
183227 }
228+
229+ companion object {
230+ /* *
231+ * Trou maximal accepté entre deux relevés pour intégrer la vitesse. Le collecteur relève
232+ * toutes les dix secondes en roulant : une minute laisse passer cinq relevés manqués, bien
233+ * au-delà d'un simple retard de l'ordonnanceur, mais sans couvrir une mise en veille.
234+ */
235+ const val MAX_SAMPLE_GAP_MS = 60_000L
236+ }
184237}
0 commit comments