M7-T04: anchor cumulative cost entities to active meter start (per-meter reset)

This commit is contained in:
2026-06-25 17:01:54 +02:00
parent 17057ed41e
commit 82bb329500
2 changed files with 419 additions and 128 deletions
+54 -52
View File
@@ -549,53 +549,57 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_import_cost_getter() -> Callable[["Session"], Any]: def _make_import_cost_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative import cost plus prorated standing charges. """Return a getter for the cumulative import cost plus prorated standing charges.
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``, D2 (M7): anchor = current active electricity meter's ``started_at``.
where ``anchor_utc`` is the **max** of: After a meter swap the cumulative resets to zero for the new meter —
- the earliest version's effective_from (contract billing start), and old-meter periods have ``period_start < new_meter.started_at`` and fall
- the earliest non-degraded EnergyCostPeriod.period_start (recording start). 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 No active electricity meter → returns None (cannot anchor; safer than
(e.g. contract starts Jan 1 but data recording only starts Jun 17 — we do not returning a stale or wrong value). The check is done via an inline
want to include ~€270 of standing charges for a period with no meter data). 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 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: def _getter(sess: "Session") -> Any:
from datetime import UTC, datetime as _dt from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
from app.services.contracts import _as_utc as _cu
from app.services.energy_cost import summarize as _summarize from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP from sqlalchemy import func as _func, select as _select
from sqlalchemy import func as _func
# Quick check: any non-degraded period at all? (avoids full summarize overhead # Quick check: any non-degraded period at all?
# when there are zero periods, which should return None)
has_data = sess.query(_func.sum(_ECP.import_cost)).filter( has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
_ECP.degraded.is_(False) _ECP.degraded.is_(False)
).scalar() ).scalar()
if has_data is None: if has_data is None:
return None return None
versions = active_contract_versions(sess) # D2: anchor = active electricity meter's started_at.
if not versions: # Inline query (ended_at IS NULL) is more robust than meter_at(now)
# No active contract — return pure SUM(import_cost) without standing charges. # because it avoids the edge case where started_at is in the future.
return float(has_data) 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). if active_meter is None:
# This prevents accumulating fixed costs for days with no meter data. # No active electricity meter — cannot anchor; return None.
contract_anchor_utc = _cu(versions[0].effective_from) return None
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
from app.services.contracts import _as_utc as _cu
anchor_utc = _cu(active_meter.started_at)
now_utc = _dt.now(UTC) now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, 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]: def _make_export_revenue_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative export revenue plus prorated tax credit. """Return a getter for the cumulative export revenue plus prorated tax credit.
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``. D2 (M7): anchor = current active electricity meter's ``started_at``.
Anchor is the same max(contract_start, recording_start) logic as import getter. Same reasoning as the import cost getter — see its docstring.
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits 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: def _getter(sess: "Session") -> Any:
from datetime import UTC, datetime as _dt from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
from app.services.contracts import _as_utc as _cu
from app.services.energy_cost import summarize as _summarize from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP from sqlalchemy import func as _func, select as _select
from sqlalchemy import func as _func
# Quick check: any non-degraded period at all? # Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter( 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: if has_data is None:
return None return None
versions = active_contract_versions(sess) # D2: anchor = active electricity meter's started_at.
if not versions: active_meter = sess.execute(
# No active contract — return pure SUM(export_revenue) without credits. _select(_Meter)
return float(has_data) .where(
_Meter.commodity == "electricity",
_Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
# anchor = max(contract billing start, earliest recording start). if active_meter is None:
contract_anchor_utc = _cu(versions[0].effective_from) # No active electricity meter — cannot anchor; return None.
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter( return None
_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
from app.services.contracts import _as_utc as _cu
anchor_utc = _cu(active_meter.started_at)
now_utc = _dt.now(UTC) now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, now_utc) result = _summarize(sess, anchor_utc, now_utc)
+365 -76
View File
@@ -90,6 +90,37 @@ def _make_period(
return p 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( def _make_settings(
*, *,
mqtt_enabled: bool = True, 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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2025, 3, 1, 6, 0, tzinfo=timezone.utc) t0 = datetime(2025, 3, 1, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2025, 3, 1, 6, 15, tzinfo=timezone.utc) t1 = datetime(2025, 3, 1, 6, 15, tzinfo=timezone.utc)
t2 = datetime(2025, 3, 1, 6, 30, 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
# Two good rows: 0.10 + 0.20 = 0.30 # 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=t0, import_cost=0.10, degraded=False)
_make_period(session, period_start=t1, import_cost=0.20, 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) value = import_entry.entity.value_getter(session)
# No active contract → fixed_costs = 0; only metered sum = 0.30.
assert value == pytest.approx(0.30), ( assert value == pytest.approx(0.30), (
f"Expected cumulative import_cost 0.30 (non-degraded only), got {value!r}" 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: 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 from app.integrations.expose import build_catalog
t0 = datetime(2025, 3, 2, 6, 0, tzinfo=timezone.utc) t0 = datetime(2025, 3, 2, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2025, 3, 2, 6, 15, tzinfo=timezone.utc) t1 = datetime(2025, 3, 2, 6, 15, tzinfo=timezone.utc)
t2 = datetime(2025, 3, 2, 6, 30, 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
# Two good rows: 0.05 + 0.07 = 0.12 # 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=t0, export_revenue=0.05, degraded=False)
_make_period(session, period_start=t1, export_revenue=0.07, 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) value = export_entry.entity.value_getter(session)
# No active contract → credits = 0; only metered sum = 0.12.
assert value == pytest.approx(0.12), ( assert value == pytest.approx(0.12), (
f"Expected cumulative export_revenue 0.12 (non-degraded only), got {value!r}" 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. This verifies the exclusion filter on degraded=True rows.
(In practice compute_period writes 0.0 for degraded rows; but if a row was (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.) 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 from app.integrations.expose import build_catalog
t0 = datetime(2025, 4, 1, 10, 0, tzinfo=timezone.utc) t0 = datetime(2025, 4, 1, 10, 0, tzinfo=timezone.utc)
t1 = datetime(2025, 4, 1, 10, 15, 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: 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) _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): # 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) _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) 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), ( assert value == pytest.approx(0.50), (
f"Degraded row must not be included in SUM; expected 0.50, got {value!r}" 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: def test_import_cost_total_includes_standing_charges(energy_db) -> None:
"""import_cost_total getter must add prorated standing charges from active contract. """import_cost_total getter must add prorated standing charges from active contract.
Principle B/D + FU11 anchor guardrail: the getter delegates to M7-T04 (D2): anchor = active electricity meter's started_at.
summarize(anchor → now), where anchor = max(contract_start, recording_start). 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. 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. 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. We pin the timezone to UTC for simplicity: local days = UTC days.
Setup: Setup:
- effective_from = 10 full UTC days ago (exact UTC midnight). - meter.started_at = effective_from = 10 full UTC days ago (exact UTC midnight).
- First period placed AT effective_from (same UTC midnight), so recording_start = - First period placed AT meter.started_at → anchor = meter.started_at.
contract_start → anchor = max(contract_start, recording_start) = contract_start. - Under UTC pinned, Principle C counts days [meter.started_at.date(), today] = 11 days.
- Under UTC pinned, Principle C counts days [effective_from.date(), today] = 11 days.
""" """
from decimal import Decimal from decimal import Decimal
from datetime import timedelta 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 from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
# anchor = exactly 10 UTC days ago at midnight # D2 anchor = meter.started_at = 10 UTC days ago at midnight
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10) 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). # 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 import_cost_sum = 5.00 # EUR
# Place period rows AT effective_from (= recording_start = contract_start → same anchor). t0 = meter_started_at # first period at anchor
# This ensures anchor = max(effective_from, effective_from) = effective_from exactly.
t0 = effective_from # first period starts exactly at contract effective_from
t1 = t0 + timedelta(minutes=15) t1 = t0 + timedelta(minutes=15)
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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: def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
"""export_revenue_total getter must add prorated heffingskorting from active contract. """export_revenue_total getter must add prorated heffingskorting from active contract.
Principle B/D + FU11 anchor guardrail: delegates to summarize(anchor → now), M7-T04 (D2): anchor = active electricity meter's started_at.
where anchor = max(contract_start, recording_start).
With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day. With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
Timezone pinned to UTC for deterministic local-day counting. 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 decimal import Decimal
from datetime import timedelta 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 from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) 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. # Under UTC pinned: expected_days = days from meter.started_at.date() to today inclusive.
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5 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 = meter_started_at
t0 = effective_from
t1 = t0 + timedelta(minutes=15) t1 = t0 + timedelta(minutes=15)
export_sum = 2.50 # EUR export_sum = 2.50 # EUR
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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: 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). M7-T04 (D2): anchor = active electricity meter's started_at (in the past).
summarize(anchor → now) has a negative/empty window → 0 days, 0 fixed_costs. The summarize window [meter.started_at, now) DOES include the period data,
The global `has_data` check passes (there are non-degraded rows from the past), but Principle C does not count future local days for fixed costs. Since
but no periods fall within [anchor, now] window → metered = 0, fixed = 0. the contract's effective_from is in the future, no days in [meter.started_at,
The getter returns 0 + 0 = 0. 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 With the D2 anchor in the past, the metered sum IS returned (unlike the old
getter returns the pure metered sum from the summarize window (which is 0 FU11 design where the anchor was the future effective_from → empty window → 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 Expected: value = import_cost_sum + 0 (no standing days)
date → window is empty → metered = 0.
""" """
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch 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 from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) 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) 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) t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
import_cost_sum = 4.00 import_cost_sum = 4.00
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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) value = import_entry.entity.value_getter(session)
# anchor is in future → summarize([future, now]) is empty → metered=0, fixed_costs=0 # D2: anchor = meter.started_at (past) → metered_import = import_cost_sum.
# Principle C: no future local days counted → 0 fixed costs. # Principle C: contract effective_from is in the future → 0 standing days → fixed_costs = 0.
# Result = 0 (no periods in window) + 0 (0 standing days) = 0. # Result = import_cost_sum + 0.
assert value == pytest.approx(0.0, abs=1e-9), ( assert value == pytest.approx(import_cost_sum, rel=1e-9), (
f"When effective_from is in future, summarize window is empty → value must be 0.0; " f"When effective_from is in future, standing = 0, value = metered sum; "
f"got {value!r}" f"expected {import_cost_sum}, got {value!r}"
) )
def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None: 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 datetime import timedelta
from unittest.mock import patch 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 from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) 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) future_effective = now_utc + timedelta(days=60)
t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1) t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
export_sum = 3.00 export_sum = 3.00
with Session(energy_db) as session: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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) value = export_entry.entity.value_getter(session)
# anchor is in future → empty window → 0 credits # D2: metered_export = export_sum; Principle C: future effective_from → 0 credits.
assert value == pytest.approx(0.0, abs=1e-9), ( assert value == pytest.approx(export_sum, rel=1e-9), (
f"When effective_from is in future, credit must be 0; got {value!r}" 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: def test_import_cost_total_returns_metered_sum_when_no_active_contract(energy_db) -> None:
"""When no active contract exists, import_cost_total must return the raw SUM only.""" """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 from app.integrations.expose import build_catalog
t0 = datetime(2026, 1, 1, 6, 0, tzinfo=timezone.utc) t0 = datetime(2026, 1, 1, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2026, 1, 1, 6, 15, 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
# Inactive contract — active=False # Inactive contract — active=False
_make_contract_with_version( _make_contract_with_version(
session, 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) 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), ( 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: def test_export_revenue_total_returns_metered_sum_when_no_active_contract(energy_db) -> None:
"""When no active contract exists, export_revenue_total must return the raw SUM only.""" """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 from app.integrations.expose import build_catalog
t0 = datetime(2026, 2, 1, 6, 0, tzinfo=timezone.utc) t0 = datetime(2026, 2, 1, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2026, 2, 1, 6, 15, 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
# Inactive contract — active=False # Inactive contract — active=False
_make_contract_with_version( _make_contract_with_version(
session, 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) 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), ( 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: def test_cumulative_anchor_is_meter_started_at(energy_db) -> None:
"""FU11: anchor = max(contract_start, recording_start) — no pre-recording fixed costs. """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 Scenario: contract effective_from = 180 days ago; meter.started_at = 7 days ago;
as anchor), but first non-degraded period_start = 7 days ago. Expected: anchor is first non-degraded period_start = 7 days ago.
7 days ago → only 8 days of standing charges (7 + today under Principle C). 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 decimal import Decimal
from datetime import timedelta 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) now_utc = datetime.now(timezone.utc)
midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) 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) contract_start = midnight_today - timedelta(days=180)
# First (and only) data period starts 7 days ago # Active meter started 7 days ago — this is the D2 anchor
recording_start = midnight_today - timedelta(days=7) 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: with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version( _make_contract_with_version(
session, session,
values=_STANDING_VALUES, 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) value = import_entry.entity.value_getter(session)
# Anchor = recording_start (7 days ago UTC midnight). # Anchor = meter.started_at (7 days ago).
# Principle C (UTC pinned): count local days from recording_start.date() to today inclusive. # Principle C (UTC pinned): count local days from meter_started_at.date() to today inclusive.
# That's 7+1 = 8 days. # That's 7 + 1 = 8 days.
expected_days = (now_utc.date() - recording_start.date()).days + 1 # = 8 expected_days = (now_utc.date() - meter_started_at.date()).days + 1 # = 8
daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day
expected = float(Decimal("5.00") + daily_standing * expected_days) 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), ( 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"import_cost_total={expected} (metered=5.00 + standing={float(daily_standing*expected_days)} "
f"over {expected_days} days), got {value!r}" 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_days = (now_utc.date() - contract_start.date()).days + 1 # ~181 days
old_expected = float(Decimal("5.00") + daily_standing * old_expected_days) old_expected = float(Decimal("5.00") + daily_standing * old_expected_days)
assert abs(value - old_expected) > 100, ( assert abs(value - old_expected) > 100, (
f"Old (wrong) anchor would yield ~{old_expected:.2f}, " f"D2 anchor (meter.started_at) expected ~{expected:.2f}, "
f"the new value {value:.2f} should differ by >€100" 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}"
) )