Fix consumption tracking while charging

This commit is contained in:
TCFDONMEZ
2026-10-02 11:15:22 +03:00
parent ecbee4980c
commit dbbfb9f803
10 changed files with 643 additions and 119 deletions
+41
View File
@@ -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 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [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 ## [2.2.18] - 2026-09-30
### Changed ### Changed
+1
View File
@@ -0,0 +1 @@
7b02485c155facba0af522861cee88760c62ecdcc366c4d885d473595d2750b2 FatihsMG4-unstable-2.2.33.apk
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.evsuite.abrp" applicationId "com.evsuite.abrp"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 48 versionCode 50
versionName "2.2.31" versionName "2.2.33"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -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() { }
}
@@ -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() { }
}
@@ -2,13 +2,21 @@ package com.evsuite.abrp;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.SystemClock;
import com.evsuite.hardware.telemetry.EnergySnapshot; import com.evsuite.hardware.telemetry.EnergySnapshot;
import com.evsuite.hardware.FirmwareInfo;
import java.time.LocalDate; 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 { final class ConsumptionTracker {
enum Period { LIFETIME, WEEK, MONTH, TRIP_A, TRIP_B } 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; private static ConsumptionTracker instance;
static synchronized ConsumptionTracker get(Context context) { static synchronized ConsumptionTracker get(Context context) {
if (instance == null) instance = new ConsumptionTracker(context.getApplicationContext()); if (instance == null) instance = new ConsumptionTracker(context.getApplicationContext());
@@ -27,112 +45,117 @@ final class ConsumptionTracker {
} }
private final SharedPreferences prefs; private final SharedPreferences prefs;
private Totals lifetime, tripA, tripB; private final Counter lifetime = new Counter();
private long lastMs; private final Counter tripA = new Counter();
private Float lastSpeed, lastPower, lastSoc, lastVehicleConsumedKwh; 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) { private ConsumptionTracker(Context context) {
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
lifetime = load("life"); tripA = load("a"); tripB = load("b"); discardLegacyStore(context);
migrateCalendarCounters(); 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) { synchronized void sample(EnergySnapshot s) {
migrateSwi69DistanceScale(s.getFirmware()); long now = SystemClock.elapsedRealtime();
long now = s.getTimestampMs(); long previous = lastElapsedMs;
if (lastMs > 0 && now > lastMs) { lastElapsedMs = now;
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 speed = s.getSpeedKmh();
Float power = s.getBatteryPowerKw(); Float power = s.getBatteryPowerKw();
double km = speed == null ? 0d : Math.max(0d, Float soc = s.getSocPercent();
((lastSpeed == null ? speed : lastSpeed) + speed) * 0.5d) * hours; boolean charging = ChargingState.isCharging(
// DriveHub_Dort lifetime algorithm: trapezoidal speed, the same dead band s.getChargingStatus(), s.getChargePortConnected(), power, speed);
// and calibration factors, and net DC power including regeneration.
float currentSpeed = speed != null ? Math.abs(speed) double hours = previous > 0L ? (now - previous) / 3_600_000d : 0d;
: (lastSpeed == null ? 0f : Math.abs(lastSpeed)); ConsumptionMath.Step step = ConsumptionMath.integrate(
float lifetimeSpeed = lastSpeed == null ? currentSpeed hours, lastSpeed, speed, lastPower, power, lastSoc, soc, charging);
: (Math.abs(lastSpeed) + currentSpeed) * 0.5f;
if (lifetimeSpeed < 2.5f) lifetimeSpeed = 0f; // Only a real reading becomes the next interval's trapezoid partner; a failed read
else if (lifetimeSpeed > 90f) lifetimeSpeed *= 1.0035f; // must not erase the last known value and turn the next interval into a step change.
else if (lifetimeSpeed > 30f) lifetimeSpeed *= 1.0015f; if (speed != null) lastSpeed = speed;
double lifetimeKm = lifetimeSpeed * hours; if (power != null) lastPower = power;
double lifetimeKwh = 0d; if (soc != null) lastSoc = soc;
if (power != null) {
if (currentSpeed == 0f && power < 0f) { if (step == null) return;
lifetimeKwh = 0d;
} else { rollDayIfNeeded();
double effectiveKw = lastPower == null ? power : (lastPower + power) * 0.5d; lifetime.add(step);
lifetimeKwh = effectiveKw * hours; tripA.add(step);
} tripB.add(step);
} today.add(step);
Float vehicleConsumed = s.getVehicleConsumedKwh();
double kwh; if (now - lastPersistMs >= PERSIST_INTERVAL_MS) persist();
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();
} }
synchronized Totals totals(Period period) { synchronized Totals totals(Period period) {
switch (period) { switch (period) {
case WEEK: return rollingDays(7); case MONTH: return rollingDays(30); case TRIP_A: return tripA; case WEEK: return rollingDays(7);
case TRIP_B: return tripB; default: return lifetime; 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) { synchronized void reset(Period period) {
if (period == Period.TRIP_A) tripA = zero(); if (period == Period.TRIP_A) tripA.reset();
else if (period == Period.TRIP_B) tripB = zero(); else if (period == Period.TRIP_B) tripB.reset();
else return; else return;
persist(); persist();
} }
synchronized void persist() { synchronized void persist() {
lastPersistMs = SystemClock.elapsedRealtime();
SharedPreferences.Editor e = prefs.edit(); SharedPreferences.Editor e = prefs.edit();
save(e, "life", lifetime); lifetime.save(e, "life");
save(e, "a", tripA); save(e, "b", tripB); tripA.save(e, "a");
tripB.save(e, "b");
today.save(e, dayKey(todayDate));
e.commit(); 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) { synchronized Totals totalsForDay(LocalDate date) {
if (date == null || date.isAfter(LocalDate.now()) || date.isBefore(LocalDate.now().minusMonths(4))) if (date == null || date.isAfter(LocalDate.now()) || date.isBefore(LocalDate.now().minusMonths(4)))
return zero(); 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() { private void pruneOldDays() {
@@ -151,42 +174,85 @@ final class ConsumptionTracker {
if (editor != null) editor.commit(); if (editor != null) editor.commit();
} }
/** Preserve anything gathered by 2.2.24 by treating its just-created counter as today. */ /**
private void migrateCalendarCounters() { * Zeroes the SOC column once, for 2.2.33.
if (prefs.getBoolean("rolling_days_migrated", false)) return; *
Totals old = load("week"); * Until then a charge was detected from the charge-port property alone, which this
SharedPreferences.Editor e = prefs.edit(); * vehicle answers {@code false} to even when the cable is in, so every percent gained
if (old.km != 0 || old.kwh != 0 || old.hours != 0 || old.soc != 0) * on a charger was booked as negative consumption. Only this column is affected —
save(e, "day_" + LocalDate.now(), old); * distance, energy and time never consulted the charge state — so the rest of the
e.putBoolean("rolling_days_migrated", true).commit(); * store is sound and is kept.
pruneOldDays(); */
} private void repairSocColumn() {
if (prefs.getBoolean("soc_charge_detection_v2", false)) return;
/** 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;
SharedPreferences.Editor editor = prefs.edit(); SharedPreferences.Editor editor = prefs.edit();
for (java.util.Map.Entry<String, ?> entry : prefs.getAll().entrySet()) { for (String key : prefs.getAll().keySet()) {
String key = entry.getKey(); if (key.endsWith("_soc")) editor.putLong(key, Double.doubleToRawLongBits(0d));
if (!key.endsWith("_km") || !(entry.getValue() instanceof Long)) continue;
double oldKm = Double.longBitsToDouble((Long) entry.getValue());
editor.putLong(key, Double.doubleToRawLongBits(oldKm / 3.6d));
} }
editor.putBoolean("swi69_distance_v2", true).commit(); editor.putBoolean("soc_charge_detection_v2", true).commit();
lifetime = load("life"); tripA = load("a"); tripB = load("b");
} }
/** 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 static String dayKey(LocalDate date) { return "day_" + date; }
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) { private Totals loadTotals(String key) {
e.putLong(key+"_km", Double.doubleToRawLongBits(t.km)); return new Totals(bits(key, "km"), bits(key, "kwh"), bits(key, "h"), bits(key, "soc"));
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 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 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 static Totals zero() { return new Totals(0,0,0,0); }
} }
@@ -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;
}
}
@@ -893,9 +893,8 @@ public class MainActivity extends AppCompatActivity {
private void refreshCharging(EnergySnapshot s) { private void refreshCharging(EnergySnapshot s) {
if (chargeStatus == null) return; if (chargeStatus == null) return;
Integer code = s.getChargingStatus(); Integer code = s.getChargingStatus();
boolean charging = (code != null && (code == 1 || code == 10)) || boolean charging = ChargingState.isCharging(
(Boolean.TRUE.equals(s.getChargePortConnected()) && s.getBatteryPowerKw() != null code, s.getChargePortConnected(), s.getBatteryPowerKw(), s.getSpeedKmh());
&& s.getBatteryPowerKw() < -0.2f);
long now = s.getTimestampMs(); long now = s.getTimestampMs();
if (charging && chargeStartMs == 0) { chargeStartMs = now; chargedKwh = 0; } if (charging && chargeStartMs == 0) { chargeStartMs = now; chargedKwh = 0; }
if (!charging) { chargeStartMs = 0; lastChargeMs = 0; chargedKwh = 0; } if (!charging) { chargeStartMs = 0; lastChargeMs = 0; chargedKwh = 0; }
@@ -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));
}
}
@@ -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);
}
}