Meter DC charging only, on a monotonic clock
The charging session meter used the head unit's wall clock, which jumps when it picks up GPS or NTP time. One forward jump mid-charge invented both kWh and hours. The consumption counters already integrate on elapsedRealtime for exactly this reason; the session meter now does too, and keeps wall-clock values only as the start and end stamps a reader needs to place a session on a timeline. Intervals longer than five minutes are no longer bridged. A head unit that slept mid-charge came back and billed the whole gap at the last power reading, and a session interrupted overnight reported the night as charging time. Reported duration is metered charging time now, not the span from start to end. Sessions are metered on DC alone, at or above 10 kW. AC charging on this car is an overnight affair the head unit sleeps through, so its curve, duration and energy were guesses either way. The charging screen says so rather than letting the previous DC session's figures be read as the current AC charge. Also: - A daily consumption record uploaded after an offline spell carries the lifetime total as it stood at the close of that day. Every backlogged day used to be stamped with the current lifetime, which plots as a flat line and one cliff. - The offline session queue is capped at 50. It is a single SharedPreferences string read into memory in full, and nothing drained it while the cloud endpoint was unconfigured. - A long charge keeps the start of its curve: at 720 points the sample interval doubles and the curve is thinned, rather than dropping the oldest point. - The cloud sync throttle runs on the monotonic clock, and catch-up sends up to 10 days and 10 sessions per cycle rather than one and three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a37e52eda
commit
e9bfa1217b
@@ -2,6 +2,7 @@ package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import com.evsuite.hardware.telemetry.EnergySnapshot;
|
||||
|
||||
@@ -12,7 +13,16 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Persistent charging session meter and offline upload queue. */
|
||||
/**
|
||||
* Persistent DC charging session meter and offline upload queue.
|
||||
*
|
||||
* Only DC is metered — see {@link ChargingState#DC_MIN_KW}. Energy and duration are
|
||||
* integrated on {@link SystemClock#elapsedRealtime()} for the same reason
|
||||
* {@link ConsumptionTracker} is: the head unit's wall clock jumps when it picks up GPS or
|
||||
* NTP time, and one forward jump mid-charge used to invent both kWh and hours. Wall-clock
|
||||
* values are still recorded as the session's start and end, because that is what a phone
|
||||
* reading the database needs to place it on a timeline — but nothing is measured from them.
|
||||
*/
|
||||
final class ChargeSessionTracker {
|
||||
private static final String PREFS = "charging_sessions_v1";
|
||||
private static final String ACTIVE = "active";
|
||||
@@ -23,6 +33,18 @@ final class ChargeSessionTracker {
|
||||
private static final long CURVE_INTERVAL_MS = 60_000L;
|
||||
private static final int MAX_CURVE_POINTS = 720;
|
||||
|
||||
/**
|
||||
* Longest interval still integrated, in hours (5 minutes against a 15-second tick).
|
||||
*
|
||||
* A head unit that slept or a service that was killed comes back with an arbitrarily
|
||||
* large gap. Charging almost certainly stopped somewhere inside it, so bridging it
|
||||
* would bill the whole gap at the last power reading. The gap is dropped instead.
|
||||
*/
|
||||
private static final double MAX_STEP_HOURS = 5d / 60d;
|
||||
|
||||
/** Sessions kept while offline. Each carries a curve, so the queue is not free. */
|
||||
private static final int MAX_PENDING = 50;
|
||||
|
||||
private static ChargeSessionTracker instance;
|
||||
static synchronized ChargeSessionTracker get(Context context) {
|
||||
if (instance == null) instance = new ChargeSessionTracker(context.getApplicationContext());
|
||||
@@ -75,39 +97,38 @@ final class ChargeSessionTracker {
|
||||
|
||||
synchronized void sample(EnergySnapshot snapshot) {
|
||||
long now = snapshot.getTimestampMs();
|
||||
long elapsed = SystemClock.elapsedRealtime();
|
||||
Float power = snapshot.getBatteryPowerKw();
|
||||
Float soc = snapshot.getSocPercent();
|
||||
boolean charging = ChargingState.isCharging(snapshot.getChargingStatus(),
|
||||
boolean charging = ChargingState.isDcCharging(snapshot.getChargingStatus(),
|
||||
snapshot.getChargePortConnected(), power, snapshot.getSpeedKmh());
|
||||
|
||||
if (charging) {
|
||||
endCandidateMs = 0L;
|
||||
if (active == null) active = new Session(UUID.randomUUID().toString(), now, soc);
|
||||
active.add(now, soc, power);
|
||||
active.add(now, elapsed, soc, power);
|
||||
persistActive();
|
||||
return;
|
||||
}
|
||||
if (active == null) return;
|
||||
if (endCandidateMs == 0L) endCandidateMs = now;
|
||||
if (now - endCandidateMs < END_GRACE_MS) return;
|
||||
active.finish(endCandidateMs, soc, pricePerKwh());
|
||||
if (endCandidateMs == 0L) endCandidateMs = elapsed;
|
||||
if (elapsed - endCandidateMs < END_GRACE_MS) return;
|
||||
active.finish(now, soc, pricePerKwh());
|
||||
JSONObject completed = active.toJson();
|
||||
enqueue(completed);
|
||||
prefs.edit().putString(LAST, completed.toString()).commit();
|
||||
prefs.edit().putString(LAST, completed.toString()).remove(ACTIVE).commit();
|
||||
active = null;
|
||||
endCandidateMs = 0L;
|
||||
prefs.edit().remove(ACTIVE).commit();
|
||||
}
|
||||
|
||||
synchronized Snapshot snapshot() {
|
||||
Session session = active != null ? active : Session.fromJson(prefs.getString(LAST, null));
|
||||
if (session == null) return new Snapshot(false, 0L, 0L, null, null,
|
||||
0d, pricePerKwh(), new ArrayList<>());
|
||||
long end = session.endedAtMs > 0 ? session.endedAtMs : session.lastSampleMs;
|
||||
double price = active != null ? pricePerKwh() : session.pricePerKwh;
|
||||
return new Snapshot(active != null, session.startedAtMs,
|
||||
Math.max(0L, (end - session.startedAtMs) / 1000L), session.startSoc,
|
||||
session.lastSoc, session.energyKwh, price, new ArrayList<>(session.points));
|
||||
return new Snapshot(active != null, session.startedAtMs, session.activeSeconds(),
|
||||
session.startSoc, session.lastSoc, session.energyKwh, price,
|
||||
new ArrayList<>(session.points));
|
||||
}
|
||||
|
||||
synchronized boolean setPricePerKwh(double value) {
|
||||
@@ -143,10 +164,21 @@ final class ChargeSessionTracker {
|
||||
prefs.edit().putString(ACTIVE, active.toJson().toString()).commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a completed session, dropping the oldest once the queue is full.
|
||||
*
|
||||
* Unbounded, this grows forever whenever the cloud endpoint is unconfigured or
|
||||
* unreachable — and it is one SharedPreferences string, read into memory in full.
|
||||
*/
|
||||
private void enqueue(JSONObject session) {
|
||||
JSONArray array = pendingArray();
|
||||
array.put(session);
|
||||
prefs.edit().putString(PENDING, array.toString()).commit();
|
||||
JSONArray kept = array;
|
||||
if (array.length() > MAX_PENDING) {
|
||||
kept = new JSONArray();
|
||||
for (int i = array.length() - MAX_PENDING; i < array.length(); i++) kept.put(array.opt(i));
|
||||
}
|
||||
prefs.edit().putString(PENDING, kept.toString()).commit();
|
||||
}
|
||||
|
||||
private JSONArray pendingArray() {
|
||||
@@ -159,39 +191,68 @@ final class ChargeSessionTracker {
|
||||
final long startedAtMs;
|
||||
final Float startSoc;
|
||||
final List<Point> points = new ArrayList<>();
|
||||
long lastSampleMs;
|
||||
Float lastSoc;
|
||||
Float lastPower;
|
||||
double energyKwh;
|
||||
double meteredSeconds;
|
||||
long endedAtMs;
|
||||
double pricePerKwh;
|
||||
long curveIntervalMs = CURVE_INTERVAL_MS;
|
||||
|
||||
/**
|
||||
* Monotonic anchor of the previous sample, or 0 when there is none.
|
||||
*
|
||||
* Never persisted: {@code elapsedRealtime} is only comparable within one boot, so
|
||||
* a session restored from disk starts a fresh interval rather than integrating
|
||||
* against a reading from before the restart.
|
||||
*/
|
||||
private long lastElapsedMs;
|
||||
|
||||
Session(String id, long startedAtMs, Float startSoc) {
|
||||
this.id = id; this.startedAtMs = startedAtMs; this.startSoc = startSoc;
|
||||
this.lastSampleMs = startedAtMs; this.lastSoc = startSoc;
|
||||
this.lastSoc = startSoc;
|
||||
}
|
||||
|
||||
void add(long now, Float soc, Float power) {
|
||||
if (now > lastSampleMs && power != null && lastPower != null) {
|
||||
double hours = (now - lastSampleMs) / 3_600_000d;
|
||||
energyKwh += Math.max(0d, -(power + lastPower) / 2d) * hours;
|
||||
void add(long nowMs, long elapsedMs, Float soc, Float power) {
|
||||
long previous = lastElapsedMs;
|
||||
lastElapsedMs = elapsedMs;
|
||||
double hours = previous > 0L ? (elapsedMs - previous) / 3_600_000d : 0d;
|
||||
if (hours > 0d && hours <= MAX_STEP_HOURS) {
|
||||
meteredSeconds += hours * 3600d;
|
||||
if (power != null && lastPower != null) {
|
||||
energyKwh += Math.max(0d, -(power + lastPower) / 2d) * hours;
|
||||
}
|
||||
}
|
||||
if (now > lastSampleMs) lastSampleMs = now;
|
||||
if (soc != null) lastSoc = soc;
|
||||
if (power != null) lastPower = power;
|
||||
if (power != null && power < ChargingState.CHARGING_POWER_KW &&
|
||||
(points.isEmpty() || now - points.get(points.size() - 1).timestampMs >= CURVE_INTERVAL_MS)) {
|
||||
points.add(new Point(now, soc, Math.abs(power)));
|
||||
if (points.size() > MAX_CURVE_POINTS) points.remove(0);
|
||||
if (power != null && -power >= ChargingState.DC_MIN_KW &&
|
||||
(points.isEmpty() || nowMs - points.get(points.size() - 1).timestampMs >= curveIntervalMs)) {
|
||||
points.add(new Point(nowMs, soc, Math.abs(power)));
|
||||
decimateIfFull();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Halves the sample rate instead of dropping the oldest point.
|
||||
*
|
||||
* Dropping the head cost the beginning of the curve — on a long charge the part
|
||||
* that actually has shape. Thinning keeps the whole session, at lower resolution.
|
||||
*/
|
||||
private void decimateIfFull() {
|
||||
if (points.size() <= MAX_CURVE_POINTS) return;
|
||||
for (int i = points.size() - 2; i >= 0; i -= 2) points.remove(i);
|
||||
curveIntervalMs *= 2;
|
||||
}
|
||||
|
||||
void finish(long endedAtMs, Float finalSoc, double price) {
|
||||
this.endedAtMs = endedAtMs;
|
||||
if (finalSoc != null) lastSoc = finalSoc;
|
||||
pricePerKwh = price;
|
||||
}
|
||||
|
||||
/** Metered charging time, not wall-clock span: a dropped gap is not charging. */
|
||||
long activeSeconds() { return Math.max(0L, Math.round(meteredSeconds)); }
|
||||
|
||||
JSONObject toJson() {
|
||||
try {
|
||||
JSONArray curve = new JSONArray();
|
||||
@@ -202,11 +263,11 @@ final class ChargeSessionTracker {
|
||||
curve.put(p);
|
||||
}
|
||||
JSONObject json = new JSONObject().put("sessionId", id).put("startedAt", startedAtMs)
|
||||
.put("endedAt", endedAtMs).put("durationSeconds",
|
||||
Math.max(0L, ((endedAtMs > 0 ? endedAtMs : lastSampleMs) - startedAtMs) / 1000L))
|
||||
.put("endedAt", endedAtMs).put("durationSeconds", activeSeconds())
|
||||
.put("energyKwh", energyKwh).put("pricePerKwh", pricePerKwh)
|
||||
.put("totalCost", energyKwh * pricePerKwh).put("curve", curve)
|
||||
.put("lastSampleMs", lastSampleMs);
|
||||
.put("meteredSeconds", meteredSeconds)
|
||||
.put("curveIntervalMs", curveIntervalMs);
|
||||
if (startSoc != null) json.put("startSocPercent", startSoc);
|
||||
if (lastSoc != null) json.put("endSocPercent", lastSoc);
|
||||
if (lastPower != null) json.put("lastPower", lastPower);
|
||||
@@ -222,12 +283,13 @@ final class ChargeSessionTracker {
|
||||
JSONObject json = new JSONObject(raw);
|
||||
Session session = new Session(json.getString("sessionId"), json.getLong("startedAt"),
|
||||
json.has("startSocPercent") ? (float) json.getDouble("startSocPercent") : null);
|
||||
session.lastSampleMs = json.optLong("lastSampleMs", session.startedAtMs);
|
||||
session.lastSoc = json.has("endSocPercent") ? (float) json.getDouble("endSocPercent") : session.startSoc;
|
||||
session.lastPower = json.has("lastPower") ? (float) json.getDouble("lastPower") : null;
|
||||
session.energyKwh = json.optDouble("energyKwh", 0d);
|
||||
session.meteredSeconds = json.optDouble("meteredSeconds", json.optLong("durationSeconds", 0L));
|
||||
session.endedAtMs = json.optLong("endedAt", 0L);
|
||||
session.pricePerKwh = json.optDouble("pricePerKwh", 0d);
|
||||
session.curveIntervalMs = json.optLong("curveIntervalMs", CURVE_INTERVAL_MS);
|
||||
JSONArray curve = json.optJSONArray("curve");
|
||||
if (curve != null) for (int i = 0; i < curve.length(); i++) {
|
||||
JSONObject p = curve.getJSONObject(i);
|
||||
|
||||
Reference in New Issue
Block a user