Compare commits

..
1 Commits
Author SHA1 Message Date
tliu93 0958d9a2e9 fix(energy): report real kWh in the cost Summary instead of mislabelled money
docker-image / build-and-push (push) Successful in 1m37s
frontend / frontend (push) Successful in 10m24s
pytest / test (push) Successful in 11m57s
The Summary cards labelled `metered_import` / `metered_export` as "(kWh)", but
both fields are monetary totals (Σ import_cost / Σ export_revenue).  Today's
page therefore showed "Import 1.339 kWh" when the meter had actually imported
4.188 kWh — the 1.339 was EUR.  Cross-checked against the DSMR cumulative
registers and Home Assistant: our energy figures were correct all along, only
the label was wrong.

summarize() now also aggregates the metered energy, reusing the already-fetched
non-degraded rows so no extra query is issued:

  metered_import_kwh = Σ (d1_kwh + d2_kwh)
  metered_export_kwh = Σ (r1_kwh + r2_kwh)

The Import/Export cards show kWh as the headline figure and keep the monetary
equivalent as a sub-line, so the split between energy cost and standing
charges/credits behind total_payable stays visible.

The `_kwh` suffix is now the only thing separating energy from money in this
payload, so the docstrings on both summarize() and SummaryResponse call that
out explicitly.

