From dbbfb9f803e0bee89970ec923a592da03840a936 Mon Sep 17 00:00:00 2001 From: TCFDONMEZ Date: Fri, 2 Oct 2026 11:15:22 +0300 Subject: [PATCH] Fix consumption tracking while charging --- CHANGELOG.md | 41 +++ FatihsMG4-unstable-2.2.33.apk.sha256 | 1 + app/build.gradle | 4 +- .../java/com/evsuite/abrp/ChargingState.java | 46 +++ .../com/evsuite/abrp/ConsumptionMath.java | 100 ++++++ .../com/evsuite/abrp/ConsumptionTracker.java | 294 +++++++++++------- .../main/java/com/evsuite/abrp/KahanSum.java | 38 +++ .../java/com/evsuite/abrp/MainActivity.java | 5 +- .../com/evsuite/abrp/ChargingStateTest.java | 74 +++++ .../com/evsuite/abrp/ConsumptionMathTest.java | 159 ++++++++++ 10 files changed, 643 insertions(+), 119 deletions(-) create mode 100644 FatihsMG4-unstable-2.2.33.apk.sha256 create mode 100644 app/src/main/java/com/evsuite/abrp/ChargingState.java create mode 100644 app/src/main/java/com/evsuite/abrp/ConsumptionMath.java create mode 100644 app/src/main/java/com/evsuite/abrp/KahanSum.java create mode 100644 app/src/test/java/com/evsuite/abrp/ChargingStateTest.java create mode 100644 app/src/test/java/com/evsuite/abrp/ConsumptionMathTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d774e1f..a0fab3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.33] - 2026-10-02 + +### Fixed + +- The SOC total no longer loses its meaning after a charge. The consumption counters + decided whether the car was charging from `EV_CHARGE_PORT_CONNECTED` alone, and this + vehicle's VHAL answers `false` to it with the cable in — the documented reason + `ChargingSignal` exists. A charge therefore read as a drive, and every percent gained + was booked as negative consumption against the total. +- The charging screen and the consumption counters now share one answer, in the pure + `ChargingState`, so they cannot drift apart again. A charge is the vendor status saying + so, or power flowing into the pack with the port agreeing or the car standing still — + a stationary car cannot regenerate. + +### Changed + +- The stored SOC column is zeroed once on upgrade. Distance, energy and time never + consulted the charge state, are unaffected by the bug, and are kept. + +## [2.2.32] - 2026-10-02 + +### Fixed + +- Consumption now disagrees with neither itself nor the car. Lifetime was integrated from + net DC power while the day and trip counters took either the vehicle's own cumulative + kWh register or a fallback that discarded regeneration — three sources, three answers, + and the fallback read high because recovered energy never subtracted. Every period is + now integrated from one step of DriveHub_Dort's algorithm: trapezoidal net DC power, + trapezoidal calibrated speed, and compensated (Kahan) accumulation. +- Integration intervals are measured on the monotonic clock. The head unit's wall clock + jumps when it picks up GPS or NTP time, which previously either dropped intervals or + invented hours of driving from a single forward jump. + +### Changed + +- Consumption counters start from zero on this release. The stored totals were produced by + the mixed algorithms above and no factor converts them into what is now measured, so the + old store is discarded rather than carried forward as a plausible-looking total. +- `ConsumptionMath` holds the integration as a pure, unit-tested class, per the repository's + rule that logic lives outside the Android classes. + ## [2.2.18] - 2026-09-30 ### Changed diff --git a/FatihsMG4-unstable-2.2.33.apk.sha256 b/FatihsMG4-unstable-2.2.33.apk.sha256 new file mode 100644 index 0000000..cf6afda --- /dev/null +++ b/FatihsMG4-unstable-2.2.33.apk.sha256 @@ -0,0 +1 @@ +7b02485c155facba0af522861cee88760c62ecdcc366c4d885d473595d2750b2 FatihsMG4-unstable-2.2.33.apk diff --git a/app/build.gradle b/app/build.gradle index 4885900..ad4fe51 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.evsuite.abrp" minSdk 27 targetSdk 36 - versionCode 48 - versionName "2.2.31" + versionCode 50 + versionName "2.2.33" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/evsuite/abrp/ChargingState.java b/app/src/main/java/com/evsuite/abrp/ChargingState.java new file mode 100644 index 0000000..3c5f130 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ChargingState.java @@ -0,0 +1,46 @@ +package com.evsuite.abrp; + +/** + * Is the pack taking energy right now? Pure, unit-tested, and the single answer the + * charging screen and the consumption counters both use. + * + * They used to answer it separately: the screen with a composite test, the counters with + * the raw {@code EV_CHARGE_PORT_CONNECTED} property alone. That property is optional in + * AAOS and this vehicle's VHAL answers {@code false} on a plugged-in car — see + * {@link ChargingSignal}, which exists for the same reason. So the counters read a charge + * as a drive, SOC climbed instead of falling, and every rising percent was booked as + * negative consumption against the lifetime total. + */ +final class ChargingState { + + /** Charge in progress, as the vendor charging-status property reports it. */ + private static final int STATUS_AC_CHARGING = 1; + private static final int STATUS_DC_CHARGING = 10; + + /** Below this (i.e. more negative than) the pack is taking energy, not reading noise. */ + static final float CHARGING_POWER_KW = -0.3f; + + /** At or under this the car is standing still; same dead band the integrator uses. */ + private static final float STANDSTILL_KMH = ConsumptionMath.SPEED_DEAD_BAND_KMH; + + /** + * @param status vendor charging status, null when unreadable + * @param portConnected charge port property, null when unreadable — and false on this + * car even when the cable is in, which is why it cannot stand alone + * @param powerKw battery power, negative into the pack, null when unreadable + * @param speedKmh road speed, null when unreadable + */ + static boolean isCharging(Integer status, Boolean portConnected, Float powerKw, Float speedKmh) { + if (status != null && (status == STATUS_AC_CHARGING || status == STATUS_DC_CHARGING)) { + return true; + } + if (powerKw == null || powerKw >= CHARGING_POWER_KW) return false; + + // Power is flowing in. Either the port agrees, or the car is not moving — and a + // stationary car cannot regenerate, so inbound power at a standstill is a charge. + if (Boolean.TRUE.equals(portConnected)) return true; + return speedKmh == null || Math.abs(speedKmh) < STANDSTILL_KMH; + } + + private ChargingState() { } +} diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionMath.java b/app/src/main/java/com/evsuite/abrp/ConsumptionMath.java new file mode 100644 index 0000000..dec1731 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionMath.java @@ -0,0 +1,100 @@ +package com.evsuite.abrp; + +/** + * One integration step of distance, energy, time and SOC — pure, Android-free, unit-tested. + * + * This is DriveHub_Dort's algorithm, which has proven stable on this car over months, and + * it is now the *only* algorithm: every period (lifetime, day, trip A, trip B) is fed from + * the same step. Earlier builds integrated the lifetime counter from net DC power while + * feeding the trip and day counters from either the vehicle's own cumulative kWh register + * or a regeneration-discarding gross fallback. Three sources, three answers, none of them + * agreeing with the car's dashboard — the counters were not measuring the same quantity. + * + * What the vehicle reports is net DC battery power: positive out of the pack, negative in. + * Integrating it as-is means regeneration subtracts, which is exactly what the car's own + * kWh figure does. Discarding the negative half (the old {@code max(0, kW)} fallback) is + * what made our number read high. + */ +final class ConsumptionMath { + + /** + * Longest gap we will still integrate across, in hours (6 minutes). + * + * A head unit that slept, a service that was killed, or a car that sat parked overnight + * all show up as one enormous dt. Bridging it would invent a drive that never happened, + * so the gap is dropped and the next sample starts a fresh interval. + */ + static final double MAX_GAP_HOURS = 0.1d; + + /** Below this the speed signal is noise around a standstill, not motion. */ + static final float SPEED_DEAD_BAND_KMH = 2.5f; + + /** A reading past this is a decode artefact, not power the pack could ever deliver. */ + static final float MAX_PLAUSIBLE_KW = 3000f; + + /** Result of one interval. All fields are increments, never totals. */ + static final class Step { + final double km, kwh, hours, socDrop; + + Step(double km, double kwh, double hours, double socDrop) { + this.km = km; + this.kwh = kwh; + this.hours = hours; + this.socDrop = socDrop; + } + } + + /** + * Integrates one interval, or returns null when the interval carries no usable data. + * + * Every input is nullable because a failed vehicle read is not a zero: a missing power + * reading suppresses the energy term for this interval only, and leaves distance and + * time — which do not depend on it — intact. + * + * @param hours elapsed time, from a monotonic clock + * @param charging whether the charge port is connected; SOC gained on a charger is not + * consumption and must not offset the drive's SOC drop + */ + static Step integrate(double hours, + Float previousSpeedKmh, Float speedKmh, + Float previousPowerKw, Float powerKw, + Float previousSocPercent, Float socPercent, + boolean charging) { + if (!(hours > 0d) || hours > MAX_GAP_HOURS) return null; + + // Distance: trapezoidal speed across the interval. The calibration factors correct + // the head unit's speed signal against the odometer at motorway speeds, where the + // reported value runs slightly low. + float current = speedKmh != null ? Math.abs(speedKmh) + : (previousSpeedKmh != null ? Math.abs(previousSpeedKmh) : 0f); + float previous = previousSpeedKmh != null ? Math.abs(previousSpeedKmh) : current; + float effectiveKmh = (previous + current) * 0.5f; + if (effectiveKmh < SPEED_DEAD_BAND_KMH) effectiveKmh = 0f; + else if (effectiveKmh > 90f) effectiveKmh *= 1.0035f; + else if (effectiveKmh > 30f) effectiveKmh *= 1.0015f; + double km = effectiveKmh * hours; + + // Energy: trapezoidal net DC power, regeneration included with its own sign. + double kwh = 0d; + if (powerKw != null && Math.abs(powerKw) <= MAX_PLAUSIBLE_KW) { + if (current == 0f && powerKw < 0f) { + // Standing still with power flowing in is charging or pre-conditioning + // recovery, not a drive crediting itself energy it never spent. + kwh = 0d; + } else { + double effectiveKw = previousPowerKw != null && Math.abs(previousPowerKw) <= MAX_PLAUSIBLE_KW + ? (previousPowerKw + powerKw) * 0.5d + : powerKw; + kwh = effectiveKw * hours; + } + } + + double socDrop = (!charging && previousSocPercent != null && socPercent != null) + ? previousSocPercent - socPercent + : 0d; + + return new Step(km, kwh, hours, socDrop); + } + + private ConsumptionMath() { } +} diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java index 85712de..05e0909 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java @@ -2,13 +2,21 @@ package com.evsuite.abrp; import android.content.Context; import android.content.SharedPreferences; +import android.os.SystemClock; import com.evsuite.hardware.telemetry.EnergySnapshot; -import com.evsuite.hardware.FirmwareInfo; import java.time.LocalDate; -/** Persistent, read-only trip/lifetime integration patterned after DriveHub_Dort. */ +/** + * Persistent, read-only trip/lifetime integration. + * + * All five periods are integrated from one {@link ConsumptionMath} step, so they can only + * ever disagree by what they cover, never by how they were measured. The counters live in + * {@link KahanSum} accumulators in memory and reach storage on a 30-second throttle; the + * store is new in 2.2.32, which is what resets the mixed-algorithm history inherited from + * earlier builds. + */ final class ConsumptionTracker { enum Period { LIFETIME, WEEK, MONTH, TRIP_A, TRIP_B } @@ -19,7 +27,17 @@ final class ConsumptionTracker { } } - private static final String PREFS = "consumption_counters_v1"; + /** + * Bumped from v1 deliberately. The old store holds totals accumulated under three + * different algorithms; there is no factor that converts them into what this one + * measures, so they are discarded rather than carried forward as a plausible lie. + */ + private static final String PREFS = "consumption_counters_v2"; + private static final String LEGACY_PREFS = "consumption_counters_v1"; + + /** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */ + private static final long PERSIST_INTERVAL_MS = 30_000L; + private static ConsumptionTracker instance; static synchronized ConsumptionTracker get(Context context) { if (instance == null) instance = new ConsumptionTracker(context.getApplicationContext()); @@ -27,112 +45,117 @@ final class ConsumptionTracker { } private final SharedPreferences prefs; - private Totals lifetime, tripA, tripB; - private long lastMs; - private Float lastSpeed, lastPower, lastSoc, lastVehicleConsumedKwh; + private final Counter lifetime = new Counter(); + private final Counter tripA = new Counter(); + private final Counter tripB = new Counter(); + private final Counter today = new Counter(); + private LocalDate todayDate; + + /** + * Monotonic. {@code EnergySnapshot.timestampMs} is wall clock, and the head unit's + * clock jumps when it picks up GPS or NTP time — backwards, in which case intervals + * were silently dropped, or forwards, in which case one jump invented hours of driving. + */ + private long lastElapsedMs; + private long lastPersistMs; + private Float lastSpeed, lastPower, lastSoc; private ConsumptionTracker(Context context) { prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); - lifetime = load("life"); tripA = load("a"); tripB = load("b"); - migrateCalendarCounters(); + discardLegacyStore(context); + repairSocColumn(); + lifetime.load(prefs, "life"); + tripA.load(prefs, "a"); + tripB.load(prefs, "b"); + todayDate = LocalDate.now(); + today.load(prefs, dayKey(todayDate)); + pruneOldDays(); } synchronized void sample(EnergySnapshot s) { - migrateSwi69DistanceScale(s.getFirmware()); - long now = s.getTimestampMs(); - if (lastMs > 0 && now > lastMs) { - double hours = (now - lastMs) / 3_600_000d; - // Never bridge a process pause/sleep into invented driving data. - if (hours <= 0.1d) { - Float speed = s.getSpeedKmh(); - Float power = s.getBatteryPowerKw(); - double km = speed == null ? 0d : Math.max(0d, - ((lastSpeed == null ? speed : lastSpeed) + speed) * 0.5d) * hours; - // DriveHub_Dort lifetime algorithm: trapezoidal speed, the same dead band - // and calibration factors, and net DC power including regeneration. - float currentSpeed = speed != null ? Math.abs(speed) - : (lastSpeed == null ? 0f : Math.abs(lastSpeed)); - float lifetimeSpeed = lastSpeed == null ? currentSpeed - : (Math.abs(lastSpeed) + currentSpeed) * 0.5f; - if (lifetimeSpeed < 2.5f) lifetimeSpeed = 0f; - else if (lifetimeSpeed > 90f) lifetimeSpeed *= 1.0035f; - else if (lifetimeSpeed > 30f) lifetimeSpeed *= 1.0015f; - double lifetimeKm = lifetimeSpeed * hours; - double lifetimeKwh = 0d; - if (power != null) { - if (currentSpeed == 0f && power < 0f) { - lifetimeKwh = 0d; - } else { - double effectiveKw = lastPower == null ? power : (lastPower + power) * 0.5d; - lifetimeKwh = effectiveKw * hours; - } - } - Float vehicleConsumed = s.getVehicleConsumedKwh(); - double kwh; - if (vehicleConsumed != null && lastVehicleConsumedKwh != null - && vehicleConsumed >= lastVehicleConsumedKwh - && vehicleConsumed - lastVehicleConsumedKwh <= 5f) { - kwh = vehicleConsumed - lastVehicleConsumedKwh; - } else { - // Gross draw fallback: regeneration/charging must not make consumption - // look artificially low when the OEM cumulative counter is unavailable. - double effectiveKw = power == null ? 0d - : ((lastPower == null ? power : lastPower) + power) * 0.5d; - kwh = Math.max(0d, effectiveKw) * hours; - } - double dsoc = (lastSoc == null || s.getSocPercent() == null || - Boolean.TRUE.equals(s.getChargePortConnected())) ? 0d : lastSoc - s.getSocPercent(); - lifetime = add(lifetime, lifetimeKm, lifetimeKwh, hours, dsoc); - String dayKey = "day_" + LocalDate.now(); - SharedPreferences.Editor daily = prefs.edit(); - save(daily, dayKey, add(load(dayKey), km, kwh, hours, dsoc)); - daily.commit(); - pruneOldDays(); - tripA = add(tripA, km, kwh, hours, dsoc); - tripB = add(tripB, km, kwh, hours, dsoc); - persist(); - } - } - lastMs = now; lastSpeed = s.getSpeedKmh(); lastPower = s.getBatteryPowerKw(); - lastSoc = s.getSocPercent(); - lastVehicleConsumedKwh = s.getVehicleConsumedKwh(); + long now = SystemClock.elapsedRealtime(); + long previous = lastElapsedMs; + lastElapsedMs = now; + + Float speed = s.getSpeedKmh(); + Float power = s.getBatteryPowerKw(); + Float soc = s.getSocPercent(); + boolean charging = ChargingState.isCharging( + s.getChargingStatus(), s.getChargePortConnected(), power, speed); + + double hours = previous > 0L ? (now - previous) / 3_600_000d : 0d; + ConsumptionMath.Step step = ConsumptionMath.integrate( + hours, lastSpeed, speed, lastPower, power, lastSoc, soc, charging); + + // Only a real reading becomes the next interval's trapezoid partner; a failed read + // must not erase the last known value and turn the next interval into a step change. + if (speed != null) lastSpeed = speed; + if (power != null) lastPower = power; + if (soc != null) lastSoc = soc; + + if (step == null) return; + + rollDayIfNeeded(); + lifetime.add(step); + tripA.add(step); + tripB.add(step); + today.add(step); + + if (now - lastPersistMs >= PERSIST_INTERVAL_MS) persist(); } synchronized Totals totals(Period period) { switch (period) { - case WEEK: return rollingDays(7); case MONTH: return rollingDays(30); case TRIP_A: return tripA; - case TRIP_B: return tripB; default: return lifetime; + case WEEK: return rollingDays(7); + case MONTH: return rollingDays(30); + case TRIP_A: return tripA.snapshot(); + case TRIP_B: return tripB.snapshot(); + default: return lifetime.snapshot(); } } synchronized void reset(Period period) { - if (period == Period.TRIP_A) tripA = zero(); - else if (period == Period.TRIP_B) tripB = zero(); + if (period == Period.TRIP_A) tripA.reset(); + else if (period == Period.TRIP_B) tripB.reset(); else return; persist(); } synchronized void persist() { + lastPersistMs = SystemClock.elapsedRealtime(); SharedPreferences.Editor e = prefs.edit(); - save(e, "life", lifetime); - save(e, "a", tripA); save(e, "b", tripB); + lifetime.save(e, "life"); + tripA.save(e, "a"); + tripB.save(e, "b"); + today.save(e, dayKey(todayDate)); e.commit(); } - private Totals rollingDays(int days) { - Totals result = zero(); - LocalDate today = LocalDate.now(); - for (int i = 0; i < days; i++) { - Totals day = load("day_" + today.minusDays(i)); - result = add(result, day.km, day.kwh, day.hours, day.soc); - } - return result; - } - synchronized Totals totalsForDay(LocalDate date) { if (date == null || date.isAfter(LocalDate.now()) || date.isBefore(LocalDate.now().minusMonths(4))) return zero(); - return load("day_" + date); + if (date.equals(todayDate)) return today.snapshot(); + return loadTotals(dayKey(date)); + } + + /** Flushes the day that just ended and opens the new one. */ + private void rollDayIfNeeded() { + LocalDate date = LocalDate.now(); + if (date.equals(todayDate)) return; + persist(); + todayDate = date; + today.load(prefs, dayKey(date)); + pruneOldDays(); + } + + private Totals rollingDays(int days) { + double km = 0, kwh = 0, hours = 0, soc = 0; + LocalDate today_ = LocalDate.now(); + for (int i = 0; i < days; i++) { + Totals day = totalsForDay(today_.minusDays(i)); + km += day.km; kwh += day.kwh; hours += day.hours; soc += day.soc; + } + return new Totals(km, kwh, hours, soc); } private void pruneOldDays() { @@ -151,42 +174,85 @@ final class ConsumptionTracker { if (editor != null) editor.commit(); } - /** Preserve anything gathered by 2.2.24 by treating its just-created counter as today. */ - private void migrateCalendarCounters() { - if (prefs.getBoolean("rolling_days_migrated", false)) return; - Totals old = load("week"); - SharedPreferences.Editor e = prefs.edit(); - if (old.km != 0 || old.kwh != 0 || old.hours != 0 || old.soc != 0) - save(e, "day_" + LocalDate.now(), old); - e.putBoolean("rolling_days_migrated", true).commit(); - pruneOldDays(); - } - - /** 2.2.27 and earlier multiplied SWI69's already-km/h speed by 3.6. Repair distance only. */ - private void migrateSwi69DistanceScale(FirmwareInfo.Gen firmware) { - if (firmware != FirmwareInfo.Gen.SWI69 || prefs.getBoolean("swi69_distance_v2", false)) return; + /** + * Zeroes the SOC column once, for 2.2.33. + * + * Until then a charge was detected from the charge-port property alone, which this + * vehicle answers {@code false} to even when the cable is in, so every percent gained + * on a charger was booked as negative consumption. Only this column is affected — + * distance, energy and time never consulted the charge state — so the rest of the + * store is sound and is kept. + */ + private void repairSocColumn() { + if (prefs.getBoolean("soc_charge_detection_v2", false)) return; SharedPreferences.Editor editor = prefs.edit(); - for (java.util.Map.Entry entry : prefs.getAll().entrySet()) { - String key = entry.getKey(); - if (!key.endsWith("_km") || !(entry.getValue() instanceof Long)) continue; - double oldKm = Double.longBitsToDouble((Long) entry.getValue()); - editor.putLong(key, Double.doubleToRawLongBits(oldKm / 3.6d)); + for (String key : prefs.getAll().keySet()) { + if (key.endsWith("_soc")) editor.putLong(key, Double.doubleToRawLongBits(0d)); } - editor.putBoolean("swi69_distance_v2", true).commit(); - lifetime = load("life"); tripA = load("a"); tripB = load("b"); + editor.putBoolean("soc_charge_detection_v2", true).commit(); } + /** Deletes the pre-2.2.32 store once, so its mixed-algorithm totals cannot be read back. */ + private void discardLegacyStore(Context context) { + if (prefs.getBoolean("legacy_v1_discarded", false)) return; + try { + context.deleteSharedPreferences(LEGACY_PREFS); + } catch (RuntimeException ignored) { + // An older platform without the API: the v1 file is simply never read again. + } + prefs.edit().putBoolean("legacy_v1_discarded", true).commit(); + } - private Totals load(String key) { return new Totals(bits(key,"km"), bits(key,"kwh"), bits(key,"h"), bits(key,"soc")); } - private double bits(String key, String field) { return Double.longBitsToDouble(prefs.getLong(key + "_" + field, 0L)); } - private static void save(SharedPreferences.Editor e, String key, Totals t) { - e.putLong(key+"_km", Double.doubleToRawLongBits(t.km)); - e.putLong(key+"_kwh", Double.doubleToRawLongBits(t.kwh)); - e.putLong(key+"_h", Double.doubleToRawLongBits(t.hours)); - e.putLong(key+"_soc", Double.doubleToRawLongBits(t.soc)); + private static String dayKey(LocalDate date) { return "day_" + date; } + + private Totals loadTotals(String key) { + return new Totals(bits(key, "km"), bits(key, "kwh"), bits(key, "h"), bits(key, "soc")); } - private static Totals add(Totals t, double km, double kwh, double h, double soc) { - return new Totals(t.km + km, t.kwh + kwh, t.hours + h, t.soc + soc); + + private double bits(String key, String field) { + return Double.longBitsToDouble(prefs.getLong(key + "_" + field, 0L)); + } + + private static Totals zero() { return new Totals(0, 0, 0, 0); } + + /** The four compensated accumulators that make up one period. */ + private static final class Counter { + private final KahanSum km = new KahanSum(); + private final KahanSum kwh = new KahanSum(); + private final KahanSum hours = new KahanSum(); + private final KahanSum soc = new KahanSum(); + + void add(ConsumptionMath.Step step) { + km.add(step.km); + kwh.add(step.kwh); + hours.add(step.hours); + soc.add(step.socDrop); + } + + void load(SharedPreferences prefs, String key) { + km.setTotal(read(prefs, key, "km")); + kwh.setTotal(read(prefs, key, "kwh")); + hours.setTotal(read(prefs, key, "h")); + soc.setTotal(read(prefs, key, "soc")); + } + + void save(SharedPreferences.Editor e, String key) { + e.putLong(key + "_km", Double.doubleToRawLongBits(km.get())); + e.putLong(key + "_kwh", Double.doubleToRawLongBits(kwh.get())); + e.putLong(key + "_h", Double.doubleToRawLongBits(hours.get())); + e.putLong(key + "_soc", Double.doubleToRawLongBits(soc.get())); + } + + void reset() { + km.reset(); kwh.reset(); hours.reset(); soc.reset(); + } + + Totals snapshot() { + return new Totals(km.get(), kwh.get(), hours.get(), soc.get()); + } + + private static double read(SharedPreferences prefs, String key, String field) { + return Double.longBitsToDouble(prefs.getLong(key + "_" + field, 0L)); + } } - private static Totals zero() { return new Totals(0,0,0,0); } } diff --git a/app/src/main/java/com/evsuite/abrp/KahanSum.java b/app/src/main/java/com/evsuite/abrp/KahanSum.java new file mode 100644 index 0000000..11d7b3e --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/KahanSum.java @@ -0,0 +1,38 @@ +package com.evsuite.abrp; + +/** + * Neumaier–Kahan compensated summation. + * + * Consumption is integrated from thousands of sub-watt-hour slices per drive. A plain + * {@code sum += x} loses the low bits of every one of them, and the error is one-signed, + * so it accumulates rather than cancelling. Ported from DriveHub_Dort, where this is what + * keeps the lifetime counter honest over months. + */ +final class KahanSum { + + private double sum; + private double compensation; + + void add(double input) { + double y = input - compensation; + double t = sum + y; + compensation = (t - sum) - y; + sum = t; + } + + double get() { + return sum; + } + + /** Clears the total and the compensation term. */ + void reset() { + sum = 0d; + compensation = 0d; + } + + /** Seeds the total from persistent storage; the compensation term starts clean. */ + void setTotal(double total) { + sum = total; + compensation = 0d; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index eebda48..0db90e8 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -893,9 +893,8 @@ public class MainActivity extends AppCompatActivity { private void refreshCharging(EnergySnapshot s) { if (chargeStatus == null) return; Integer code = s.getChargingStatus(); - boolean charging = (code != null && (code == 1 || code == 10)) || - (Boolean.TRUE.equals(s.getChargePortConnected()) && s.getBatteryPowerKw() != null - && s.getBatteryPowerKw() < -0.2f); + boolean charging = ChargingState.isCharging( + code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh()); long now = s.getTimestampMs(); if (charging && chargeStartMs == 0) { chargeStartMs = now; chargedKwh = 0; } if (!charging) { chargeStartMs = 0; lastChargeMs = 0; chargedKwh = 0; } diff --git a/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java b/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java new file mode 100644 index 0000000..bf84959 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/ChargingStateTest.java @@ -0,0 +1,74 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ChargingStateTest { + + @Test + public void vendorStatusIsConclusiveOnItsOwn() { + // The port says false and the power is unreadable: status still decides. + assertTrue(ChargingState.isCharging(1, Boolean.FALSE, null, null)); + assertTrue(ChargingState.isCharging(10, Boolean.FALSE, null, null)); + } + + @Test + public void anIdleStatusDoesNotByItselfRuleChargingOut() { + // Status 0 with the cable in and power flowing in is still a charge; the vendor + // code is one signal among several, not a veto. + assertTrue(ChargingState.isCharging(0, Boolean.TRUE, -7f, 0f)); + } + + @Test + public void inboundPowerAtAStandstillIsACharge() { + // This is the case the old port-only test missed: plugged in, port reports false. + assertTrue(ChargingState.isCharging(null, Boolean.FALSE, -7f, 0f)); + assertTrue(ChargingState.isCharging(null, null, -45f, 0f)); + } + + @Test + public void inboundPowerWhileMovingIsRegenerationNotCharging() { + assertFalse(ChargingState.isCharging(null, Boolean.FALSE, -20f, 60f)); + assertFalse(ChargingState.isCharging(null, null, -20f, 60f)); + } + + @Test + public void thePortStillCountsWhenItDoesAnswer() { + assertTrue(ChargingState.isCharging(null, Boolean.TRUE, -7f, 0f)); + } + + @Test + public void outboundPowerIsNeverACharge() { + assertFalse(ChargingState.isCharging(null, Boolean.TRUE, 20f, 60f)); + assertFalse(ChargingState.isCharging(0, Boolean.TRUE, 2f, 0f)); + } + + @Test + public void noiseAroundZeroIsNotACharge() { + assertFalse(ChargingState.isCharging(null, Boolean.TRUE, -0.1f, 0f)); + assertFalse(ChargingState.isCharging(null, Boolean.TRUE, 0f, 0f)); + } + + @Test + public void unreadablePowerWithoutAStatusIsNotAssumedToBeACharge() { + assertFalse(ChargingState.isCharging(null, Boolean.TRUE, null, 0f)); + assertFalse(ChargingState.isCharging(null, null, null, null)); + } + + @Test + public void unreadableSpeedWithInboundPowerFallsBackToCharging() { + // Conservative on purpose: without a speed we cannot tell a charge from regen, and + // treating it as a charge only suppresses a SOC delta. The opposite choice invents + // negative consumption, which is the bug this class was written to stop. + assertTrue(ChargingState.isCharging(null, null, -7f, null)); + } + + @Test + public void creepingSpeedIsStillAStandstill() { + // Same dead band the integrator uses, so the two cannot disagree about motion. + assertTrue(ChargingState.isCharging(null, null, -7f, 1f)); + assertFalse(ChargingState.isCharging(null, null, -7f, 10f)); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/ConsumptionMathTest.java b/app/src/test/java/com/evsuite/abrp/ConsumptionMathTest.java new file mode 100644 index 0000000..c5320f3 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/ConsumptionMathTest.java @@ -0,0 +1,159 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class ConsumptionMathTest { + + /** One minute, comfortably inside {@link ConsumptionMath#MAX_GAP_HOURS}. */ + private static final double DT = 1d / 60d; + + /** Float inputs widen to double exactly, but the arithmetic is done in float. */ + private static final double TOLERANCE = 1e-6; + + @Test + public void nonPositiveOrOversizedIntervalsAreDropped() { + assertNull(ConsumptionMath.integrate(0d, 50f, 50f, 20f, 20f, 80f, 79f, false)); + assertNull(ConsumptionMath.integrate(-1d, 50f, 50f, 20f, 20f, 80f, 79f, false)); + // A process pause must not be bridged into invented driving data. + assertNull(ConsumptionMath.integrate(ConsumptionMath.MAX_GAP_HOURS + 0.001d, + 50f, 50f, 20f, 20f, 80f, 79f, false)); + } + + @Test + public void distanceIsTheTrapezoidOfTheTwoSpeeds() { + // 20 and 40 km/h average to 30, which sits on the uncalibrated side of the band. + ConsumptionMath.Step step = + ConsumptionMath.integrate(DT, 20f, 40f, null, null, null, null, false); + assertEquals(30d * DT, step.km, TOLERANCE); + assertEquals(DT, step.hours, TOLERANCE); + } + + @Test + public void speedBelowTheDeadBandContributesNoDistance() { + ConsumptionMath.Step step = + ConsumptionMath.integrate(DT, 2f, 2f, null, null, null, null, false); + assertEquals(0d, step.km, TOLERANCE); + } + + @Test + public void motorwayAndMidRangeSpeedsAreCalibrated() { + ConsumptionMath.Step mid = + ConsumptionMath.integrate(DT, 50f, 50f, null, null, null, null, false); + assertEquals(50d * 1.0015d * DT, mid.km, TOLERANCE); + + ConsumptionMath.Step motorway = + ConsumptionMath.integrate(DT, 120f, 120f, null, null, null, null, false); + assertEquals(120d * 1.0035d * DT, motorway.km, TOLERANCE); + } + + @Test + public void energyIsTheTrapezoidOfNetPowerAndRegenerationSubtracts() { + ConsumptionMath.Step drawing = + ConsumptionMath.integrate(DT, 60f, 60f, 10f, 20f, null, null, false); + assertEquals(15d * DT, drawing.kwh, TOLERANCE); + + // Regeneration keeps its sign: this is what the car's own kWh figure does, and + // discarding it is what used to make our number read high. + ConsumptionMath.Step regenerating = + ConsumptionMath.integrate(DT, 60f, 60f, -20f, -20f, null, null, false); + assertEquals(-20d * DT, regenerating.kwh, TOLERANCE); + } + + @Test + public void standstillPowerFlowingInIsNotCreditedToTheDrive() { + ConsumptionMath.Step step = + ConsumptionMath.integrate(DT, 0f, 0f, -7f, -7f, null, null, false); + assertEquals(0d, step.kwh, TOLERANCE); + } + + @Test + public void standstillPowerFlowingOutStillCounts() { + // Parked with the climate running is real consumption. + ConsumptionMath.Step step = + ConsumptionMath.integrate(DT, 0f, 0f, 2f, 2f, null, null, false); + assertEquals(2d * DT, step.kwh, TOLERANCE); + } + + @Test + public void anImplausiblePowerReadingSuppressesEnergyButNotDistance() { + ConsumptionMath.Step step = ConsumptionMath.integrate( + DT, 60f, 60f, 20f, ConsumptionMath.MAX_PLAUSIBLE_KW + 1f, null, null, false); + assertEquals(0d, step.kwh, TOLERANCE); + assertEquals(60d * 1.0015d * DT, step.km, TOLERANCE); + assertEquals(DT, step.hours, TOLERANCE); + } + + @Test + public void aMissingPowerReadingSuppressesEnergyOnlyForThatInterval() { + ConsumptionMath.Step step = + ConsumptionMath.integrate(DT, 60f, 60f, 20f, null, null, null, false); + assertEquals(0d, step.kwh, TOLERANCE); + assertEquals(DT, step.hours, TOLERANCE); + } + + @Test + public void aMissingSpeedReadingFallsBackToThePreviousOne() { + ConsumptionMath.Step step = + ConsumptionMath.integrate(DT, 60f, null, 20f, 20f, null, null, false); + assertEquals(60d * 1.0015d * DT, step.km, TOLERANCE); + assertEquals(20d * DT, step.kwh, TOLERANCE); + } + + @Test + public void reverseIsDistanceTravelled() { + ConsumptionMath.Step step = + ConsumptionMath.integrate(DT, -10f, -10f, null, null, null, null, false); + assertEquals(10d * DT, step.km, TOLERANCE); + } + + @Test + public void socDropIsCountedOnlyOffTheCharger() { + ConsumptionMath.Step driving = + ConsumptionMath.integrate(DT, 60f, 60f, 20f, 20f, 80f, 79.5f, false); + assertEquals(0.5d, driving.socDrop, TOLERANCE); + + ConsumptionMath.Step charging = + ConsumptionMath.integrate(DT, 0f, 0f, -40f, -40f, 50f, 52f, true); + assertEquals(0d, charging.socDrop, TOLERANCE); + } + + @Test + public void aMissingSocEndpointYieldsNoDrop() { + ConsumptionMath.Step step = + ConsumptionMath.integrate(DT, 60f, 60f, 20f, 20f, null, 79f, false); + assertEquals(0d, step.socDrop, TOLERANCE); + } + + @Test + public void aSteadyDriveIntegratesToTheExpectedAverage() { + // 90 km/h at 15 kW for an hour: ~16.6 kWh/100 km, a plausible MG4 motorway figure. + KahanSum km = new KahanSum(); + KahanSum kwh = new KahanSum(); + for (int i = 0; i < 3600; i++) { + ConsumptionMath.Step step = ConsumptionMath.integrate( + 1d / 3600d, 90f, 90f, 15f, 15f, null, null, false); + km.add(step.km); + kwh.add(step.kwh); + } + assertEquals(90d * 1.0015d, km.get(), 1e-4); + assertEquals(15d, kwh.get(), 1e-9); + assertEquals(16.64d, kwh.get() * 100d / km.get(), 0.01d); + } + + @Test + public void regenerationOffsetsTheDrawItFollows() { + // Ten minutes pulling 30 kW, then ten minutes recovering 6 kW: the net is what the + // dashboard shows, and the old gross-only fallback reported 5.0 kWh for this. + KahanSum kwh = new KahanSum(); + for (int i = 0; i < 10; i++) { + kwh.add(ConsumptionMath.integrate(DT, 60f, 60f, 30f, 30f, null, null, false).kwh); + } + for (int i = 0; i < 10; i++) { + kwh.add(ConsumptionMath.integrate(DT, 60f, 60f, -6f, -6f, null, null, false).kwh); + } + assertEquals(4d, kwh.get(), 1e-4); + } +}