FU11: anchor cumulative cost at recording start; add daily-reset cost entities; prefill add-version form
- MQTT import_cost_total / export_revenue_total now anchor fixed-fee / tax-credit accrual at max(contract start, first recorded period), so standing charges for untracked pre-recording days are no longer folded into the running total. - Add energy.import_cost_today / energy.export_revenue_today (monetary, total_increasing) that reset at server-local midnight, for per-day cost aggregation in Home Assistant alongside the running totals. - ContractForm add-version mode prefills the contract's open version values, so only the fields that actually change need editing.
This commit is contained in:
+145
-7
@@ -550,13 +550,17 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
"""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 effective_from of the earliest version of the active
|
||||
contract (i.e. the contract billing start). This makes the cumulative total
|
||||
monotonically increasing and cross-version consistent:
|
||||
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).
|
||||
|
||||
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).
|
||||
|
||||
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
|
||||
|
||||
Returns None when no non-degraded period exists in [anchor, now].
|
||||
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:
|
||||
@@ -580,8 +584,18 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
# No active contract — return pure SUM(import_cost) without standing charges.
|
||||
return float(has_data)
|
||||
|
||||
# anchor = earliest version's effective_from (contract billing start).
|
||||
anchor_utc = _cu(versions[0].effective_from)
|
||||
# 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
|
||||
|
||||
now_utc = _dt.now(UTC)
|
||||
|
||||
result = _summarize(sess, anchor_utc, now_utc)
|
||||
@@ -595,6 +609,7 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
"""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.
|
||||
|
||||
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
|
||||
|
||||
@@ -620,7 +635,17 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
# No active contract — return pure SUM(export_revenue) without credits.
|
||||
return float(has_data)
|
||||
|
||||
anchor_utc = _cu(versions[0].effective_from)
|
||||
# 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
|
||||
|
||||
now_utc = _dt.now(UTC)
|
||||
|
||||
result = _summarize(sess, anchor_utc, now_utc)
|
||||
@@ -628,6 +653,85 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: today's import cost (local-day window, resets at local midnight) ---
|
||||
|
||||
def _make_import_cost_today_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for today's import cost (metered + standing for today).
|
||||
|
||||
Window: [local today 00:00, local tomorrow 00:00) in UTC.
|
||||
After local midnight the window rolls to the new day → value resets to that
|
||||
day's running cost, implementing "resets at local midnight" semantics for HA.
|
||||
|
||||
Uses timezone module via module-attribute access to preserve monkeypatch safety.
|
||||
Returns None when no non-degraded period exists or no active contract.
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.services.contracts import active_contract_versions
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from app.services.energy_cost import summarize as _summarize
|
||||
from app.services import timezone as _tz_mod
|
||||
from sqlalchemy import func as _func
|
||||
from datetime import timedelta as _td
|
||||
|
||||
# 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:
|
||||
return None
|
||||
|
||||
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
||||
today_local = _tz_mod.local_now().date()
|
||||
tomorrow_local = today_local + _td(days=1)
|
||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||
|
||||
result = _summarize(sess, today_start_utc, tomorrow_start_utc)
|
||||
return result["metered_import"] + result["fixed_costs"]
|
||||
|
||||
return _getter
|
||||
|
||||
# --- value_getter: today's export revenue (local-day window, resets at local midnight) ---
|
||||
|
||||
def _make_export_revenue_today_getter() -> Callable[["Session"], Any]:
|
||||
"""Return a getter for today's export revenue (metered + tax credit for today).
|
||||
|
||||
Same window semantics as import_cost_today.
|
||||
Returns None when no non-degraded period exists or no active contract.
|
||||
"""
|
||||
def _getter(sess: "Session") -> Any:
|
||||
from app.services.contracts import active_contract_versions
|
||||
from app.models.energy import EnergyCostPeriod as _ECP
|
||||
from app.services.energy_cost import summarize as _summarize
|
||||
from app.services import timezone as _tz_mod
|
||||
from sqlalchemy import func as _func
|
||||
from datetime import timedelta as _td
|
||||
|
||||
# Quick check: any non-degraded period at all?
|
||||
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||
_ECP.degraded.is_(False)
|
||||
).scalar()
|
||||
if has_data is None:
|
||||
return None
|
||||
|
||||
versions = active_contract_versions(sess)
|
||||
if not versions:
|
||||
return None
|
||||
|
||||
today_local = _tz_mod.local_now().date()
|
||||
tomorrow_local = today_local + _td(days=1)
|
||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||
|
||||
result = _summarize(sess, today_start_utc, tomorrow_start_utc)
|
||||
return result["metered_export"] + result["credits"]
|
||||
|
||||
return _getter
|
||||
|
||||
# Price unit string: "<currency>/kWh"
|
||||
price_unit = f"{currency}/kWh"
|
||||
|
||||
@@ -672,6 +776,40 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||
value_getter=_make_export_revenue_getter(),
|
||||
state_class="total",
|
||||
),
|
||||
# Daily entities: reset at local midnight, state_class=total_increasing.
|
||||
#
|
||||
# device_class=monetary is correct here. The HA developer docs (long-term
|
||||
# statistics section) prohibit MONETARY from being combined with
|
||||
# state_class=measurement — they do NOT prohibit it with total or
|
||||
# total_increasing. monetary+total_increasing is valid (the cumulative
|
||||
# entities above use monetary+total with no issue).
|
||||
#
|
||||
# total_increasing is appropriate because these daily quantities are
|
||||
# non-negative (import/export metered cost ≥ 0, fixed standing fee/tax
|
||||
# credit ≥ 0) and monotonically non-decreasing within any given local day.
|
||||
# When the local midnight rolls over the value drops back to ~1 day's fixed
|
||||
# fee; HA interprets that decrease as a new cycle, giving correct daily
|
||||
# aggregation without any explicit last_reset pipeline.
|
||||
ExposableEntity(
|
||||
key="energy.import_cost_today",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="monetary",
|
||||
unit=currency,
|
||||
name="Energy Import Cost Today (incl. standing)",
|
||||
value_getter=_make_import_cost_today_getter(),
|
||||
state_class="total_increasing",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="energy.export_revenue_today",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="monetary",
|
||||
unit=currency,
|
||||
name="Energy Export Revenue Today (incl. tax credit)",
|
||||
value_getter=_make_export_revenue_today_getter(),
|
||||
state_class="total_increasing",
|
||||
),
|
||||
]
|
||||
|
||||
return entities
|
||||
|
||||
Reference in New Issue
Block a user