diff --git a/CHANGELOG.md b/CHANGELOG.md index cb02362..0dc3d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.2.38 + +### Changed + +- Charging cost now uses estimated grid energy: battery-side net kWh plus a fixed 10% + charging loss. Both net and estimated grid energy are stored separately. +- Driving consumption, duration, distance and SOC drop now accumulate only while the car + is moving; parked HVAC and accessory use no longer inflate driving statistics. + ## 2.2.37 ### Added diff --git a/FatihsMG4-unstable-2.2.38.apk.sha256 b/FatihsMG4-unstable-2.2.38.apk.sha256 new file mode 100644 index 0000000..46d837b --- /dev/null +++ b/FatihsMG4-unstable-2.2.38.apk.sha256 @@ -0,0 +1 @@ +3efa568d982f2594d596dca7d60bb84e39afd9b1f2db04c844f68c2e9af46ca2 FatihsMG4-unstable-2.2.38.apk diff --git a/app/build.gradle b/app/build.gradle index 441ece2..e3951f1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.evsuite.abrp" minSdk 27 targetSdk 36 - versionCode 54 - versionName "2.2.37" + versionCode 55 + versionName "2.2.38" // MongoDB credentials never ship in the APK. The app only talks to the small // authenticated API in server/, which owns the database connection. diff --git a/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java b/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java index 2460407..f204f92 100644 --- a/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ChargeSessionTracker.java @@ -24,6 +24,8 @@ import java.util.UUID; * reading the database needs to place it on a timeline — but nothing is measured from them. */ final class ChargeSessionTracker { + static final double CHARGING_LOSS_PERCENT = 10d; + static final double GRID_ENERGY_FACTOR = 1d + CHARGING_LOSS_PERCENT / 100d; private static final String PREFS = "charging_sessions_v1"; private static final String ACTIVE = "active"; private static final String LAST = "last_completed"; @@ -67,6 +69,7 @@ final class ChargeSessionTracker { final Float startSocPercent; final Float currentSocPercent; final double energyKwh; + final double gridEnergyKwh; final double pricePerKwh; final double totalCost; final List points; @@ -80,8 +83,9 @@ final class ChargeSessionTracker { this.startSocPercent = startSocPercent; this.currentSocPercent = currentSocPercent; this.energyKwh = energyKwh; + this.gridEnergyKwh = gridEnergyKwh(energyKwh); this.pricePerKwh = pricePerKwh; - this.totalCost = energyKwh * pricePerKwh; + this.totalCost = totalCost(energyKwh, pricePerKwh); this.points = points; } } @@ -140,6 +144,14 @@ final class ChargeSessionTracker { return Double.longBitsToDouble(prefs.getLong(PRICE, 0L)); } + static double gridEnergyKwh(double batteryEnergyKwh) { + return batteryEnergyKwh * GRID_ENERGY_FACTOR; + } + + static double totalCost(double batteryEnergyKwh, double pricePerKwh) { + return gridEnergyKwh(batteryEnergyKwh) * pricePerKwh; + } + synchronized List pendingUploads() { JSONArray array = pendingArray(); List result = new ArrayList<>(); @@ -264,8 +276,12 @@ final class ChargeSessionTracker { } JSONObject json = new JSONObject().put("sessionId", id).put("startedAt", startedAtMs) .put("endedAt", endedAtMs).put("durationSeconds", activeSeconds()) - .put("energyKwh", energyKwh).put("pricePerKwh", pricePerKwh) - .put("totalCost", energyKwh * pricePerKwh).put("curve", curve) + .put("energyKwh", energyKwh) + .put("chargingLossPercent", CHARGING_LOSS_PERCENT) + .put("gridEnergyKwh", gridEnergyKwh(energyKwh)) + .put("pricePerKwh", pricePerKwh) + .put("totalCost", totalCost(energyKwh, pricePerKwh)) + .put("curve", curve) .put("meteredSeconds", meteredSeconds) .put("curveIntervalMs", curveIntervalMs); if (startSoc != null) json.put("startSocPercent", startSoc); diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionMath.java b/app/src/main/java/com/evsuite/abrp/ConsumptionMath.java index dec1731..27c3de6 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionMath.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionMath.java @@ -10,7 +10,9 @@ package com.evsuite.abrp; * 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. + * Only moving intervals belong to these driving counters. Parked HVAC/accessory use is + * deliberately excluded from distance, energy, time and SOC-drop totals. What the vehicle + * reports while moving 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. @@ -69,24 +71,19 @@ final class ConsumptionMath { : (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; + if (effectiveKmh < SPEED_DEAD_BAND_KMH) { + return new Step(0d, 0d, 0d, 0d); + } 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 effectiveKw = previousPowerKw != null && Math.abs(previousPowerKw) <= MAX_PLAUSIBLE_KW + ? (previousPowerKw + powerKw) * 0.5d + : powerKw; + kwh = effectiveKw * hours; } double socDrop = (!charging && previousSocPercent != null && socPercent != null) diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index ceb79e9..75d515b 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -119,7 +119,7 @@ public class MainActivity extends AppCompatActivity { private TextInputEditText consumptionSohInput; private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now(); private TextView chargeStatus, chargeSoc, chargeStartSoc, chargeVoltage, chargeCurrent, chargePower, - chargeEnergy, chargeDuration, chargeTotalCost, chargeCurveTable; + chargeEnergy, chargeGridEnergy, chargeDuration, chargeTotalCost, chargeCurveTable; private TextInputLayout chargePriceLayout; private TextInputEditText chargePriceInput; private ChargeSessionTracker chargeSessionTracker; @@ -936,6 +936,7 @@ public class MainActivity extends AppCompatActivity { chargeCurrent = chargingPane.findViewById(R.id.charge_current); chargePower = chargingPane.findViewById(R.id.charge_power); chargeEnergy = chargingPane.findViewById(R.id.charge_energy); + chargeGridEnergy = chargingPane.findViewById(R.id.charge_grid_energy); chargeDuration = chargingPane.findViewById(R.id.charge_duration); chargeTotalCost = chargingPane.findViewById(R.id.charge_total_cost); chargeGraph = chargingPane.findViewById(R.id.charge_graph); @@ -980,6 +981,8 @@ public class MainActivity extends AppCompatActivity { chargeCurrent.setText(fmt(s.getBatteryCurrentA(), "%.1f A")); chargePower.setText(s.getBatteryPowerKw() == null ? "—" : String.format(java.util.Locale.getDefault(), "%.1f kW", Math.abs(s.getBatteryPowerKw()))); chargeEnergy.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f kWh", session.energyKwh) : "—"); + chargeGridEnergy.setText(session.startedAtMs > 0 ? String.format( + java.util.Locale.getDefault(), "%.2f kWh", session.gridEnergyKwh) : "—"); long sec = session.durationSeconds; chargeDuration.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%02d:%02d:%02d", sec/3600,(sec%3600)/60,sec%60) : "—"); chargeTotalCost.setText(session.startedAtMs > 0 ? String.format(java.util.Locale.getDefault(), "%.2f ₺", session.totalCost) : "—"); diff --git a/app/src/main/res/layout/pane_charging.xml b/app/src/main/res/layout/pane_charging.xml index 55356de..4d561aa 100644 --- a/app/src/main/res/layout/pane_charging.xml +++ b/app/src/main/res/layout/pane_charging.xml @@ -9,7 +9,8 @@ - + + diff --git a/app/src/test/java/com/evsuite/abrp/ChargeCostTest.java b/app/src/test/java/com/evsuite/abrp/ChargeCostTest.java new file mode 100644 index 0000000..175a0f4 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/ChargeCostTest.java @@ -0,0 +1,13 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class ChargeCostTest { + @Test + public void costIncludesTenPercentChargingLoss() { + assertEquals(55d, ChargeSessionTracker.gridEnergyKwh(50d), 0.000001d); + assertEquals(550d, ChargeSessionTracker.totalCost(50d, 10d), 0.000001d); + } +} diff --git a/app/src/test/java/com/evsuite/abrp/ConsumptionMathTest.java b/app/src/test/java/com/evsuite/abrp/ConsumptionMathTest.java index c5320f3..8eb3825 100644 --- a/app/src/test/java/com/evsuite/abrp/ConsumptionMathTest.java +++ b/app/src/test/java/com/evsuite/abrp/ConsumptionMathTest.java @@ -32,10 +32,13 @@ public class ConsumptionMathTest { } @Test - public void speedBelowTheDeadBandContributesNoDistance() { + public void speedBelowTheDeadBandContributesNothingToDrivingTotals() { ConsumptionMath.Step step = - ConsumptionMath.integrate(DT, 2f, 2f, null, null, null, null, false); + ConsumptionMath.integrate(DT, 2f, 2f, 8f, 8f, 80f, 79f, false); assertEquals(0d, step.km, TOLERANCE); + assertEquals(0d, step.kwh, TOLERANCE); + assertEquals(0d, step.hours, TOLERANCE); + assertEquals(0d, step.socDrop, TOLERANCE); } @Test @@ -70,11 +73,11 @@ public class ConsumptionMathTest { } @Test - public void standstillPowerFlowingOutStillCounts() { - // Parked with the climate running is real consumption. + public void parkedClimateUseIsExcludedFromDrivingConsumption() { ConsumptionMath.Step step = ConsumptionMath.integrate(DT, 0f, 0f, 2f, 2f, null, null, false); - assertEquals(2d * DT, step.kwh, TOLERANCE); + assertEquals(0d, step.kwh, TOLERANCE); + assertEquals(0d, step.hours, TOLERANCE); } @Test diff --git a/web/.env.example b/web/.env.example index 23955c8..5cd72df 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,5 +1,5 @@ -MONGODB_URI mongodb+srv://boklukunefe:9rprDcfHTjBYza7z@cluster0.1aarkvo.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 -MONGODB_DATABASE fatihsmg4 -INGEST_TOKEN 066da6fc58a0fcd8a35deccb29e43ef7a0b3bcda5a3c2b99db86e64a5b982ec6 -SESSION_SECRET a89b011dfe7bec22c4f44f89e8e0ef647e6ff3289e84ca1b5b68c413ff7f4b77 -DASHBOARD_PASSWORD \ No newline at end of file +MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/ +MONGODB_DATABASE=fatihsmg4 +INGEST_TOKEN=replace-with-a-long-random-write-token +SESSION_SECRET=replace-with-a-long-random-session-secret +DASHBOARD_PASSWORD=replace-with-a-strong-dashboard-password diff --git a/web/src/app/charging/page.tsx b/web/src/app/charging/page.tsx index 7a2d760..0f0c9c6 100644 --- a/web/src/app/charging/page.tsx +++ b/web/src/app/charging/page.tsx @@ -27,11 +27,12 @@ export default async function ChargingPage() { return (
- + + 0 ? money(total.cost / total.energyKwh) : "—"} + value={total.gridEnergyKwh > 0 ? money(total.cost / total.gridEnergyKwh) : "—"} note="kWh başına" tone="teal" /> @@ -78,7 +79,8 @@ export default async function ChargingPage() { - + + 0 ? `${num(peak)} kW` : "—"} /> 0 ? `${num(average)} kW` : "—"} /> diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 3d4b312..a6cae0f 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -36,8 +36,8 @@ export default async function OverviewPage() { >
0 ? `${money(charge.cost / charge.energyKwh)}/kWh ort.` : undefined} + note={charge.gridEnergyKwh > 0 ? `${money(charge.cost / charge.gridEnergyKwh)}/kWh ort.` : undefined} tone="gold" /> @@ -68,7 +68,8 @@ export default async function OverviewPage() {

Son şarj

- + + ( (total, session) => ({ energyKwh: total.energyKwh + session.energyKwh, + gridEnergyKwh: total.gridEnergyKwh + (session.gridEnergyKwh ?? session.energyKwh * 1.1), cost: total.cost + session.totalCost, sessions: total.sessions + 1, seconds: total.seconds + session.durationSeconds, @@ -68,7 +70,7 @@ export function sumCharging(sessions: ChargingSession[]): ChargeTotals { : 0), peakKw: Math.max(total.peakKw, ...session.curve.map((point) => point.powerKw), 0), }), - { energyKwh: 0, cost: 0, sessions: 0, seconds: 0, socGained: 0, peakKw: 0 }, + { energyKwh: 0, gridEnergyKwh: 0, cost: 0, sessions: 0, seconds: 0, socGained: 0, peakKw: 0 }, ); } diff --git a/web/src/lib/mongo.ts b/web/src/lib/mongo.ts index 5e85f59..4bac775 100644 --- a/web/src/lib/mongo.ts +++ b/web/src/lib/mongo.ts @@ -41,6 +41,8 @@ export interface ChargingSession { endedAt: Date; durationSeconds: number; energyKwh: number; + chargingLossPercent: number; + gridEnergyKwh: number; pricePerKwh: number; totalCost: number; startSocPercent?: number; diff --git a/web/src/lib/validate.ts b/web/src/lib/validate.ts index 8e4308f..333a916 100644 --- a/web/src/lib/validate.ts +++ b/web/src/lib/validate.ts @@ -49,15 +49,21 @@ export function validateChargingSession(body: unknown, pathSessionId: string): C }); const startSocPercent = record.startSocPercent; const endSocPercent = record.endSocPercent; + const energyKwh = bounded(record.energyKwh, 0, 1000, "energyKwh"); + const pricePerKwh = bounded(record.pricePerKwh, 0, 1_000_000, "pricePerKwh"); + const chargingLossPercent = 10; + const gridEnergyKwh = energyKwh * 1.1; return { installationId: validateUuid(record.installationId, "installationId"), sessionId, startedAt: validateDate(record.startedAt, "startedAt"), endedAt: validateDate(record.endedAt, "endedAt"), durationSeconds: bounded(record.durationSeconds, 0, 604800, "durationSeconds"), - energyKwh: bounded(record.energyKwh, 0, 1000, "energyKwh"), - pricePerKwh: bounded(record.pricePerKwh, 0, 1_000_000, "pricePerKwh"), - totalCost: bounded(record.totalCost, 0, 1_000_000_000, "totalCost"), + energyKwh, + chargingLossPercent, + gridEnergyKwh, + pricePerKwh, + totalCost: gridEnergyKwh * pricePerKwh, ...(startSocPercent === undefined ? {} : { startSocPercent: bounded(startSocPercent, 0, 100, "startSocPercent") }), ...(endSocPercent === undefined ? {} : { endSocPercent: bounded(endSocPercent, 0, 100, "endSocPercent") }), curve, diff --git a/web/test/validate.test.ts b/web/test/validate.test.ts index 9319d49..ab1144c 100644 --- a/web/test/validate.test.ts +++ b/web/test/validate.test.ts @@ -52,6 +52,8 @@ test("a charging session must match the id in its path", () => { endedAt: 1_760_002_000_000, durationSeconds: 1800, energyKwh: 31.4, + chargingLossPercent: 10, + gridEnergyKwh: 34.54, pricePerKwh: 7.5, totalCost: 235.5, startSocPercent: 18, @@ -61,6 +63,9 @@ test("a charging session must match the id in its path", () => { const record = validateChargingSession(body, SESSION); assert.equal(record.curve.length, 1); assert.equal(record.curve[0].powerKw, 84.2); + assert.equal(record.chargingLossPercent, 10); + assert.equal(record.gridEnergyKwh, 34.54); + assert.equal(record.totalCost, 259.05); assert.throws(() => validateChargingSession(body, "11111111-2222-4333-8444-555555555556"), ValidationError); }); @@ -79,6 +84,8 @@ test("refuses a curve longer than the car can produce", () => { endedAt: 2, durationSeconds: 1, energyKwh: 1, + chargingLossPercent: 10, + gridEnergyKwh: 1.1, pricePerKwh: 1, totalCost: 1, curve,