From 422bbc79aafd2f1b5c11f5515f80bb38e56c4e29 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:07:37 +0200 Subject: [PATCH] fix(hev): read the energy counters as fuel, not kilowatt-hours The gateway publishes drivetrain/powerUsageOfDay on the same topic for every drivetrain, but a plain hybrid has no traction battery worth metering: the trip computer puts its fuel total there instead, in hundredths of a litre. Read as kWh that turned a 59 km day into "394 kWh today" and "6678 Wh/km". The scale is measured, not guessed. Regressing the counter against the fuel gauge within each tank over 90 days of an HS Hybrid+ (20 tanks, r2 0.94 to 0.997) implies a 54.7 L tank against the 55 L the car actually has. The same car's soc_kwh and totalBatteryCapacity are the BMS percentage scaled by an EV-sized 72.5 kWh default, roughly forty times the real 1.83 kWh pack. The percentage behind them is genuine, so the HV card now takes charge and capacity separately and shows kWh only once the real capacity is known. Every reading of those counters goes through utils/energy, so the unit is decided in one place rather than per card. BEV and PHEV behaviour is unchanged, since their counters really do hold kWh. Capacity is deployment configuration (HV_BATTERY_CAPACITY_KWH), served on the vehicle list DTO beside the detected drivetrain. Left unset, a plug-in car keeps the gateway's figure and a hybrid shows percentages only, rather than a confident wrong number of kWh. The widget API keeps exposing the raw counter as powerUsageOfDayKwh; renaming it would break existing Homepage configs, so the caveat is documented instead. Co-Authored-By: Claude Opus 5 --- .env.example | 9 ++ README.md | 16 ++- docker-compose.yml | 4 + docker/all-in-one/README.md | 1 + docker/all-in-one/entrypoint.sh | 4 + frontend/src/cards/__tests__/registry.spec.ts | 18 ++- frontend/src/cards/registry.ts | 26 +++- .../src/components/DashboardCardContent.vue | 48 ++++++- frontend/src/components/HvBatteryCard.vue | 45 +++--- frontend/src/locales/en.json | 15 +- frontend/src/locales/nl.json | 15 +- frontend/src/services/vehicleApi.ts | 6 + frontend/src/stores/__tests__/vehicle.spec.ts | 33 ++++- frontend/src/stores/vehicle.ts | 7 + frontend/src/utils/__tests__/energy.spec.ts | 131 ++++++++++++++++-- frontend/src/utils/energy.ts | 93 ++++++++++++- frontend/src/views/StatisticsView.vue | 27 ++-- .../Endpoints/VehicleEndpoints.cs | 12 +- src/GarageStack.Api/Program.cs | 1 + .../Configuration/HostingExtensions.cs | 18 +++ .../Configuration/HvBatteryCapacity.cs | 17 +++ .../HostingExtensionsTests.cs | 29 ++++ unraid/garagestack.xml | 11 ++ 23 files changed, 503 insertions(+), 83 deletions(-) create mode 100644 src/GarageStack.Core/Configuration/HvBatteryCapacity.cs diff --git a/.env.example b/.env.example index 2bb3c22..e0e41b1 100644 --- a/.env.example +++ b/.env.example @@ -191,3 +191,12 @@ OPENCHARGEMAP_API_KEY= # TYRE_PRESSURE_LOW_BAR=2.2 # TYRE_PRESSURE_GOOD_BAR=2.6 # TYRE_PRESSURE_HIGH_BAR=3.2 + +# ── High-voltage battery capacity ────────────────────────────────────────────── +# Usable capacity of the traction battery, in kWh. The MQTT gateway does not read +# this off the pack: it scales the BMS percentage by an EV-sized default, so the +# kWh it reports are right for a BEV or PHEV and far too large for a plain hybrid +# (an MG HS Hybrid+ carries 1.83 kWh and is reported as 72.5). Set your car's real +# figure to correct it. Left unset, a plug-in car keeps the gateway's number and a +# hybrid shows state of charge as a percentage only. +# HV_BATTERY_CAPACITY_KWH=1.83 diff --git a/README.md b/README.md index 257ca72..46cee2e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ GarageStack is a free, open-source vehicle monitoring dashboard for **modern MG - **Live dashboard** -- Real-time vehicle telemetry displayed as configurable cards. Cards are automatically shown or hidden based on your vehicle type (HEV, PHEV, BEV) and can be reordered or toggled individually in the dashboard's edit mode. - **Trip history** -- Browse past journeys on an interactive map with route playback and heatmap visualisation to identify frequently driven roads. -- **Energy statistics** -- Track daily energy consumption, efficiency (Wh/km), fuel use, electric share, average driving speed, and more over a configurable time window. +- **Energy statistics** -- Track daily energy consumption, efficiency (Wh/km on a plug-in car, L/100 km on a hybrid), fuel use, electric share, average driving speed, and more over a configurable time window. - **Remote commands** -- Trigger climate pre-conditioning, lock or unlock the car, and activate the horn and lights remotely from the dashboard. - **Push notifications** -- Browser and in-app alerts for key events: engine started, low tyre pressure, low EV battery, car left unlocked, and doors or windows left open. - **Homepage widget** -- A read-only API endpoint for the [gethomepage.dev](https://gethomepage.dev) Custom API widget, exposing key vehicle stats at a glance. @@ -37,13 +37,13 @@ Cards are shown or hidden automatically based on vehicle type (HEV / PHEV / BEV) | Windows | Window and sunroof states | All | | Sunroof | Sunroof open/closed | All (off by default) | | Climate | Temperature, seat heating, defroster | All | -| HV Battery | kWh, voltage, current, power | All | +| HV Battery | State of charge, voltage, current, power (kWh too, with `HV_BATTERY_CAPACITY_KWH` set) | All | | Find My Car | Horn + lights to locate the car | All | | Lights | Main beam, low beam, sidelights | All | | Daily Distance | Distance driven today | All | -| Daily Energy | Energy used today (Wh) | All | +| Daily Energy | Energy used today (kWh), or fuel burned today (L) on an HEV | All | | Since Charge | Distance since last charge session | PHEV, BEV | -| Efficiency | Energy per km (Wh/km) | All | +| Efficiency | Energy per km (Wh/km), or fuel consumption (L/100 km) on an HEV | All | | Speed | Current vehicle speed | All | | Top Speed | Highest speed recorded in the most recent completed trip | All | | Active Trip | Distance covered in the current trip | All | @@ -180,6 +180,8 @@ Sign-in is configured separately: the built-in login reuses `SAIC_USER` / `SAIC_ `TYRE_PRESSURE_LOW_BAR` / `TYRE_PRESSURE_GOOD_BAR` / `TYRE_PRESSURE_HIGH_BAR` are optional and default to `2.2` / `2.6` / `3.2` bar; override them to match your vehicle's placarded tyre pressure (see [Push notifications](#push-notifications) below). +`HV_BATTERY_CAPACITY_KWH` is optional and tells GarageStack how big the traction battery really is. The MQTT gateway does not read this off the pack, it scales the BMS percentage by an EV-sized default, so the kWh it reports are right for a BEV or PHEV and far too large for a plain hybrid (an MG HS Hybrid+ carries 1.83 kWh and is reported as 72.5). Left unset, a plug-in car keeps the gateway's figure and a hybrid shows state of charge as a percentage only. + `RATE_LIMIT_GLOBAL_PER_MINUTE` is optional and defaults to `120` requests per minute per client IP. Raise it when several people reach GarageStack through one public address, or when something polls the API frequently; the tighter limits protecting login and the widget endpoint are unaffected. #### 3. Start the stack @@ -371,14 +373,14 @@ The endpoint returns a flat JSON object. Numeric fields are `null` when the vehi | `isCharging` | string | Charging state: `"Charging"` or `"Not charging"` | | `chargerConnected` | string | Charger connection state: `"Plugged in"` or `"Unplugged"` | | `mileageSinceLastCharge` | number | Distance driven since last full charge (km) | -| `hvSocKwh` | number | HV battery energy (kWh) | -| `hvTotalCapacityKwh` | number | HV battery total capacity (kWh) | +| `hvSocKwh` | number | HV battery energy (kWh), as the gateway reports it | +| `hvTotalCapacityKwh` | number | HV battery total capacity (kWh), as the gateway reports it | | `hvVoltage` | number | HV system voltage (V) | | `hvCurrent` | number | HV system current (A) | | `hvPower` | number | HV system power (W) | | `odometerKm` | number | Total odometer reading (km) | | `mileageOfTheDayKm` | number | Distance driven today (km) | -| `powerUsageOfDayKwh` | number | Energy used today (kWh) | +| `powerUsageOfDayKwh` | number | Energy used today (kWh). On a plain hybrid this counter holds the trip computer's fuel total in hundredths of a litre instead, so divide by 100 for litres | | `electricSharePercent` | number | % of today's distance driven on electric power (PHEV) | | `isLocked` | string | Lock state: `"Locked"` or `"Unlocked"` | | `engineRunning` | string | Engine state: `"Engine on"` or `"Engine off"` | diff --git a/docker-compose.yml b/docker-compose.yml index 94ccb82..29c0935 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -162,6 +162,10 @@ services: Auth__CookieSecure: "${AUTH_COOKIE_SECURE:-false}" Auth__SessionLifetimeHours: "${AUTH_SESSION_LIFETIME_HOURS:-168}" RateLimits__GlobalPerMinute: "${RATE_LIMIT_GLOBAL_PER_MINUTE:-}" + # The traction battery's real capacity, which the MQTT gateway assumes rather than reads. + # Left unset, a plug-in car keeps the gateway's figure and a hybrid shows charge as a + # percentage only. See .env.example. + HvBattery__CapacityKwh: "${HV_BATTERY_CAPACITY_KWH:-}" ports: - "127.0.0.1:${API_PORT:-5000}:8080" volumes: diff --git a/docker/all-in-one/README.md b/docker/all-in-one/README.md index d71e74e..ee558a0 100644 --- a/docker/all-in-one/README.md +++ b/docker/all-in-one/README.md @@ -63,6 +63,7 @@ By default the web login uses the same `SAIC_USER` and `SAIC_PASSWORD` credentia | `OVERPASS__BASEURL` | Overpass API endpoint used for the fuel station and motorway service area map overlays. Defaults to the public endpoint (`https://overpass-api.de/api/interpreter`). Set this only if you self-host an Overpass instance. No API key is required for the default public endpoint. | | `RATE_LIMIT_GLOBAL_PER_MINUTE` | Requests per minute the API accepts per client IP (default: `120`). Raise it when several people share one public address; the tighter limits on login and the widget endpoint apply regardless. | | `TYRE_PRESSURE_LOW_BAR` / `TYRE_PRESSURE_GOOD_BAR` / `TYRE_PRESSURE_HIGH_BAR` | Colour-coding and notification thresholds (bar) for tyre pressure. Default to `2.2` / `2.6` / `3.2`. Override to match your vehicle's placarded pressure, e.g. `TYRE_PRESSURE_GOOD_BAR=2.55`. | +| `HV_BATTERY_CAPACITY_KWH` | Usable capacity of the traction battery, in kWh. The MQTT gateway assumes an EV-sized pack rather than reading the real one, so its kWh figures are far too large on a plain hybrid (an MG HS Hybrid+ carries `1.83` and is reported as `72.5`). Leave empty to keep the gateway's figure on a BEV or PHEV and show charge as a percentage only on a hybrid. | | `SAIC_REST_URI` | Override for the SAIC gateway API endpoint. Only needed if your region isn't listed in the `SAIC_REGION` row above -- set it directly to your gateway's endpoint. | | `POSTGRES_DB` | Database name (default: `garagestack`) | | `POSTGRES_USER` | Database user (default: `garagestack`) | diff --git a/docker/all-in-one/entrypoint.sh b/docker/all-in-one/entrypoint.sh index 557d820..446fdf4 100644 --- a/docker/all-in-one/entrypoint.sh +++ b/docker/all-in-one/entrypoint.sh @@ -95,6 +95,10 @@ export TyrePressure__LowBar="${TYRE_PRESSURE_LOW_BAR:-}" export TyrePressure__GoodBar="${TYRE_PRESSURE_GOOD_BAR:-}" export TyrePressure__HighBar="${TYRE_PRESSURE_HIGH_BAR:-}" +# The traction battery's real capacity, which the MQTT gateway assumes rather than reads. +# Optional; empty means "trust the gateway on a plug-in car, show a hybrid percentages only". +export HvBattery__CapacityKwh="${HV_BATTERY_CAPACITY_KWH:-}" + # .NET API listens on an internal port; nginx proxies port 80 to it export ASPNETCORE_URLS="http://127.0.0.1:9000" diff --git a/frontend/src/cards/__tests__/registry.spec.ts b/frontend/src/cards/__tests__/registry.spec.ts index 667e3b2..d081c62 100644 --- a/frontend/src/cards/__tests__/registry.spec.ts +++ b/frontend/src/cards/__tests__/registry.spec.ts @@ -4,6 +4,8 @@ import { cardHasData, cardIcon, defaultCards, + hasEnergyEfficiency, + hasFuelConsumption, type CardDataContext, type CardId, } from '@/cards/registry' @@ -55,16 +57,30 @@ describe('card registry', () => { expect(cardHasData('efficiencyCharge', context(status, 'bev'))).toBe(true) }) - it('accepts either presentation of the efficiency ratio card', () => { + it('accepts any presentation of the efficiency ratio card', () => { const energy = context({ powerUsageOfDay: 8, mileageOfTheDay: 40 }, 'bev') + const consumption = context({ powerUsageOfDay: 394, mileageOfTheDay: 59 }, 'hev') const fuel = context({ fuelRangeKm: 400, fuelLevelPercent: 50 }, 'hev') const neither = context({ powerUsageOfDay: null, mileageOfTheDay: null }, 'bev') expect(cardHasData('efficiencyRatio', energy)).toBe(true) + expect(cardHasData('efficiencyRatio', consumption)).toBe(true) expect(cardHasData('efficiencyRatio', fuel)).toBe(true) expect(cardHasData('efficiencyRatio', neither)).toBe(false) }) + it('splits the efficiency ratio by what the energy counter measures', () => { + const driven = { powerUsageOfDay: 394, mileageOfTheDay: 59 } + + // A hybrid's counter is fuel, so Wh/km would be nonsense and L/100 km is the reading + expect(hasEnergyEfficiency(context(driven, 'hev'))).toBe(false) + expect(hasFuelConsumption(context(driven, 'hev'))).toBe(true) + + // A plug-in car cycles a real pack, so the counter is kWh and stays Wh/km + expect(hasEnergyEfficiency(context(driven, 'phev'))).toBe(true) + expect(hasFuelConsumption(context(driven, 'phev'))).toBe(false) + }) + it('needs a trip with recorded speeds for the top speed card', () => { expect(cardHasData('topSpeed', context({}, 'bev', null))).toBe(false) expect(cardHasData('topSpeed', context({}, 'bev', trip([null, null])))).toBe(false) diff --git a/frontend/src/cards/registry.ts b/frontend/src/cards/registry.ts index 1719939..573e1e5 100644 --- a/frontend/src/cards/registry.ts +++ b/frontend/src/cards/registry.ts @@ -1,6 +1,6 @@ import type { TelemetrySnapshot, Trip } from '@/services/vehicleApi' import type { VehicleType } from '@/stores/vehicle' -import { whPerKm } from '@/utils/energy' +import { energyUnit, litresPer100Km, whPerKm } from '@/utils/energy' /** * Everything the dashboard needs to know about a card, in one place: its icon, whether it is @@ -85,9 +85,10 @@ export const CARD_DEFINITIONS = [ { id: 'efficiencyRatio', icon: 'leaf', - // Two presentations share this card: Wh/km while driving data is available, and a fuel - // economy estimate for vehicles that burn fuel. DashboardCardContent picks between them. - hasData: (ctx) => hasEnergyEfficiency(ctx) || hasFuelEconomy(ctx), + // Three presentations share this card: measured L/100 km on a hybrid, Wh/km on a plug-in + // car, and a fuel economy estimate from the range computer for anything that burns fuel but + // has no driving data yet. DashboardCardContent picks between them. + hasData: (ctx) => hasFuelConsumption(ctx) || hasEnergyEfficiency(ctx) || hasFuelEconomy(ctx), }, { id: 'speed', @@ -161,9 +162,20 @@ export function defaultCards(type: VehicleType = 'unknown'): CardConfig[] { return [...all.filter((c) => c.visible), ...all.filter((c) => !c.visible)] } -/** Wh/km over today's driving, the efficiencyRatio card's primary presentation. */ -export function hasEnergyEfficiency({ status }: CardDataContext): boolean { - return whPerKm(status.powerUsageOfDay, status.mileageOfTheDay) !== null +/** Wh/km over today's driving, the efficiencyRatio card's presentation for a plug-in car. */ +export function hasEnergyEfficiency({ status, vehicleType }: CardDataContext): boolean { + return ( + energyUnit(vehicleType) === 'kwh' && + whPerKm(status.powerUsageOfDay, status.mileageOfTheDay) !== null + ) +} + +/** L/100 km over today's driving, the efficiencyRatio card's presentation for a hybrid. */ +export function hasFuelConsumption({ status, vehicleType }: CardDataContext): boolean { + return ( + energyUnit(vehicleType) === 'litres' && + litresPer100Km(status.powerUsageOfDay, status.mileageOfTheDay) !== null + ) } /** Fuel economy from the range computer, the efficiencyRatio card's fallback for fuel burners. */ diff --git a/frontend/src/components/DashboardCardContent.vue b/frontend/src/components/DashboardCardContent.vue index 3741594..e82a09f 100644 --- a/frontend/src/components/DashboardCardContent.vue +++ b/frontend/src/components/DashboardCardContent.vue @@ -4,7 +4,13 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { useVehicleStore } from '@/stores/vehicle' import type { CardId } from '@/cards/registry' -import { cardHasData, cardIcon, hasEnergyEfficiency, hasFuelEconomy } from '@/cards/registry' +import { + cardHasData, + cardIcon, + hasEnergyEfficiency, + hasFuelConsumption, + hasFuelEconomy, +} from '@/cards/registry' import { useCardData } from '@/cards/useCardData' import StatusCard from './StatusCard.vue' import DoorsCard from './DoorsCard.vue' @@ -17,7 +23,7 @@ import ChargingSessionCard from './ChargingSessionCard.vue' import BatteryHeatingCard from './BatteryHeatingCard.vue' import MaintenanceSummaryCard from './MaintenanceSummaryCard.vue' import { formatNumber } from '@/utils/format' -import { whPerKm } from '@/utils/energy' +import { energyUnit, hvBatteryReading, litres, litresPer100Km, whPerKm } from '@/utils/energy' const props = defineProps<{ cardId: CardId }>() @@ -52,6 +58,16 @@ const supportsExternalCharge = computed( () => vehicleType.value === 'phev' || vehicleType.value === 'bev', ) +// A hybrid's energy counters hold fuel, not kWh, so the same telemetry field is labelled and +// scaled differently per drivetrain. See utils/energy. +const reportsFuelCounter = computed(() => energyUnit(vehicleType.value) === 'litres') + +const hvBattery = computed(() => { + const s = status.value + if (!s) return null + return hvBatteryReading(s, vehicleType.value, store.hvBatteryCapacityKwh) +}) + // Config table for the branches that render nothing but a plain . Branches that // dispatch to a dedicated sub-component (doors, climate, hvBattery, etc.) stay in the // v-else-if chain below since they aren't simple StatusCard-only cases. Whether a card has @@ -62,6 +78,8 @@ const simpleCards = computed((): SimpleCardConfig[] => { const ctx = cardData.value if (!s || !ctx) return [] const efficiencyWhPerKm = whPerKm(s.powerUsageOfDay, s.mileageOfTheDay) + const fuelUsedLitres = litres(s.powerUsageOfDay) + const consumptionL100Km = litresPer100Km(s.powerUsageOfDay, s.mileageOfTheDay) return [ { id: 'fuelLevel', @@ -128,18 +146,37 @@ const simpleCards = computed((): SimpleCardConfig[] => { value: s.mileageOfTheDay !== null ? formatNumber(s.mileageOfTheDay) : null, unit: t('common.km'), }, + // efficiencyEnergy - kWh through the traction battery on a plug-in car { id: 'efficiencyEnergy', + match: !reportsFuelCounter.value && s.powerUsageOfDay !== null, label: t('vehicle.efficiency.todayEnergy'), value: s.powerUsageOfDay !== null ? formatNumber(s.powerUsageOfDay) : null, unit: t('common.kwh'), }, + // efficiencyEnergy - litres burned on a hybrid, whose counter reports fuel + { + id: 'efficiencyEnergy', + match: reportsFuelCounter.value && fuelUsedLitres !== null, + icon: 'gas-pump', + label: t('vehicle.efficiency.todayFuel'), + value: fuelUsedLitres !== null ? formatNumber(fuelUsedLitres) : null, + unit: t('common.litre'), + }, { id: 'efficiencyCharge', label: t('vehicle.efficiency.sinceCharge'), value: s.mileageSinceLastCharge !== null ? formatNumber(s.mileageSinceLastCharge) : null, unit: t('common.km'), }, + // efficiencyRatio - measured L/100 km on a hybrid, which beats the estimate below + { + id: 'efficiencyRatio', + match: hasFuelConsumption(ctx), + label: t('vehicle.efficiency.consumption'), + value: consumptionL100Km !== null ? formatNumber(consumptionL100Km) : null, + unit: `${t('common.litre')}/100${t('common.km')}`, + }, // efficiencyRatio - Wh/km when driving data is available { id: 'efficiencyRatio', @@ -244,10 +281,11 @@ const activeSimpleCard = computed(() => { { - if ( - props.hvSocKwh === null || - props.hvTotalCapacityKwh === null || - props.hvTotalCapacityKwh === 0 - ) - return null - return Math.round((props.hvSocKwh / props.hvTotalCapacityKwh) * 100) -}) +const roundedSocPercent = computed(() => + props.socPercent === null ? null : Math.round(props.socPercent), +) const summaryValue = computed((): string | null => { const parts: string[] = [] - if (socPercent.value !== null) parts.push(`${socPercent.value}%`) - else if (props.hvSocKwh !== null) parts.push(`${formatNumber(props.hvSocKwh)} kWh`) + if (roundedSocPercent.value !== null) parts.push(`${roundedSocPercent.value}%`) + else if (props.storedKwh !== null) parts.push(`${formatNumber(props.storedKwh)} kWh`) if (props.hvBatteryActive !== null) parts.push(props.hvBatteryActive ? t('vehicle.hvBattery.active') : t('vehicle.hvBattery.idle')) return parts.length ? parts.join(' · ') : null }) const summaryVariant = computed(() => { - if (socPercent.value === null) return undefined - if (socPercent.value < 20) return 'danger' as const - if (socPercent.value < 50) return 'warning' as const + if (roundedSocPercent.value === null) return undefined + if (roundedSocPercent.value < 20) return 'danger' as const + if (roundedSocPercent.value < 50) return 'warning' as const return 'success' as const }) const hasAnyData = computed( - () => props.hvSocKwh !== null || props.hvVoltage !== null || props.hvPower !== null, + () => props.socPercent !== null || props.hvVoltage !== null || props.hvPower !== null, ) function setChargeLimit(value: string) { @@ -69,21 +68,21 @@ function setChargeLimit(value: string) { >
{ }) }) +describe('useVehicleStore - hvBatteryCapacityKwh', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('is null before a vehicle is fetched and when the deployment configured none', () => { + const store = useVehicleStore() + expect(store.hvBatteryCapacityKwh).toBeNull() + + store.vehicles = [vehicle('hev')] + expect(store.hvBatteryCapacityKwh).toBeNull() + }) + + it('reports the configured capacity for the active vehicle', () => { + const store = useVehicleStore() + store.vehicles = [{ ...vehicle('hev'), hvBatteryCapacityKwh: 1.83 }] + expect(store.hvBatteryCapacityKwh).toBe(1.83) + }) +}) + describe('useVehicleStore - fetchVehicles', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -98,7 +119,15 @@ describe('useVehicleStore - fetchVehicles', () => { it('populates vehicles on success', async () => { const { vehicleApi } = await import('@/services/vehicleApi') vi.mocked(vehicleApi.list).mockResolvedValue([ - { id: 1, vin: 'ABC123', model: 'MG ZS EV', series: null, createdAt: '', vehicleType: 'bev' }, + { + id: 1, + vin: 'FAKEVN00000000002', + model: 'MG ZS EV', + series: null, + createdAt: '', + vehicleType: 'bev', + hvBatteryCapacityKwh: null, + }, ]) const store = useVehicleStore() await store.fetchVehicles() @@ -106,7 +135,7 @@ describe('useVehicleStore - fetchVehicles', () => { const firstVehicle = store.vehicles[0] expect(firstVehicle).toBeDefined() if (!firstVehicle) throw new Error('Expected first vehicle to exist') - expect(firstVehicle.vin).toBe('ABC123') + expect(firstVehicle.vin).toBe('FAKEVN00000000002') }) it('sets error on failure', async () => { diff --git a/frontend/src/stores/vehicle.ts b/frontend/src/stores/vehicle.ts index 31ec145..fb3adfb 100644 --- a/frontend/src/stores/vehicle.ts +++ b/frontend/src/stores/vehicle.ts @@ -121,6 +121,12 @@ export const useVehicleStore = defineStore('vehicle', () => { return override === 'auto' ? detectedVehicleType.value : override }) + // The deployment's answer for how big the traction battery really is, or null when it has not + // been told. Read alongside the snapshot's own kWh figures in utils/energy. + const hvBatteryCapacityKwh = computed( + (): number | null => activeVehicle.value?.hvBatteryCapacityKwh ?? null, + ) + return { vehicles, activeVehicle, @@ -129,6 +135,7 @@ export const useVehicleStore = defineStore('vehicle', () => { vehicleConfig, detectedVehicleType, effectiveVehicleType, + hvBatteryCapacityKwh, history, trips, loading, diff --git a/frontend/src/utils/__tests__/energy.spec.ts b/frontend/src/utils/__tests__/energy.spec.ts index 9910006..44462ab 100644 --- a/frontend/src/utils/__tests__/energy.spec.ts +++ b/frontend/src/utils/__tests__/energy.spec.ts @@ -1,5 +1,28 @@ import { describe, it, expect } from 'vitest' -import { whPerKm, dailyEnergyKwh } from '@/utils/energy' +import { + dailyCounterTotal, + energyUnit, + hvBatteryReading, + litres, + litresPer100Km, + whPerKm, +} from '@/utils/energy' +import type { TelemetrySnapshot } from '@/services/vehicleApi' + +type HvFields = Pick + +function hv(fields: Partial): HvFields { + return { evSocPercent: null, hvSocKwh: null, hvTotalCapacityKwh: null, ...fields } +} + +describe('energyUnit', () => { + it('reads the counters as fuel only on a plain hybrid', () => { + expect(energyUnit('hev')).toBe('litres') + expect(energyUnit('phev')).toBe('kwh') + expect(energyUnit('bev')).toBe('kwh') + expect(energyUnit('unknown')).toBe('kwh') + }) +}) describe('whPerKm', () => { it('converts kWh over km to Wh/km', () => { @@ -17,36 +40,122 @@ describe('whPerKm', () => { }) }) -describe('dailyEnergyKwh', () => { +describe('litres', () => { + it('reads the hybrid fuel counter as litres', () => { + // An MG HS Hybrid+ reporting 394 has burned 3.94 L, not 394 kWh + expect(litres(394)).toBeCloseTo(3.94, 2) + expect(litres(0)).toBe(0) + }) + + it('returns null without a reading', () => { + expect(litres(null)).toBeNull() + }) +}) + +describe('litresPer100Km', () => { + it('turns the fuel counter and distance into L/100 km', () => { + // 3.94 L over 59 km on an MG HS Hybrid+ + expect(litresPer100Km(394, 59)).toBeCloseTo(6.68, 2) + // A short cold run burns more per km: 1.715 L over 14 km + expect(litresPer100Km(171.5, 14)).toBeCloseTo(12.25, 2) + }) + + it('returns null without a reading or distance', () => { + expect(litresPer100Km(null, 59)).toBeNull() + expect(litresPer100Km(394, null)).toBeNull() + expect(litresPer100Km(394, 0)).toBeNull() + }) +}) + +describe('hvBatteryReading', () => { + it('rescales a hybrid to its configured capacity instead of the gateway figure', () => { + // The gateway scales 78.3% by an EV-sized 72.5 kWh; the pack actually holds 1.83 kWh + const reading = hvBatteryReading( + hv({ evSocPercent: 78.3, hvSocKwh: 56.8, hvTotalCapacityKwh: 72.5 }), + 'hev', + 1.83, + ) + + expect(reading.socPercent).toBe(78.3) + expect(reading.capacityKwh).toBe(1.83) + expect(reading.storedKwh).toBeCloseTo(1.43, 2) + }) + + it('shows a hybrid no kWh at all until the real capacity is configured', () => { + const reading = hvBatteryReading( + hv({ evSocPercent: 78.3, hvSocKwh: 56.8, hvTotalCapacityKwh: 72.5 }), + 'hev', + null, + ) + + expect(reading.socPercent).toBe(78.3) + expect(reading.capacityKwh).toBeNull() + expect(reading.storedKwh).toBeNull() + }) + + it('keeps what the gateway reports for a car that really has a traction battery', () => { + const reading = hvBatteryReading( + hv({ evSocPercent: 80, hvSocKwh: 58, hvTotalCapacityKwh: 72.5 }), + 'bev', + null, + ) + + expect(reading.capacityKwh).toBe(72.5) + expect(reading.storedKwh).toBeCloseTo(58, 2) + }) + + it('falls back to the reported ratio when no percentage is published', () => { + const reading = hvBatteryReading(hv({ hvSocKwh: 36.25, hvTotalCapacityKwh: 72.5 }), 'bev', null) + + expect(reading.socPercent).toBeCloseTo(50, 5) + expect(reading.storedKwh).toBeCloseTo(36.25, 5) + }) + + it('reports nothing when the vehicle publishes no charge at all', () => { + const reading = hvBatteryReading(hv({}), 'phev', null) + + expect(reading.socPercent).toBeNull() + expect(reading.storedKwh).toBeNull() + expect(reading.capacityKwh).toBeNull() + }) +}) + +describe('dailyCounterTotal', () => { it('returns null without readings', () => { - expect(dailyEnergyKwh([], false)).toBeNull() - expect(dailyEnergyKwh([], true)).toBeNull() + expect(dailyCounterTotal([], false)).toBeNull() + expect(dailyCounterTotal([], true)).toBeNull() }) it('uses the cumulative peak for a completed day', () => { - expect(dailyEnergyKwh([2.1, 9.8, 16.3, 16.3], false)).toBe(16.3) + expect(dailyCounterTotal([2.1, 9.8, 16.3, 16.3], false)).toBe(16.3) }) it('uses the peak after a counter reset on the current day', () => { // 21.4 kWh carried over from yesterday, reset after midnight, then driving resumed - expect(dailyEnergyKwh([21.4, 0, 1.2, 4.5], true)).toBe(4.5) + expect(dailyCounterTotal([21.4, 0, 1.2, 4.5], true)).toBe(4.5) }) - it('treats a drop below 50 Wh as a reset even when the peak was small', () => { + it('treats a drop below the noise floor as a reset even when the peak was small', () => { // 0.03 is above 5% of the 0.5 peak, so only the noise floor flags it as a reset - expect(dailyEnergyKwh([0.5, 0.03, 0.8], true)).toBe(0.8) + expect(dailyCounterTotal([0.5, 0.03, 0.8], true)).toBe(0.8) }) it('returns null when the counter reset and nothing has been used since', () => { - expect(dailyEnergyKwh([18.7, 0], true)).toBeNull() + expect(dailyCounterTotal([18.7, 0], true)).toBeNull() }) it('returns only the net increase when the counter has not reset yet', () => { // A Wh-based floor (50) would flag every one of these as a reset and return 14 - expect(dailyEnergyKwh([10, 12.5, 14], true)).toBe(4) + expect(dailyCounterTotal([10, 12.5, 14], true)).toBe(4) }) it('returns null for an unchanged carryover with no driving', () => { - expect(dailyEnergyKwh([7.3, 7.3], true)).toBeNull() + expect(dailyCounterTotal([7.3, 7.3], true)).toBeNull() + }) + + it('works the same on the much larger numbers of a fuel counter', () => { + // Hundredths of a litre: yesterday's 907.7 carried over, reset, then 394 burned today + expect(dailyCounterTotal([907.7, 0, 106.6, 394], true)).toBe(394) + expect(dailyCounterTotal([106.6, 394], false)).toBe(394) }) }) diff --git a/frontend/src/utils/energy.ts b/frontend/src/utils/energy.ts index e9766c9..49bc5c5 100644 --- a/frontend/src/utils/energy.ts +++ b/frontend/src/utils/energy.ts @@ -1,18 +1,99 @@ -// Energy counters from the gateway (powerUsageOfDay, powerUsageSinceLastCharge) are published -// in kWh, not Wh. Every calculation on them goes through this file so the unit lives in one place. +import type { TelemetrySnapshot, VehicleType } from '@/services/vehicleApi' + +// The gateway publishes powerUsageOfDay and powerUsageSinceLastCharge on the same topics for +// every drivetrain, but they do not measure the same thing on every drivetrain. A car with a +// traction battery reports electrical energy in kWh. A plain hybrid has no such counter, so the +// trip computer puts its fuel total there instead, in hundredths of a litre: read as kWh that +// turns a 59 km day into "394 kWh". Every calculation on those counters goes through this file, +// so which unit applies is decided in one place. +// +// The hundredths-of-a-litre scale is measured, not guessed. Regressing the counter against the +// fuel gauge within each tank over 90 days of an MG HS Hybrid+ (20 tanks, r2 0.94 to 0.997) +// implies a 54.7 L tank against the 55 L the car actually has. + +export type EnergyUnit = 'kwh' | 'litres' + +/** Hundredths of a litre per litre, the scale a hybrid reports its fuel total in. */ +const COUNTER_UNITS_PER_LITRE = 100 // A reading below this is treated as a counter reset rather than noise -const RESET_NOISE_FLOOR_KWH = 0.05 +const RESET_NOISE_FLOOR = 0.05 // A reading below this fraction of the running peak is treated as a counter reset const RESET_PEAK_FRACTION = 0.05 +/** + * What the energy counters measure on this drivetrain. Only a plain hybrid reports fuel: a BEV + * and a PHEV both cycle a traction battery the counter can describe in kWh. + */ +export function energyUnit(vehicleType: VehicleType): EnergyUnit { + return vehicleType === 'hev' ? 'litres' : 'kwh' +} + export function whPerKm(energyKwh: number | null, distanceKm: number | null): number | null { if (energyKwh === null || distanceKm === null || distanceKm <= 0) return null return (energyKwh * 1000) / distanceKm } -// Energy consumed on one day from the cumulative powerUsageOfDay readings, oldest first. -export function dailyEnergyKwh(readings: readonly number[], isPartialDay: boolean): number | null { +/** Litres burned, from a hybrid's fuel counter. */ +export function litres(counter: number | null): number | null { + return counter === null ? null : counter / COUNTER_UNITS_PER_LITRE +} + +/** Fuel consumption in L/100 km, from a hybrid's fuel counter over the distance it covers. */ +export function litresPer100Km(counter: number | null, distanceKm: number | null): number | null { + const used = litres(counter) + if (used === null || distanceKm === null || distanceKm <= 0) return null + return (used / distanceKm) * 100 +} + +/** + * The high-voltage battery as it should be shown, given what the deployment knows about the car. + * + * The gateway does not read capacity off the pack: it scales the BMS percentage by an EV-sized + * default, which is right for a car that has one and off by a factor of forty on a hybrid whose + * buffer is under 2 kWh (an MG HS Hybrid+ carries 1.83 kWh and is reported as 72.5). So a + * configured capacity always wins, and a hybrid without one shows percentages only rather than + * a confident wrong number of kWh. + */ +export interface HvBatteryReading { + socPercent: number | null + storedKwh: number | null + capacityKwh: number | null +} + +export function hvBatteryReading( + status: Pick, + vehicleType: VehicleType, + configuredCapacityKwh: number | null, +): HvBatteryReading { + const reported = status.hvTotalCapacityKwh + const capacityKwh = configuredCapacityKwh ?? (vehicleType === 'hev' ? null : reported) + + // drivetrain/soc is the BMS reading itself. The ratio behind it is the fallback for a car that + // publishes only the derived pair, and survives rescaling because both sides carry the same + // assumed capacity. + const socPercent = + status.evSocPercent ?? + (status.hvSocKwh !== null && reported !== null && reported > 0 + ? (status.hvSocKwh / reported) * 100 + : null) + + return { + socPercent, + storedKwh: + capacityKwh !== null && socPercent !== null ? (capacityKwh * socPercent) / 100 : null, + capacityKwh, + } +} + +/** + * One day's total from the cumulative counter readings, oldest first. Unit-agnostic: it works on + * the running peak and on resets, which behave the same whether the counter holds kWh or litres. + */ +export function dailyCounterTotal( + readings: readonly number[], + isPartialDay: boolean, +): number | null { if (!readings.length) return null // Completed days: cumulative peak = day's total @@ -24,7 +105,7 @@ export function dailyEnergyKwh(readings: readonly number[], isPartialDay: boolea let peak = readings[0]! for (let i = 1; i < readings.length; i++) { peak = Math.max(peak, readings[i - 1]!) - if (readings[i]! < Math.max(peak * RESET_PEAK_FRACTION, RESET_NOISE_FLOOR_KWH)) { + if (readings[i]! < Math.max(peak * RESET_PEAK_FRACTION, RESET_NOISE_FLOOR)) { lastResetIdx = i } } diff --git a/frontend/src/views/StatisticsView.vue b/frontend/src/views/StatisticsView.vue index 0a33d62..4954e58 100644 --- a/frontend/src/views/StatisticsView.vue +++ b/frontend/src/views/StatisticsView.vue @@ -24,7 +24,7 @@ import StatusCard from '@/components/StatusCard.vue' import StatsChartCard, { type StatsChartType } from '@/components/StatsChartCard.vue' import EditableCardSlot from '@/components/EditableCardSlot.vue' import { formatNumber } from '@/utils/format' -import { dailyEnergyKwh } from '@/utils/energy' +import { dailyCounterTotal, energyUnit, litres } from '@/utils/energy' import { startOfLocalDayDaysAgoIso } from '@/utils/dates' const { t } = useI18n() @@ -426,19 +426,24 @@ const hybridSocChartData = computed(() => ({ ], })) -const dailyKwhChartData = computed(() => ({ +// The same counter, read in the unit this drivetrain actually reports: kWh out of the traction +// battery on a plug-in car, litres of fuel on a plain hybrid. See utils/energy. +const reportsFuelCounter = computed(() => energyUnit(vehicleType.value) === 'litres') + +const dailyEnergyChartData = computed(() => ({ labels: chartLabels.value, datasets: [ { - label: 'kWh', + label: reportsFuelCounter.value ? t('common.litre') : t('common.kwh'), data: groupedHistory.value.map((d) => { const readings = d.points .slice() .sort((a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime()) .map((p) => p.powerUsageOfDay) .filter((v): v is number => v !== null) - const kwh = dailyEnergyKwh(readings, d.key === toLocalDateKey(new Date())) - return kwh !== null ? round2(kwh) : null + const total = dailyCounterTotal(readings, d.key === toLocalDateKey(new Date())) + const used = reportsFuelCounter.value ? litres(total) : total + return used !== null ? round2(used) : null }), borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.7)', @@ -519,13 +524,17 @@ const chartDefs = computed((): ChartDef[] => [ }, { id: 'dailyKwhChart', - icon: CHART_ICONS.dailyKwhChart, - title: t('statistics.dailyKwhChart'), - description: t('statistics.chartDesc.dailyKwhChart'), + icon: reportsFuelCounter.value ? 'gas-pump' : CHART_ICONS.dailyKwhChart, + title: reportsFuelCounter.value + ? t('statistics.dailyFuelChart') + : t('statistics.dailyKwhChart'), + description: reportsFuelCounter.value + ? t('statistics.chartDesc.dailyFuelChart') + : t('statistics.chartDesc.dailyKwhChart'), vehicleApplicable: isHybrid.value, applicable: isHybrid.value && store.history.length > 0, type: 'bar', - data: dailyKwhChartData.value, + data: dailyEnergyChartData.value, options: kwhOptions, }, ]) diff --git a/src/GarageStack.Api/Endpoints/VehicleEndpoints.cs b/src/GarageStack.Api/Endpoints/VehicleEndpoints.cs index 098c816..9410f03 100644 --- a/src/GarageStack.Api/Endpoints/VehicleEndpoints.cs +++ b/src/GarageStack.Api/Endpoints/VehicleEndpoints.cs @@ -62,11 +62,16 @@ public static IEndpointRouteBuilder MapVehicleEndpoints(this IEndpointRouteBuild .WithTags("Vehicles") .RequireAuthorization(); + // Resolved once here rather than taken as a handler parameter: it is deployment + // configuration, not request input. + var hvBatteryCapacity = app.ServiceProvider.GetRequiredService(); + group.MapGet("/", async (IVehicleRepository vehicles, CancellationToken ct) => { var all = await vehicles.GetAllAsync(ct); return Results.Ok(all.Select(v => new VehicleListItemDto( - v.Id, v.Vin, v.Model, v.Series, v.CreatedAt, VehicleTypeHelper.GetVehicleType(v)))); + v.Id, v.Vin, v.Model, v.Series, v.CreatedAt, VehicleTypeHelper.GetVehicleType(v), + hvBatteryCapacity.Kwh))); }) .WithSummary("List all vehicles"); @@ -254,6 +259,8 @@ public static IEndpointRouteBuilder MapVehicleEndpoints(this IEndpointRouteBuild /// A vehicle as the list endpoint returns it. VehicleType is the drivetrain detected from /// the vehicle's reported hardware version (hev, phev, bev, or unknown while nothing has reported /// one), served here so every client reads the same answer instead of parsing it themselves. +/// HvBatteryCapacityKwh rides along for the same reason: it decides how a client turns a +/// state of charge into kWh, and is null when the deployment has not configured one. /// public record VehicleListItemDto( int Id, @@ -261,4 +268,5 @@ public record VehicleListItemDto( string? Model, string? Series, DateTime CreatedAt, - string VehicleType); + string VehicleType, + double? HvBatteryCapacityKwh); diff --git a/src/GarageStack.Api/Program.cs b/src/GarageStack.Api/Program.cs index 77a8f26..bcc88cc 100644 --- a/src/GarageStack.Api/Program.cs +++ b/src/GarageStack.Api/Program.cs @@ -79,6 +79,7 @@ }); builder.Services.AddTyrePressureThresholds(builder.Configuration); + builder.Services.AddHvBatteryCapacity(builder.Configuration); builder.Services.AddMemoryCache(); builder.Services.AddScoped(); diff --git a/src/GarageStack.Core/Configuration/HostingExtensions.cs b/src/GarageStack.Core/Configuration/HostingExtensions.cs index 0a5a0fe..061ac7e 100644 --- a/src/GarageStack.Core/Configuration/HostingExtensions.cs +++ b/src/GarageStack.Core/Configuration/HostingExtensions.cs @@ -71,6 +71,24 @@ static double Value(IConfiguration section, string key, double fallback) => : fallback; } + /// + /// The traction battery's real capacity from configuration, or + /// when the deployment has not said. A value of zero + /// or less is treated as unset: it would make every state of charge meaningless rather than + /// merely unknown. + /// + public static IServiceCollection AddHvBatteryCapacity( + this IServiceCollection services, IConfiguration configuration) + { + var configured = configuration["HvBattery:CapacityKwh"]; + var parsed = double.TryParse(configured, NumberStyles.Float, CultureInfo.InvariantCulture, out var kwh) + && kwh > 0 + ? new HvBatteryCapacity(kwh) + : HvBatteryCapacity.Unknown; + + return services.AddSingleton(parsed); + } + /// /// An integer setting, falling back to when it is unset, blank or /// unparseable. Deployments pass configuration through environment variables, where "unset" diff --git a/src/GarageStack.Core/Configuration/HvBatteryCapacity.cs b/src/GarageStack.Core/Configuration/HvBatteryCapacity.cs new file mode 100644 index 0000000..e2f39fe --- /dev/null +++ b/src/GarageStack.Core/Configuration/HvBatteryCapacity.cs @@ -0,0 +1,17 @@ +namespace GarageStack.Core.Configuration; + +/// +/// The traction battery's real usable capacity in kWh, when the deployment configures one. +/// +/// The MQTT gateway does not read capacity off the pack: it scales the BMS percentage by an +/// EV-sized default to produce drivetrain/soc_kwh and drivetrain/totalBatteryCapacity. That is +/// fine for a car that has such a pack and wrong by a factor of forty on a plain hybrid, where +/// the buffer is under 2 kWh but 72.5 is reported. Configuring the real figure here overrides it +/// for every client; left unset, a plug-in car keeps the gateway's number and a hybrid shows +/// state of charge as a percentage only. +/// +/// +public record HvBatteryCapacity(double? Kwh) +{ + public static readonly HvBatteryCapacity Unknown = new(Kwh: null); +} diff --git a/src/GarageStack.Tests/HostingExtensionsTests.cs b/src/GarageStack.Tests/HostingExtensionsTests.cs index b13401e..7e88ae7 100644 --- a/src/GarageStack.Tests/HostingExtensionsTests.cs +++ b/src/GarageStack.Tests/HostingExtensionsTests.cs @@ -59,6 +59,35 @@ public void TyrePressureThresholds_ReadsDecimalsThesameWayInEveryLocale() Assert.Equal(3.15, thresholds.HighBar); } + private static HvBatteryCapacity ResolveCapacity(IConfiguration configuration) => + new ServiceCollection() + .AddHvBatteryCapacity(configuration) + .BuildServiceProvider() + .GetRequiredService(); + + [Fact] + public void HvBatteryCapacity_WhenConfigured_OverridesWhatTheGatewayAssumes() + { + var capacity = ResolveCapacity(Config(("HvBattery:CapacityKwh", "1.83"))); + + Assert.Equal(1.83, capacity.Kwh); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a number")] + [InlineData("0")] + [InlineData("-5")] + public void HvBatteryCapacity_WithAnUnusableValue_StaysUnknown(string? configured) + { + var capacity = ResolveCapacity(Config(("HvBattery:CapacityKwh", configured))); + + Assert.Equal(HvBatteryCapacity.Unknown, capacity); + Assert.Null(capacity.Kwh); + } + [Theory] [InlineData(null, 120)] [InlineData("", 120)] diff --git a/unraid/garagestack.xml b/unraid/garagestack.xml index 427d045..e70fe80 100644 --- a/unraid/garagestack.xml +++ b/unraid/garagestack.xml @@ -459,6 +459,17 @@ Required="false" Mask="false">3.2 + +