app/integrations/expose.py reads only the money keys, so the HA outbound
sensors are unaffected by the additive fields.
2026-08-06 22:20:56 +02:00
9 changed files with 157 additions and 31 deletions
+18 -4
View File
@@ -121,15 +121,29 @@ class CostsResponse(BaseModel):
class SummaryResponse(BaseModel): class SummaryResponse(BaseModel):
"""Response for GET /api/energy/costs/summary. """Response for GET /api/energy/costs/summary.
All monetary values are in ``currency``. Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
only the ``_kwh``-suffixed fields carry kWh.
``total_payable = metered_net + fixed_costs credits`` ``total_payable = metered_net + fixed_costs credits``
""" """
currency: str currency: str
metered_import: float = Field(description="Σ import_cost for non-degraded periods.") metered_import: float = Field(
metered_export: float = Field(description="Σ export_revenue for non-degraded periods.") description="Σ import_cost for non-degraded periods (money, in `currency`)."
metered_net: float = Field(description="Σ net_cost for non-degraded periods.") )
metered_export: float = Field(
description="Σ export_revenue for non-degraded periods (money, in `currency`)."
)
metered_net: float = Field(
description="Σ net_cost for non-degraded periods (money, in `currency`)."
)
metered_import_kwh: float = Field(
description="Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh)."
)
metered_export_kwh: float = Field(
description="Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh)."
)
fixed_costs: float = Field( fixed_costs: float = Field(
description="Standing charges (network_fee + management_fee) apportioned over the interval." description="Standing charges (network_fee + management_fee) apportioned over the interval."
) )
+22 -3
View File
@@ -18,6 +18,12 @@ M6 design document, extended in M7-T03 to be meter-aware:
÷ 30 per day) and subtracts the energy-tax credit (heffingskorting, ÷ 30 per day) and subtracts the energy-tax credit (heffingskorting,
apportioned at EUR/year ÷ 365 per day). apportioned at EUR/year ÷ 365 per day).
The summary reports **both** money and energy: ``metered_import`` /
``metered_export`` are monetary totals (Σ import_cost / Σ export_revenue),
while ``metered_import_kwh`` / ``metered_export_kwh`` are the corresponding
metered energy totals in kWh. The ``_kwh`` suffix is the only thing that
distinguishes them — always check it before labelling a value in a UI.
Design notes Design notes
------------ ------------
- **Decimal arithmetic throughout**: all monetary computations use - **Decimal arithmetic throughout**: all monetary computations use
@@ -732,9 +738,11 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
dict with keys: dict with keys:
currency str ISO 4217 currency (from contract, or "EUR" fallback) currency str ISO 4217 currency (from contract, or "EUR" fallback)
metered_import float Σ import_cost from non-degraded periods metered_import float Σ import_cost from non-degraded periods (money)
metered_export float Σ export_revenue from non-degraded periods metered_export float Σ export_revenue from non-degraded periods (money)
metered_net float Σ net_cost from non-degraded periods metered_net float Σ net_cost from non-degraded periods (money)
metered_import_kwh float Σ (d1_kwh + d2_kwh) from non-degraded periods (energy)
metered_export_kwh float Σ (r1_kwh + r2_kwh) from non-degraded periods (energy)
fixed_costs float standing charges for elapsed whole local days fixed_costs float standing charges for elapsed whole local days
credits float energy-tax credit for elapsed whole local days credits float energy-tax credit for elapsed whole local days
total_payable float metered_net + fixed_costs credits total_payable float metered_net + fixed_costs credits
@@ -763,6 +771,15 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0")) sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0")) sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
# Σ metered energy (kWh), summed across both tariff registers. Reuses the
# already-fetched ``good_rows`` so no extra query is issued.
sum_import_kwh = sum(
(_to_decimal(r.d1_kwh) + _to_decimal(r.d2_kwh) for r in good_rows), Decimal("0")
)
sum_export_kwh = sum(
(_to_decimal(r.r1_kwh) + _to_decimal(r.r2_kwh) for r in good_rows), Decimal("0")
)
# --- Interval length in days (window, not elapsed — kept for API compat) --- # --- Interval length in days (window, not elapsed — kept for API compat) ---
total_seconds = (end_utc - start_utc).total_seconds() total_seconds = (end_utc - start_utc).total_seconds()
days = _to_decimal(str(total_seconds)) / _to_decimal("86400") days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
@@ -885,6 +902,8 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
"metered_import": float(sum_import), "metered_import": float(sum_import),
"metered_export": float(sum_export), "metered_export": float(sum_export),
"metered_net": float(sum_net), "metered_net": float(sum_net),
"metered_import_kwh": float(sum_import_kwh),
"metered_export_kwh": float(sum_export_kwh),
"fixed_costs": float(fixed_dec), "fixed_costs": float(fixed_dec),
"credits": float(credits_dec), "credits": float(credits_dec),
"total_payable": float(total_payable), "total_payable": float(total_payable),
+16 -4
View File
@@ -2220,7 +2220,9 @@ export interface components {
* SummaryResponse * SummaryResponse
* @description Response for GET /api/energy/costs/summary. * @description Response for GET /api/energy/costs/summary.
* *
* All monetary values are in ``currency``. * Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
* in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
* only the ``_kwh``-suffixed fields carry kWh.
* *
* ``total_payable = metered_net + fixed_costs credits`` * ``total_payable = metered_net + fixed_costs credits``
*/ */
@@ -2229,19 +2231,29 @@ export interface components {
currency: string; currency: string;
/** /**
* Metered Import * Metered Import
* @description Σ import_cost for non-degraded periods. * @description Σ import_cost for non-degraded periods (money, in `currency`).
*/ */
metered_import: number; metered_import: number;
/** /**
* Metered Export * Metered Export
* @description Σ export_revenue for non-degraded periods. * @description Σ export_revenue for non-degraded periods (money, in `currency`).
*/ */
metered_export: number; metered_export: number;
/** /**
* Metered Net * Metered Net
* @description Σ net_cost for non-degraded periods. * @description Σ net_cost for non-degraded periods (money, in `currency`).
*/ */
metered_net: number; metered_net: number;
/**
* Metered Import Kwh
* @description Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh).
*/
metered_import_kwh: number;
/**
* Metered Export Kwh
* @description Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh).
*/
metered_export_kwh: number;
/** /**
* Fixed Costs * Fixed Costs
* @description Standing charges (network_fee + management_fee) apportioned over the interval. * @description Standing charges (network_fee + management_fee) apportioned over the interval.
+11 -2
View File
@@ -61,9 +61,13 @@ const COST_PERIOD = {
const SUMMARY = { const SUMMARY = {
currency: 'EUR', currency: 'EUR',
// Money totals and kWh totals are deliberately distinct so the assertions
// below prove the cards read the *_kwh fields, not the monetary ones.
metered_import: 10.5, metered_import: 10.5,
metered_export: 2.3, metered_export: 2.3,
metered_net: 8.2, metered_net: 8.2,
metered_import_kwh: 33.3,
metered_export_kwh: 44.4,
fixed_costs: 5.0, fixed_costs: 5.0,
credits: 50.0, credits: 50.0,
total_payable: 12.5, total_payable: 12.5,
@@ -145,9 +149,14 @@ describe('CostView', () => {
expect(screen.getByTestId('summary-import')).toBeInTheDocument() expect(screen.getByTestId('summary-import')).toBeInTheDocument()
}) })
expect(screen.getByTestId('summary-import')).toHaveTextContent('10.500') // Main figure is energy (kWh), taken from the *_kwh fields.
expect(screen.getByTestId('summary-export')).toHaveTextContent('2.300') expect(screen.getByTestId('summary-import')).toHaveTextContent('33.300')
expect(screen.getByTestId('summary-export')).toHaveTextContent('44.400')
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50') expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
// Sub-line carries the monetary equivalent, so money is still visible.
expect(screen.getByTestId('summary-import-sub')).toHaveTextContent('10.50 EUR')
expect(screen.getByTestId('summary-export-sub')).toHaveTextContent('2.30 EUR')
}) })
it('shows recompute confirmation modal on button click', async () => { it('shows recompute confirmation modal on button click', async () => {
+12 -3
View File
@@ -77,10 +77,12 @@ function getThisMonthRange(): { start: string; end: string } {
interface SummaryCardProps { interface SummaryCardProps {
label: string label: string
value: string value: string
/** Optional secondary line, e.g. the monetary equivalent of an energy figure. */
sub?: string
testId?: string testId?: string
} }
function SummaryCard({ label, value, testId }: SummaryCardProps) { function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
return ( return (
<Paper withBorder p="sm" data-testid={testId}> <Paper withBorder p="sm" data-testid={testId}>
<Stack gap={4}> <Stack gap={4}>
@@ -90,6 +92,11 @@ function SummaryCard({ label, value, testId }: SummaryCardProps) {
<Text fw={600} size="lg"> <Text fw={600} size="lg">
{value} {value}
</Text> </Text>
{sub !== undefined && (
<Text size="xs" c="dimmed" data-testid={testId ? `${testId}-sub` : undefined}>
{sub}
</Text>
)}
</Stack> </Stack>
</Paper> </Paper>
) )
@@ -207,12 +214,14 @@ export function CostView() {
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary"> <SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm" data-testid="cost-summary">
<SummaryCard <SummaryCard
label="Import (kWh)" label="Import (kWh)"
value={summaryQuery.data.metered_import.toFixed(3)} value={summaryQuery.data.metered_import_kwh.toFixed(3)}
sub={`${summaryQuery.data.metered_import.toFixed(2)} ${currency}`}
testId="summary-import" testId="summary-import"
/> />
<SummaryCard <SummaryCard
label="Export (kWh)" label="Export (kWh)"
value={summaryQuery.data.metered_export.toFixed(3)} value={summaryQuery.data.metered_export_kwh.toFixed(3)}
sub={`${summaryQuery.data.metered_export.toFixed(2)} ${currency}`}
testId="summary-export" testId="summary-export"
/> />
<SummaryCard <SummaryCard
@@ -83,6 +83,8 @@ const SUMMARY = {
metered_import: 10.5, metered_import: 10.5,
metered_export: 2.3, metered_export: 2.3,
metered_net: 8.2, metered_net: 8.2,
metered_import_kwh: 33.3,
metered_export_kwh: 44.4,
fixed_costs: 5.0, fixed_costs: 5.0,
credits: 50.0, credits: 50.0,
total_payable: 12.5, total_payable: 12.5,
+16 -4
View File
@@ -4727,17 +4727,27 @@
"metered_import": { "metered_import": {
"type": "number", "type": "number",
"title": "Metered Import", "title": "Metered Import",
"description": "Σ import_cost for non-degraded periods." "description": "Σ import_cost for non-degraded periods (money, in `currency`)."
}, },
"metered_export": { "metered_export": {
"type": "number", "type": "number",
"title": "Metered Export", "title": "Metered Export",
"description": "Σ export_revenue for non-degraded periods." "description": "Σ export_revenue for non-degraded periods (money, in `currency`)."
}, },
"metered_net": { "metered_net": {
"type": "number", "type": "number",
"title": "Metered Net", "title": "Metered Net",
"description": "Σ net_cost for non-degraded periods." "description": "Σ net_cost for non-degraded periods (money, in `currency`)."
},
"metered_import_kwh": {
"type": "number",
"title": "Metered Import Kwh",
"description": "Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported, kWh)."
},
"metered_export_kwh": {
"type": "number",
"title": "Metered Export Kwh",
"description": "Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported, kWh)."
}, },
"fixed_costs": { "fixed_costs": {
"type": "number", "type": "number",
@@ -4776,6 +4786,8 @@
"metered_import", "metered_import",
"metered_export", "metered_export",
"metered_net", "metered_net",
"metered_import_kwh",
"metered_export_kwh",
"fixed_costs", "fixed_costs",
"credits", "credits",
"total_payable", "total_payable",
@@ -4784,7 +4796,7 @@
"days" "days"
], ],
"title": "SummaryResponse", "title": "SummaryResponse",
"description": "Response for GET /api/energy/costs/summary.\n\nAll monetary values are in ``currency``.\n\n``total_payable = metered_net + fixed_costs credits``" "description": "Response for GET /api/energy/costs/summary.\n\nMonetary values are in ``currency``; the ``*_kwh`` fields are energy totals\nin kWh. ``metered_import``/``metered_export`` are **money**, not energy —\nonly the ``_kwh``-suffixed fields carry kWh.\n\n``total_payable = metered_net + fixed_costs credits``"
}, },
"TibberTestPriceSchema": { "TibberTestPriceSchema": {
"properties": { "properties": {
+20 -4
View File
@@ -3659,15 +3659,25 @@ components:
metered_import: metered_import:
type: number type: number
title: Metered Import title: Metered Import
description: Σ import_cost for non-degraded periods. description: Σ import_cost for non-degraded periods (money, in `currency`).
metered_export: metered_export:
type: number type: number
title: Metered Export title: Metered Export
description: Σ export_revenue for non-degraded periods. description: Σ export_revenue for non-degraded periods (money, in `currency`).
metered_net: metered_net:
type: number type: number
title: Metered Net title: Metered Net
description: Σ net_cost for non-degraded periods. description: Σ net_cost for non-degraded periods (money, in `currency`).
metered_import_kwh:
type: number
title: Metered Import Kwh
description: Σ (d1_kwh + d2_kwh) for non-degraded periods (energy imported,
kWh).
metered_export_kwh:
type: number
title: Metered Export Kwh
description: Σ (r1_kwh + r2_kwh) for non-degraded periods (energy exported,
kWh).
fixed_costs: fixed_costs:
type: number type: number
title: Fixed Costs title: Fixed Costs
@@ -3699,6 +3709,8 @@ components:
- metered_import - metered_import
- metered_export - metered_export
- metered_net - metered_net
- metered_import_kwh
- metered_export_kwh
- fixed_costs - fixed_costs
- credits - credits
- total_payable - total_payable
@@ -3709,7 +3721,11 @@ components:
description: 'Response for GET /api/energy/costs/summary. description: 'Response for GET /api/energy/costs/summary.
All monetary values are in ``currency``. Monetary values are in ``currency``; the ``*_kwh`` fields are energy totals
in kWh. ``metered_import``/``metered_export`` are **money**, not energy —
only the ``_kwh``-suffixed fields carry kWh.
``total_payable = metered_net + fixed_costs credits``' ``total_payable = metered_net + fixed_costs credits``'
+33
View File
@@ -990,6 +990,38 @@ class TestSummarize:
# Σnet ≈ 2 × 0.4051 = 0.8102 # Σnet ≈ 2 × 0.4051 = 0.8102
assert abs(result["metered_net"] - 0.8102) < 1e-6 assert abs(result["metered_net"] - 0.8102) < 1e-6
def test_metered_kwh_sums(self, energy_db: Session) -> None:
"""The *_kwh totals sum both tariff registers and are distinct from the money totals."""
self._setup_two_periods(energy_db)
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
# Per period: d1=0.5, d2=1.2 → import 1.7 kWh; r1=0.0, r2=0.1 → export 0.1 kWh.
assert abs(result["metered_import_kwh"] - 3.4) < 1e-6, (
f"expected Σ(d1+d2) = 2 × 1.7 = 3.4 kWh, got {result['metered_import_kwh']}"
)
assert abs(result["metered_export_kwh"] - 0.2) < 1e-6, (
f"expected Σ(r1+r2) = 2 × 0.1 = 0.2 kWh, got {result['metered_export_kwh']}"
)
# Regression guard for the mislabelled-unit bug: energy and money totals
# must never be conflated (import 3.4 kWh vs 0.8202 EUR of import cost).
assert result["metered_import_kwh"] != result["metered_import"]
assert result["metered_export_kwh"] != result["metered_export"]
def test_metered_kwh_excludes_degraded_periods(self, energy_db: Session) -> None:
"""Degraded periods contribute no kWh, mirroring the money totals."""
self._setup_two_periods(energy_db)
# Degrade the first period; its kWh must drop out of the totals.
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
row.degraded = True
energy_db.commit()
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert result["degraded_count"] == 1
assert abs(result["metered_import_kwh"] - 1.7) < 1e-6
assert abs(result["metered_export_kwh"] - 0.1) < 1e-6
def test_period_count(self, energy_db: Session) -> None: def test_period_count(self, energy_db: Session) -> None:
self._setup_two_periods(energy_db) self._setup_two_periods(energy_db)
result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
@@ -2708,6 +2740,7 @@ class TestSummarizeSettlementOffset:
expected_keys = { expected_keys = {
"currency", "metered_import", "metered_export", "metered_net", "currency", "metered_import", "metered_export", "metered_net",
"metered_import_kwh", "metered_export_kwh",
"fixed_costs", "credits", "total_payable", "period_count", "fixed_costs", "credits", "total_payable", "period_count",
"degraded_count", "days", "degraded_count", "days",
} }