diff --git a/app/schemas/energy.py b/app/schemas/energy.py index 0c90215..eb2b6e4 100644 --- a/app/schemas/energy.py +++ b/app/schemas/energy.py @@ -121,15 +121,29 @@ class CostsResponse(BaseModel): class SummaryResponse(BaseModel): """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`` """ currency: str - metered_import: float = Field(description="Σ import_cost for non-degraded periods.") - metered_export: float = Field(description="Σ export_revenue for non-degraded periods.") - metered_net: float = Field(description="Σ net_cost for non-degraded periods.") + metered_import: float = Field( + description="Σ import_cost for non-degraded periods (money, in `currency`)." + ) + 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( description="Standing charges (network_fee + management_fee) apportioned over the interval." ) diff --git a/app/services/energy_cost.py b/app/services/energy_cost.py index 65fd51d..a3de7a4 100644 --- a/app/services/energy_cost.py +++ b/app/services/energy_cost.py @@ -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, 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 ------------ - **Decimal arithmetic throughout**: all monetary computations use @@ -731,16 +737,18 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any ------- dict with keys: - currency str ISO 4217 currency (from contract, or "EUR" fallback) - metered_import float Σ import_cost from non-degraded periods - metered_export float Σ export_revenue from non-degraded periods - metered_net float Σ net_cost from non-degraded periods - fixed_costs float standing charges for elapsed whole local days - credits float energy-tax credit for elapsed whole local days - total_payable float metered_net + fixed_costs − credits - period_count int number of non-degraded periods in range - degraded_count int number of degraded periods in range - days float interval length in days (total_seconds / 86400) + currency str ISO 4217 currency (from contract, or "EUR" fallback) + metered_import float Σ import_cost from non-degraded periods (money) + metered_export float Σ export_revenue from non-degraded periods (money) + 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 + credits float energy-tax credit for elapsed whole local days + total_payable float metered_net + fixed_costs − credits + period_count int number of non-degraded periods in range + degraded_count int number of degraded periods in range + days float interval length in days (total_seconds / 86400) """ from datetime import timedelta as _td, date as _date @@ -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_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) --- total_seconds = (end_utc - start_utc).total_seconds() 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_export": float(sum_export), "metered_net": float(sum_net), + "metered_import_kwh": float(sum_import_kwh), + "metered_export_kwh": float(sum_export_kwh), "fixed_costs": float(fixed_dec), "credits": float(credits_dec), "total_payable": float(total_payable), diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index fc2567a..f974cf1 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -2220,7 +2220,9 @@ export interface components { * SummaryResponse * @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`` */ @@ -2229,19 +2231,29 @@ export interface components { currency: string; /** * Metered Import - * @description Σ import_cost for non-degraded periods. + * @description Σ import_cost for non-degraded periods (money, in `currency`). */ metered_import: number; /** * Metered Export - * @description Σ export_revenue for non-degraded periods. + * @description Σ export_revenue for non-degraded periods (money, in `currency`). */ metered_export: number; /** * Metered Net - * @description Σ net_cost for non-degraded periods. + * @description Σ net_cost for non-degraded periods (money, in `currency`). */ 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 * @description Standing charges (network_fee + management_fee) apportioned over the interval. diff --git a/frontend/src/energy/CostView.test.tsx b/frontend/src/energy/CostView.test.tsx index 4a441b2..82bfc2f 100644 --- a/frontend/src/energy/CostView.test.tsx +++ b/frontend/src/energy/CostView.test.tsx @@ -61,9 +61,13 @@ const COST_PERIOD = { const SUMMARY = { 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_export: 2.3, metered_net: 8.2, + metered_import_kwh: 33.3, + metered_export_kwh: 44.4, fixed_costs: 5.0, credits: 50.0, total_payable: 12.5, @@ -145,9 +149,14 @@ describe('CostView', () => { expect(screen.getByTestId('summary-import')).toBeInTheDocument() }) - expect(screen.getByTestId('summary-import')).toHaveTextContent('10.500') - expect(screen.getByTestId('summary-export')).toHaveTextContent('2.300') + // Main figure is energy (kWh), taken from the *_kwh fields. + expect(screen.getByTestId('summary-import')).toHaveTextContent('33.300') + expect(screen.getByTestId('summary-export')).toHaveTextContent('44.400') 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 () => { diff --git a/frontend/src/energy/CostView.tsx b/frontend/src/energy/CostView.tsx index ba057dd..7328f82 100644 --- a/frontend/src/energy/CostView.tsx +++ b/frontend/src/energy/CostView.tsx @@ -77,10 +77,12 @@ function getThisMonthRange(): { start: string; end: string } { interface SummaryCardProps { label: string value: string + /** Optional secondary line, e.g. the monetary equivalent of an energy figure. */ + sub?: string testId?: string } -function SummaryCard({ label, value, testId }: SummaryCardProps) { +function SummaryCard({ label, value, sub, testId }: SummaryCardProps) { return ( @@ -90,6 +92,11 @@ function SummaryCard({ label, value, testId }: SummaryCardProps) { {value} + {sub !== undefined && ( + + {sub} + + )} ) @@ -207,12 +214,14 @@ export function CostView() { 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: self._setup_two_periods(energy_db) result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) @@ -2708,6 +2740,7 @@ class TestSummarizeSettlementOffset: expected_keys = { "currency", "metered_import", "metered_export", "metered_net", + "metered_import_kwh", "metered_export_kwh", "fixed_costs", "credits", "total_payable", "period_count", "degraded_count", "days", }