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)
|
||||
|
||||
Reference in New Issue
Block a user