"""Trip computer + performance timers (computed from live data, no serial). TripComputer estimates fuel economy and trip totals from vehicle speed (km/h) and MAF (g/s) -- the same MAF-based estimate Torque/etc. use. PerformanceMeter measures 0-60 mph and 1/4-mile times, auto-detecting a launch from a near-stop. Both are fed time-stamped samples and are unit tested without hardware. """ KMH_PER_MPH = 1.609344 GAS_AFR = 14.7 # stoichiometric air:fuel for gasoline GAS_G_PER_GAL = 2835.0 # ~6.25 lb/gal def _gph(maf_gps, afr=GAS_AFR, g_per_gal=GAS_G_PER_GAL): return (maf_gps / afr) * 3600.0 / g_per_gal class TripComputer: def __init__(self, afr=GAS_AFR, g_per_gal=GAS_G_PER_GAL): self.afr, self.g_per_gal = afr, g_per_gal self.reset() def reset(self): self.dist_km = 0.0 self.fuel_gal = 0.0 self.moving_s = 0.0 self.elapsed_s = 0.0 self._t = None def update(self, t, speed_kmh, maf_gps): if self._t is None: self._t = t return dt = t - self._t self._t = t if dt <= 0 or dt > 5: # ignore gaps / first sample return self.elapsed_s += dt if speed_kmh: self.dist_km += speed_kmh * dt / 3600.0 if speed_kmh > 1.0: self.moving_s += dt if maf_gps: self.fuel_gal += _gph(maf_gps, self.afr, self.g_per_gal) * dt / 3600.0 def instant_mpg(self, speed_kmh, maf_gps): if not speed_kmh or not maf_gps: return 0.0 gph = _gph(maf_gps, self.afr, self.g_per_gal) return (speed_kmh / KMH_PER_MPH) / gph if gph > 0 else 0.0 def avg_mpg(self): miles = self.dist_km / KMH_PER_MPH return miles / self.fuel_gal if self.fuel_gal > 0 else 0.0 def stats(self): return { "distance_mi": round(self.dist_km / KMH_PER_MPH, 2), "fuel_gal": round(self.fuel_gal, 3), "avg_mpg": round(self.avg_mpg(), 1), "elapsed_s": round(self.elapsed_s, 1), "moving_s": round(self.moving_s, 1), } class PerformanceMeter: """0-60 mph and 1/4-mile timers. A run starts when the car pulls away from a near-stop and ends at the 1/4 mile or when it stops; best times are kept.""" QUARTER_M = 402.336 def __init__(self): self.best_0_60 = None self.best_quarter = None self.last_0_60 = None self.last_quarter = None self._reset_run() def _reset_run(self): self._running = False self._t0 = None self._dist_m = 0.0 self._last_t = None self._stop_t = None self._did60 = False def update(self, t, speed_kmh): if speed_kmh is None: return mph = speed_kmh / KMH_PER_MPH if not self._running: if mph < 1.0: self._stop_t = t # parked, ready to launch elif self._stop_t is not None: self._running = True # launch detected self._t0 = self._stop_t self._dist_m = 0.0 self._last_t = t self._did60 = False return dt = t - self._last_t self._last_t = t if dt <= 0 or dt > 5: self._reset_run() return self._dist_m += (speed_kmh / 3.6) * dt if mph >= 60 and not self._did60: self._did60 = True self.last_0_60 = round(t - self._t0, 2) if self.best_0_60 is None or self.last_0_60 < self.best_0_60: self.best_0_60 = self.last_0_60 if self._dist_m >= self.QUARTER_M: self.last_quarter = round(t - self._t0, 2) if self.best_quarter is None or self.last_quarter < self.best_quarter: self.best_quarter = self.last_quarter self._reset_run() elif mph < 1.0: self._reset_run()