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",
}