M7-T04: anchor cumulative cost entities to active meter start (per-meter reset)
This commit is contained in:
+54
-52
@@ -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)
|
||||
|
||||
+364
-75
@@ -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.
|
||||
# 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() - 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
|
||||
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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user