/// Rolling 5 km fuel economy calculated from the ECU fuel rate or MAF. /// /// The ECU's engine-fuel-rate PID (`5E`) is preferred because it uses the same /// injection data as the car's trip computer and works for petrol and diesel. /// When that PID is unavailable, MAF and petrol stoichiometric constants /// (AFR 14.7, 745 g/L) are used as a fallback. The fallback is deliberately an /// estimate; the dashboard presents it as a trend, not a measurement. class ObdEconomy { static const windowKm = 5.0; static const minimumKm = 0.5; static const _maxGap = Duration(seconds: 30); static const _afr = 14.7; static const _fuelDensityGramsPerLitre = 745.0; static const _maxPlausibleFuelRateLph = 200.0; final List<_Segment> _segments = []; DateTime? _lastAt; bool _hasPrevious = false; double? get litresPer100Km { if (_segments.isEmpty) return null; var km = 0.0; var litres = 0.0; for (final segment in _segments) { km += segment.km; litres += segment.litres; } if (km < minimumKm || km <= 0) return null; return (100 * litres / km).clamp(0.0, 99.9); } double get accumulatedKm { return _segments.fold(0, (total, segment) => total + segment.km); } void reset() { _segments.clear(); _lastAt = null; _hasPrevious = false; } void add({ required DateTime at, required double? speedKmh, required double? mafGps, required double? throttlePercent, double? fuelRateLph, }) { final previousAt = _lastAt; _lastAt = at; // A supported PID can briefly return a placeholder or a corrupt frame. // Ignore implausible values and fall back to MAF for this sample. final directFuelRate = fuelRateLph != null && fuelRateLph >= 0 && fuelRateLph <= _maxPlausibleFuelRateLph ? fuelRateLph : null; if (speedKmh == null || (directFuelRate == null && mafGps == null)) { _hasPrevious = false; return; } if (previousAt == null || !_hasPrevious || at.difference(previousAt) > _maxGap || at.isBefore(previousAt)) { if (previousAt != null && at.difference(previousAt) > _maxGap) { reset(); } _lastAt = at; _hasPrevious = true; return; } final deltaSeconds = at.difference(previousAt).inMilliseconds / 1000.0; if (deltaSeconds <= 0) return; final double litresPerHour; if (directFuelRate != null) { litresPerHour = directFuelRate; } else { final fuelCut = throttlePercent != null && throttlePercent < 1 && speedKmh > 10; litresPerHour = fuelCut ? 0.0 : (mafGps! / _afr) * 3600 / _fuelDensityGramsPerLitre; } final km = speedKmh * deltaSeconds / 3600; if (km > 0) { _segments.add( _Segment(km: km, litres: litresPerHour * deltaSeconds / 3600), ); _trim(); } _lastAt = at; _hasPrevious = true; } void _trim() { var km = accumulatedKm; while (km > windowKm && _segments.isNotEmpty) { km -= _segments.removeAt(0).km; } } } class _Segment { const _Segment({required this.km, required this.litres}); final double km; final double litres; }