diff --git a/app/integrations/expose.py b/app/integrations/expose.py index a00ffce..698bb9a 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -549,53 +549,57 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: def _make_import_cost_getter() -> Callable[["Session"], Any]: """Return a getter for the cumulative import cost plus prorated standing charges. - Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``, - where ``anchor_utc`` is the **max** of: - - the earliest version's effective_from (contract billing start), and - - the earliest non-degraded EnergyCostPeriod.period_start (recording start). + D2 (M7): anchor = current active electricity meter's ``started_at``. + After a meter swap the cumulative resets to zero for the new meter — + old-meter periods have ``period_start < new_meter.started_at`` and fall + outside the [anchor, now) window, so they are naturally excluded. + Cross-meter boundary periods are already degraded and also excluded. - This prevents counting fixed costs for the period before any data was recorded - (e.g. contract starts Jan 1 but data recording only starts Jun 17 — we do not - want to include ~€270 of standing charges for a period with no meter data). + No active electricity meter → returns None (cannot anchor; safer than + returning a stale or wrong value). The check is done via an inline + query (``ended_at IS NULL``) rather than ``meter_at(now)`` to be robust + against the edge case where the active meter's ``started_at`` is in the + future (``meter_at(now)`` would return None in that scenario). + + No non-degraded periods at all → returns None (has_data guard). + + No active contract → ``summarize`` still runs but fixed_costs = 0; + the return value is the pure metered sum within the current meter window. + This is consistent with D2: the anchor is the meter, not the contract. value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs - - Returns None when no non-degraded period exists. - No arithmetic is done here — all fixed-cost/credit accounting lives in summarize(). """ def _getter(sess: "Session") -> Any: from datetime import UTC, datetime as _dt - from app.services.contracts import active_contract_versions - from app.services.contracts import _as_utc as _cu + from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter from app.services.energy_cost import summarize as _summarize - from app.models.energy import EnergyCostPeriod as _ECP - from sqlalchemy import func as _func + from sqlalchemy import func as _func, select as _select - # Quick check: any non-degraded period at all? (avoids full summarize overhead - # when there are zero periods, which should return None) + # Quick check: any non-degraded period at all? has_data = sess.query(_func.sum(_ECP.import_cost)).filter( _ECP.degraded.is_(False) ).scalar() if has_data is None: return None - versions = active_contract_versions(sess) - if not versions: - # No active contract — return pure SUM(import_cost) without standing charges. - return float(has_data) + # D2: anchor = active electricity meter's started_at. + # Inline query (ended_at IS NULL) is more robust than meter_at(now) + # because it avoids the edge case where started_at is in the future. + active_meter = sess.execute( + _select(_Meter) + .where( + _Meter.commodity == "electricity", + _Meter.ended_at.is_(None), + ) + .limit(1) + ).scalar_one_or_none() - # anchor = max(contract billing start, earliest recording start). - # This prevents accumulating fixed costs for days with no meter data. - contract_anchor_utc = _cu(versions[0].effective_from) - earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter( - _ECP.degraded.is_(False) - ).scalar() - if earliest_period_start is not None: - recording_anchor_utc = _cu(earliest_period_start) - anchor_utc = max(contract_anchor_utc, recording_anchor_utc) - else: - anchor_utc = contract_anchor_utc + if active_meter is None: + # No active electricity meter — cannot anchor; return None. + return None + from app.services.contracts import _as_utc as _cu + anchor_utc = _cu(active_meter.started_at) now_utc = _dt.now(UTC) result = _summarize(sess, anchor_utc, now_utc) @@ -608,20 +612,18 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: def _make_export_revenue_getter() -> Callable[["Session"], Any]: """Return a getter for the cumulative export revenue plus prorated tax credit. - Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``. - Anchor is the same max(contract_start, recording_start) logic as import getter. + D2 (M7): anchor = current active electricity meter's ``started_at``. + Same reasoning as the import cost getter — see its docstring. value = summarize(anchor → now).metered_export + summarize(anchor → now).credits - Returns None when no non-degraded period exists in [anchor, now]. + Returns None when no non-degraded period exists or no active electricity meter. """ def _getter(sess: "Session") -> Any: from datetime import UTC, datetime as _dt - from app.services.contracts import active_contract_versions - from app.services.contracts import _as_utc as _cu + from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter from app.services.energy_cost import summarize as _summarize - from app.models.energy import EnergyCostPeriod as _ECP - from sqlalchemy import func as _func + from sqlalchemy import func as _func, select as _select # Quick check: any non-degraded period at all? has_data = sess.query(_func.sum(_ECP.export_revenue)).filter( @@ -630,22 +632,22 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: if has_data is None: return None - versions = active_contract_versions(sess) - if not versions: - # No active contract — return pure SUM(export_revenue) without credits. - return float(has_data) + # D2: anchor = active electricity meter's started_at. + active_meter = sess.execute( + _select(_Meter) + .where( + _Meter.commodity == "electricity", + _Meter.ended_at.is_(None), + ) + .limit(1) + ).scalar_one_or_none() - # anchor = max(contract billing start, earliest recording start). - contract_anchor_utc = _cu(versions[0].effective_from) - earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter( - _ECP.degraded.is_(False) - ).scalar() - if earliest_period_start is not None: - recording_anchor_utc = _cu(earliest_period_start) - anchor_utc = max(contract_anchor_utc, recording_anchor_utc) - else: - anchor_utc = contract_anchor_utc + if active_meter is None: + # No active electricity meter — cannot anchor; return None. + return None + from app.services.contracts import _as_utc as _cu + anchor_utc = _cu(active_meter.started_at) now_utc = _dt.now(UTC) result = _summarize(sess, anchor_utc, now_utc) diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index 5604166..5856071 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -90,6 +90,37 @@ def _make_period( return p +def _make_active_meter( + session: Session, + *, + started_at: datetime, + label: str = "Test Meter", + commodity: str = "electricity", + reason: str = "initial", +) -> Any: + """Insert an active (ended_at=None) Meter row and flush. + + Helper for M7-T04 tests: every cumulative-getter test that expects a + non-None return value must declare an active electricity meter so the + D2 anchor (meter.started_at) can be resolved. + """ + from app.models.energy import Meter + + now = datetime.now(tz=timezone.utc) + m = Meter( + label=label, + commodity=commodity, + started_at=started_at, + ended_at=None, + reason=reason, + note=None, + created_at=now, + ) + session.add(m) + session.flush() + return m + + def _make_settings( *, mqtt_enabled: bool = True, @@ -398,14 +429,21 @@ def test_sell_price_getter_reads_manual_snapshot(energy_db) -> None: def test_import_cost_total_sums_non_degraded_rows(energy_db) -> None: - """import_cost_total must return SUM of non-degraded import_cost values only.""" + """import_cost_total must return SUM of non-degraded import_cost values only. + + M7-T04 (D2): active electricity meter required so the getter can anchor on + meter.started_at. The meter is started before t0 so both non-degraded rows + fall within [anchor, now). + """ from app.integrations.expose import build_catalog t0 = datetime(2025, 3, 1, 6, 0, tzinfo=timezone.utc) t1 = datetime(2025, 3, 1, 6, 15, tzinfo=timezone.utc) t2 = datetime(2025, 3, 1, 6, 30, tzinfo=timezone.utc) + meter_start = datetime(2025, 3, 1, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) # Two good rows: 0.10 + 0.20 = 0.30 _make_period(session, period_start=t0, import_cost=0.10, degraded=False) _make_period(session, period_start=t1, import_cost=0.20, degraded=False) @@ -420,20 +458,26 @@ def test_import_cost_total_sums_non_degraded_rows(energy_db) -> None: ) value = import_entry.entity.value_getter(session) + # No active contract → fixed_costs = 0; only metered sum = 0.30. assert value == pytest.approx(0.30), ( f"Expected cumulative import_cost 0.30 (non-degraded only), got {value!r}" ) def test_export_revenue_total_sums_non_degraded_rows(energy_db) -> None: - """export_revenue_total must return SUM of non-degraded export_revenue values only.""" + """export_revenue_total must return SUM of non-degraded export_revenue values only. + + M7-T04 (D2): active electricity meter required so the getter can anchor. + """ from app.integrations.expose import build_catalog t0 = datetime(2025, 3, 2, 6, 0, tzinfo=timezone.utc) t1 = datetime(2025, 3, 2, 6, 15, tzinfo=timezone.utc) t2 = datetime(2025, 3, 2, 6, 30, tzinfo=timezone.utc) + meter_start = datetime(2025, 3, 2, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) # Two good rows: 0.05 + 0.07 = 0.12 _make_period(session, period_start=t0, export_revenue=0.05, degraded=False) _make_period(session, period_start=t1, export_revenue=0.07, degraded=False) @@ -448,6 +492,7 @@ def test_export_revenue_total_sums_non_degraded_rows(energy_db) -> None: ) value = export_entry.entity.value_getter(session) + # No active contract → credits = 0; only metered sum = 0.12. assert value == pytest.approx(0.12), ( f"Expected cumulative export_revenue 0.12 (non-degraded only), got {value!r}" ) @@ -459,13 +504,17 @@ def test_cumulative_getter_excludes_degraded_import_cost_row(energy_db) -> None: This verifies the exclusion filter on degraded=True rows. (In practice compute_period writes 0.0 for degraded rows; but if a row was previously successful and then set degraded, its import_cost could be non-zero.) + + M7-T04 (D2): active electricity meter required. """ from app.integrations.expose import build_catalog t0 = datetime(2025, 4, 1, 10, 0, tzinfo=timezone.utc) t1 = datetime(2025, 4, 1, 10, 15, tzinfo=timezone.utc) + meter_start = datetime(2025, 4, 1, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) _make_period(session, period_start=t0, import_cost=0.50, degraded=False) # A row that is degraded but somehow has a non-zero import_cost (edge case): _make_period(session, period_start=t1, import_cost=0.99, degraded=True) @@ -478,7 +527,7 @@ def test_cumulative_getter_excludes_degraded_import_cost_row(energy_db) -> None: ) value = import_entry.entity.value_getter(session) - # Only the non-degraded row should contribute: 0.50 + # Only the non-degraded row should contribute: 0.50 (no contract → fixed_costs=0). assert value == pytest.approx(0.50), ( f"Degraded row must not be included in SUM; expected 0.50, got {value!r}" ) @@ -880,18 +929,17 @@ _STANDING_VALUES = { def test_import_cost_total_includes_standing_charges(energy_db) -> None: """import_cost_total getter must add prorated standing charges from active contract. - Principle B/D + FU11 anchor guardrail: the getter delegates to - summarize(anchor → now), where anchor = max(contract_start, recording_start). + M7-T04 (D2): anchor = active electricity meter's started_at. + The getter delegates to summarize(anchor → now), where anchor = meter.started_at. With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day. The getter uses summarize, which counts whole *local* elapsed days under Principle C. We pin the timezone to UTC for simplicity: local days = UTC days. Setup: - - effective_from = 10 full UTC days ago (exact UTC midnight). - - First period placed AT effective_from (same UTC midnight), so recording_start = - contract_start → anchor = max(contract_start, recording_start) = contract_start. - - Under UTC pinned, Principle C counts days [effective_from.date(), today] = 11 days. + - meter.started_at = effective_from = 10 full UTC days ago (exact UTC midnight). + - First period placed AT meter.started_at → anchor = meter.started_at. + - Under UTC pinned, Principle C counts days [meter.started_at.date(), today] = 11 days. """ from decimal import Decimal from datetime import timedelta @@ -901,20 +949,20 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None: from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) - # anchor = exactly 10 UTC days ago at midnight - effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10) + # D2 anchor = meter.started_at = 10 UTC days ago at midnight + meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10) + effective_from = meter_started_at # contract also starts at the same time - # Under UTC timezone (pinned), Principle C counts whole days from effective_from.date() + # Under UTC timezone (pinned), Principle C counts whole days from meter.started_at.date() # to today (inclusive) = 11 days total (10 + today). - expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11 + expected_days = (now_utc.date() - meter_started_at.date()).days + 1 # = 11 import_cost_sum = 5.00 # EUR - # Place period rows AT effective_from (= recording_start = contract_start → same anchor). - # This ensures anchor = max(effective_from, effective_from) = effective_from exactly. - t0 = effective_from # first period starts exactly at contract effective_from + t0 = meter_started_at # first period at anchor t1 = t0 + timedelta(minutes=15) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_started_at) _make_contract_with_version( session, values=_STANDING_VALUES, @@ -953,12 +1001,11 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None: def test_export_revenue_total_includes_tax_credit(energy_db) -> None: """export_revenue_total getter must add prorated heffingskorting from active contract. - Principle B/D + FU11 anchor guardrail: delegates to summarize(anchor → now), - where anchor = max(contract_start, recording_start). + M7-T04 (D2): anchor = active electricity meter's started_at. With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day. Timezone pinned to UTC for deterministic local-day counting. - Setup: period placed AT effective_from → recording_start = contract_start → same anchor. + Setup: meter.started_at = effective_from = 4 days ago → 5 days total (incl. today). """ from decimal import Decimal from datetime import timedelta @@ -968,17 +1015,18 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None: from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) - effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4) + meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4) + effective_from = meter_started_at - # Under UTC pinned: expected_days = days from effective_from.date() to today inclusive. - expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5 + # Under UTC pinned: expected_days = days from meter.started_at.date() to today inclusive. + expected_days = (now_utc.date() - meter_started_at.date()).days + 1 # = 5 - # Place period rows AT effective_from → recording_start = contract_start = same anchor. - t0 = effective_from + t0 = meter_started_at t1 = t0 + timedelta(minutes=15) export_sum = 2.50 # EUR with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_started_at) _make_contract_with_version( session, values=_STANDING_VALUES, @@ -1014,19 +1062,18 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None: def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> None: - """When contract version effective_from is in the future, standing cumulative must be 0. + """When contract version effective_from is in the future, standing charges must be 0. - Principle D: anchor = effective_from (future). - summarize(anchor → now) has a negative/empty window → 0 days, 0 fixed_costs. - The global `has_data` check passes (there are non-degraded rows from the past), - but no periods fall within [anchor, now] window → metered = 0, fixed = 0. - The getter returns 0 + 0 = 0. + M7-T04 (D2): anchor = active electricity meter's started_at (in the past). + The summarize window [meter.started_at, now) DOES include the period data, + but Principle C does not count future local days for fixed costs. Since + the contract's effective_from is in the future, no days in [meter.started_at, + today] fall under a valid contract version, so fixed_costs = 0. - Note: under the new design, if a contract has a future effective_from, the - getter returns the pure metered sum from the summarize window (which is 0 - since no periods exist after the future anchor). The old test expected to - return the raw global SUM, but with Principle D the anchor is the future - date → window is empty → metered = 0. + With the D2 anchor in the past, the metered sum IS returned (unlike the old + FU11 design where the anchor was the future effective_from → empty window → 0). + + Expected: value = import_cost_sum + 0 (no standing days) """ from datetime import timedelta from unittest.mock import patch @@ -1035,14 +1082,15 @@ def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> N from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) + # Meter started 2 days ago; period is 1 day ago (within meter window). + meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=2) future_effective = now_utc + timedelta(days=30) - # Place a period BEFORE the future effective_from (global sum = 4.00) - # but it won't be in [anchor, now] window. t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1) import_cost_sum = 4.00 with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_started_at) _make_contract_with_version( session, values=_STANDING_VALUES, @@ -1060,19 +1108,21 @@ def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> N ) value = import_entry.entity.value_getter(session) - # anchor is in future → summarize([future, now]) is empty → metered=0, fixed_costs=0 - # Principle C: no future local days counted → 0 fixed costs. - # Result = 0 (no periods in window) + 0 (0 standing days) = 0. - assert value == pytest.approx(0.0, abs=1e-9), ( - f"When effective_from is in future, summarize window is empty → value must be 0.0; " - f"got {value!r}" + # D2: anchor = meter.started_at (past) → metered_import = import_cost_sum. + # Principle C: contract effective_from is in the future → 0 standing days → fixed_costs = 0. + # Result = import_cost_sum + 0. + assert value == pytest.approx(import_cost_sum, rel=1e-9), ( + f"When effective_from is in future, standing = 0, value = metered sum; " + f"expected {import_cost_sum}, got {value!r}" ) def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None: - """When contract version effective_from is in the future, credit cumulative must be 0. + """When contract version effective_from is in the future, tax credit must be 0. - Same logic as import: anchor is future → summarize window empty → 0 credits. + M7-T04 (D2): anchor = active electricity meter's started_at (in the past). + Summarize window includes the period, but Principle C does not count future + days, so credits = 0. Expected: value = export_sum + 0 credits. """ from datetime import timedelta from unittest.mock import patch @@ -1081,12 +1131,14 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) + meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=2) future_effective = now_utc + timedelta(days=60) t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1) export_sum = 3.00 with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_started_at) _make_contract_with_version( session, values=_STANDING_VALUES, @@ -1104,9 +1156,10 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> ) value = export_entry.entity.value_getter(session) - # anchor is in future → empty window → 0 credits - assert value == pytest.approx(0.0, abs=1e-9), ( - f"When effective_from is in future, credit must be 0; got {value!r}" + # D2: metered_export = export_sum; Principle C: future effective_from → 0 credits. + assert value == pytest.approx(export_sum, rel=1e-9), ( + f"When effective_from is in future, credit = 0, value = metered sum; " + f"expected {export_sum}, got {value!r}" ) @@ -1115,14 +1168,22 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> # --------------------------------------------------------------------------- -def test_import_cost_total_falls_back_to_sum_when_no_active_contract(energy_db) -> None: - """When no active contract exists, import_cost_total must return the raw SUM only.""" +def test_import_cost_total_returns_metered_sum_when_no_active_contract(energy_db) -> None: + """When active meter exists but no active contract, import_cost_total returns metered sum. + + M7-T04 (D2): anchor = meter.started_at; summarize runs without a contract + → fixed_costs = 0; the return value is the pure metered sum within the meter window. + This is a behaviour change from FU11 (which returned a global SUM) — D2 only + counts periods since meter.started_at. + """ from app.integrations.expose import build_catalog t0 = datetime(2026, 1, 1, 6, 0, tzinfo=timezone.utc) t1 = datetime(2026, 1, 1, 6, 15, tzinfo=timezone.utc) + meter_start = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) # Inactive contract — active=False _make_contract_with_version( session, @@ -1141,20 +1202,27 @@ def test_import_cost_total_falls_back_to_sum_when_no_active_contract(energy_db) ) value = import_entry.entity.value_getter(session) - # No active contract → standing cumulative = 0 → value = pure SUM = 4.00 + # D2: anchor = meter.started_at; both periods fall within [anchor, now]. + # No active contract → fixed_costs = 0 → value = pure metered SUM = 4.00. assert value == pytest.approx(4.00, rel=1e-9), ( - f"Expected pure SUM=4.00 with no active contract, got {value!r}" + f"Expected metered SUM=4.00 (no contract → no standing), got {value!r}" ) -def test_export_revenue_total_falls_back_to_sum_when_no_active_contract(energy_db) -> None: - """When no active contract exists, export_revenue_total must return the raw SUM only.""" +def test_export_revenue_total_returns_metered_sum_when_no_active_contract(energy_db) -> None: + """When active meter exists but no active contract, export_revenue_total returns metered sum. + + M7-T04 (D2): anchor = meter.started_at; summarize with no contract → credits = 0; + return value = pure metered export sum within the meter window. + """ from app.integrations.expose import build_catalog t0 = datetime(2026, 2, 1, 6, 0, tzinfo=timezone.utc) t1 = datetime(2026, 2, 1, 6, 15, tzinfo=timezone.utc) + meter_start = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc) with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_start) # Inactive contract — active=False _make_contract_with_version( session, @@ -1173,9 +1241,9 @@ def test_export_revenue_total_falls_back_to_sum_when_no_active_contract(energy_d ) value = export_entry.entity.value_getter(session) - # No active contract → credit cumulative = 0 → value = pure SUM = 2.00 + # D2: anchor = meter.started_at; no contract → credits = 0 → value = metered sum = 2.00. assert value == pytest.approx(2.00, rel=1e-9), ( - f"Expected pure SUM=2.00 with no active contract, got {value!r}" + f"Expected metered SUM=2.00 (no contract → no credits), got {value!r}" ) @@ -1629,14 +1697,15 @@ def test_daily_getter_returns_none_when_no_non_degraded_periods(energy_db) -> No # --------------------------------------------------------------------------- -def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_db) -> None: - """FU11: anchor = max(contract_start, recording_start) — no pre-recording fixed costs. +def test_cumulative_anchor_is_meter_started_at(energy_db) -> None: + """M7-T04 (D2): anchor = active electricity meter's started_at, not recording_start. - Scenario: contract effective_from = 180 days ago (lots of standing charges if used - as anchor), but first non-degraded period_start = 7 days ago. Expected: anchor is - 7 days ago → only 8 days of standing charges (7 + today under Principle C). + Scenario: contract effective_from = 180 days ago; meter.started_at = 7 days ago; + first non-degraded period_start = 7 days ago. + Expected: anchor = meter.started_at (7 days ago) → 8 days of standing charges. - Without the fix, anchor would be 180 days ago → ~180 days × 3 EUR/day = €540+. + This replaces the old FU11 test (anchor=recording_start). Under D2 the anchor + is always the active meter's started_at, irrespective of when data recording began. """ from decimal import Decimal from datetime import timedelta @@ -1648,14 +1717,15 @@ def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_ now_utc = datetime.now(timezone.utc) midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - # Contract starts 180 days ago (far before any data) + # Contract starts 180 days ago (far before the meter) contract_start = midnight_today - timedelta(days=180) - # First (and only) data period starts 7 days ago - recording_start = midnight_today - timedelta(days=7) + # Active meter started 7 days ago — this is the D2 anchor + meter_started_at = midnight_today - timedelta(days=7) - t0 = recording_start # first period at recording start + t0 = meter_started_at # first period at meter start with Session(energy_db) as session: + _make_active_meter(session, started_at=meter_started_at) _make_contract_with_version( session, values=_STANDING_VALUES, @@ -1673,25 +1743,244 @@ def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_ ) value = import_entry.entity.value_getter(session) - # Anchor = recording_start (7 days ago UTC midnight). - # Principle C (UTC pinned): count local days from recording_start.date() to today inclusive. - # That's 7+1 = 8 days. - expected_days = (now_utc.date() - recording_start.date()).days + 1 # = 8 + # Anchor = meter.started_at (7 days ago). + # Principle C (UTC pinned): count local days from meter_started_at.date() to today inclusive. + # That's 7 + 1 = 8 days. + expected_days = (now_utc.date() - meter_started_at.date()).days + 1 # = 8 daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day expected = float(Decimal("5.00") + daily_standing * expected_days) - # Without the fix, value would include ~180 days × 3 EUR/day = ~€540+ of extra standing. - # With the fix, value ≈ 5.00 + 3.0 × 8 = 29.00. assert value == pytest.approx(expected, rel=1e-6), ( - f"Expected anchor=recording_start anchor, " + f"Expected anchor=meter.started_at anchor, " f"import_cost_total={expected} (metered=5.00 + standing={float(daily_standing*expected_days)} " f"over {expected_days} days), got {value!r}" ) - # Also verify that using the old anchor (contract start) would give a very different result. + # Verify that using the old FU11 anchor (contract start, 180 days ago) would give a + # very different result (>€500 more standing), confirming we're NOT using it. old_expected_days = (now_utc.date() - contract_start.date()).days + 1 # ~181 days old_expected = float(Decimal("5.00") + daily_standing * old_expected_days) assert abs(value - old_expected) > 100, ( - f"Old (wrong) anchor would yield ~{old_expected:.2f}, " - f"the new value {value:.2f} should differ by >€100" + f"D2 anchor (meter.started_at) expected ~{expected:.2f}, " + f"old FU11 anchor (contract_start) would yield ~{old_expected:.2f}; " + f"difference must be >€100" + ) + + +# --------------------------------------------------------------------------- +# M7-T04 new tests: per-meter cumulative reset (D2) +# --------------------------------------------------------------------------- + + +def test_cumulative_returns_none_when_no_active_meter(energy_db) -> None: + """M7-T04 None protection: no active electricity meter → import/export total = None. + + This is the key D2 None protection: without an active meter the anchor + cannot be resolved, so the getter returns None rather than a stale/wrong value. + Periods exist (non-degraded), but no Meter row with ended_at IS NULL is present. + """ + from app.integrations.expose import build_catalog + + t0 = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc) + t1 = datetime(2026, 3, 1, 10, 15, tzinfo=timezone.utc) + + with Session(energy_db) as session: + # No Meter row at all — cumulative getters must return None. + _make_period(session, period_start=t0, import_cost=1.00, degraded=False) + _make_period(session, period_start=t1, export_revenue=0.50, degraded=False) + session.commit() + + with Session(energy_db) as session: + catalog = build_catalog(session) + import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_total") + export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_total") + import_val = import_entry.entity.value_getter(session) + export_val = export_entry.entity.value_getter(session) + + assert import_val is None, ( + f"import_cost_total must be None with no active meter, got {import_val!r}" + ) + assert export_val is None, ( + f"export_revenue_total must be None with no active meter, got {export_val!r}" + ) + + +def test_cumulative_resets_after_meter_swap(energy_db) -> None: + """M7-T04 (D2): after a meter swap the cumulative only counts the new meter's periods. + + Scenario: + - Old meter: started_at = 30 days ago. Two non-degraded periods (€3.00 total). + - Swap at: 7 days ago. Old meter closed, new active meter opened. + - New meter: started_at = 7 days ago. One non-degraded period (€1.00). + - Expected: import_cost_total = €1.00 + any standing charges from [7 days ago, now]. + + Old-meter periods (period_start < new_meter.started_at) fall outside the + [anchor, now) window and are excluded, giving a clean reset to zero for the + new meter epoch. + """ + from decimal import Decimal + from datetime import timedelta + from unittest.mock import patch + from zoneinfo import ZoneInfo + from app.models.energy import Meter as _Meter + from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod + + now_utc = datetime.now(timezone.utc) + midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + + old_meter_start = midnight_today - timedelta(days=30) + swap_at = midnight_today - timedelta(days=7) # new meter anchor + + # Old-meter periods (before the swap) + old_t0 = old_meter_start + old_t1 = old_meter_start + timedelta(minutes=15) + # New-meter period (after the swap) + new_t0 = swap_at # first period of the new meter epoch + + new_meter_import = 1.00 # EUR — only this should be counted (old-meter 3.00 must be excluded) + + with Session(energy_db) as session: + # Old meter: closed at swap_at + old_m = _Meter( + label="Old Meter", + commodity="electricity", + started_at=old_meter_start, + ended_at=swap_at, # closed + reason="initial", + note=None, + created_at=datetime.now(timezone.utc), + ) + session.add(old_m) + session.flush() + + # New meter: active (ended_at IS NULL) — D2 anchor + new_m = _Meter( + label="New Meter", + commodity="electricity", + started_at=swap_at, + ended_at=None, # active + reason="meter_swap", + note=None, + created_at=datetime.now(timezone.utc), + ) + session.add(new_m) + session.flush() + + # Old-meter periods (before swap): should NOT appear in post-swap cumulative + _make_period(session, period_start=old_t0, import_cost=2.00, degraded=False) + _make_period(session, period_start=old_t1, import_cost=1.00, degraded=False) + # New-meter period (at swap point / after swap) + _make_period(session, period_start=new_t0, import_cost=new_meter_import, degraded=False) + + # Active contract starting well before the old meter + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=old_meter_start, + active=True, + ) + session.commit() + + with Session(energy_db) as session: + with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + catalog = build_catalog(session) + import_entry = next( + e for e in catalog if e.entity.key == "energy.import_cost_total" + ) + value = import_entry.entity.value_getter(session) + + # Anchor = new meter's started_at (7 days ago). + # Old-meter periods (period_start < swap_at) fall outside [anchor, now) → excluded. + # Only new_t0 (= swap_at = anchor) is within the window → metered = 1.00. + # Standing charges: anchor = 7 days ago → 8 days (Principle C, UTC pinned). + expected_days = (now_utc.date() - swap_at.date()).days + 1 # = 8 + daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day + expected = float(Decimal(str(new_meter_import)) + daily_standing * expected_days) + + assert value == pytest.approx(expected, rel=1e-6), ( + f"Post-swap cumulative must only include new meter periods. " + f"Expected {expected} (metered={new_meter_import} + " + f"standing={float(daily_standing * expected_days)} over {expected_days} days), " + f"got {value!r}" + ) + + # Paranoia: if old-meter periods were accidentally included, value would be + # much larger (old_meter_import = 3.00 would inflate it). + assert value < new_meter_import + float(daily_standing * (expected_days + 1)) + 0.5, ( + f"Value suspiciously large — old-meter periods may be included: {value!r}" + ) + + +def test_daily_getters_unaffected_by_d2_meter_anchor(energy_db) -> None: + """M7-T04: daily import/export getters must still use the local-day window. + + D2 only changes the cumulative (*_total) anchor. The *_today getters + use today's local-day window [local midnight, local tomorrow midnight) in UTC. + They must continue to work correctly regardless of the meter anchor change, + and do NOT require an active meter (they use today's window directly). + + Setup: period 1h ago (today's local window, TZ=UTC), active contract. + Expected: value = import_today + fixed_for_today, independent of any meter. + """ + from decimal import Decimal + from datetime import timedelta + from unittest.mock import patch + from zoneinfo import ZoneInfo + from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod + + now_utc = datetime.now(timezone.utc) + # Period 1h ago — in today's UTC window + t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + if t0.date() < now_utc.date(): + t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + + effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) + import_cost_today = 0.88 + export_today = 0.44 + + with Session(energy_db) as session: + # No active meter added intentionally — daily getters must not need it. + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=effective_from, + active=True, + ) + _make_period( + session, + period_start=t0, + import_cost=import_cost_today, + export_revenue=export_today, + degraded=False, + ) + session.commit() + + with Session(energy_db) as session: + with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + catalog = build_catalog(session) + import_today_entry = next( + e for e in catalog if e.entity.key == "energy.import_cost_today" + ) + export_today_entry = next( + e for e in catalog if e.entity.key == "energy.export_revenue_today" + ) + import_val = import_today_entry.entity.value_getter(session) + export_val = export_today_entry.entity.value_getter(session) + + # Principle C: today counts as 1 day. + daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day + daily_credit = Decimal("365") / Decimal("365") # = 1.0 EUR/day + + expected_import = float(Decimal(str(import_cost_today)) + daily_standing * 1) + expected_export = float(Decimal(str(export_today)) + daily_credit * 1) + + assert import_val == pytest.approx(expected_import, rel=1e-6), ( + f"import_cost_today must use local-day window regardless of meter; " + f"expected {expected_import}, got {import_val!r}" + ) + assert export_val == pytest.approx(expected_export, rel=1e-6), ( + f"export_revenue_today must use local-day window regardless of meter; " + f"expected {expected_export}, got {export_val!r}" )