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]:
"""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)