FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
frontend / frontend (push) Successful in 2m5s
pytest / test (push) Successful in 7m34s

- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py).
- summarize: fixed fee / tax credit accrue only for elapsed whole local days,
  integrated across contract versions — no future-day inflation, no version-switch collapse.
- MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline
  apportionment), anchored at the active contract's earliest version effective_from so the
  total sensors stay monotonic and never jump backward.
- effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC.
- frontend ContractForm sends local-midnight naive datetime (was UTC midnight).
- get_costs_summary default window uses server-local "today".
This commit is contained in:
2026-06-25 11:13:30 +02:00
parent 682e06d256
commit 8b91146c29
10 changed files with 941 additions and 158 deletions
+102 -26
View File
@@ -60,7 +60,8 @@ from app.integrations.pricing.strategies import (
get_strategy,
)
from app.models.energy import DsmrReading, EnergyCostPeriod
from app.services.contracts import active_contract_version_at
from app.services.contracts import active_contract_version_at, active_contract_versions
from app.services.timezone import local_date, local_now
logger = logging.getLogger(__name__)
@@ -496,13 +497,23 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
Computes the total payable as:
total_payable = Σ(net_cost) -- metered electricity
+ fixed_costs -- (network_fee + management_fee) EUR/month ÷ 30 × days
- credits -- heffingskorting EUR/year ÷ 365 × days
+ fixed_costs -- per-day standing charges, cross-version
- credits -- per-day heffingskorting, cross-version
The fixed costs and credits are derived from the *currently active contract
version* at *end* (i.e. the version in effect at the end of the requested
interval). When no active contract version exists, fixed costs and credits
are both 0; only the metered sum is returned.
**Fixed-cost / credit counting — Principle C**:
Only *already-elapsed* whole local calendar days are counted. For each
local calendar date D in the window ``[start_local_date, min(end_local_date,
tomorrow_local))``, the contract version whose rate covers D (the one whose
effective_from local-date ≤ D < next version's effective_from local-date) is
used. Days that have not yet started in local time (D > today_local) are
never counted. This is cross-version: if the active contract has V1 from
June 1 and V2 from June 25, querying June 130 uses V1 for days 1-24 and V2
for day 25. Switching versions never resets the counter.
**Timezone note**: the ``days`` field in the returned dict still represents
the window length in calendar days (total_seconds / 86400), for backward
compatibility with existing API consumers. The fixed/credit calculation
independently counts whole local days as described above.
All arithmetic uses Decimal; the returned dict contains Python floats for
JSON-serialisation convenience.
@@ -512,9 +523,9 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
session:
Active read-only SQLAlchemy session.
start:
Inclusive start of the summary interval.
Inclusive start of the summary interval (UTC or naive-UTC).
end:
Exclusive end of the summary interval.
Exclusive end of the summary interval (UTC or naive-UTC).
Returns
-------
@@ -524,13 +535,15 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
metered_import float Σ import_cost from non-degraded periods
metered_export float Σ export_revenue from non-degraded periods
metered_net float Σ net_cost from non-degraded periods
fixed_costs float standing charges apportioned over the interval
credits float energy-tax credit apportioned over the interval
fixed_costs float standing charges for elapsed whole local days
credits float energy-tax credit for elapsed whole local days
total_payable float metered_net + fixed_costs credits
period_count int number of non-degraded periods in range
degraded_count int number of degraded periods in range
days float interval length in days (total_seconds / 86400)
"""
from datetime import timedelta as _td, date as _date
start_utc = _as_utc(start)
end_utc = _as_utc(end)
@@ -550,32 +563,95 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
sum_export = sum((_to_decimal(r.export_revenue) for r in good_rows), Decimal("0"))
sum_net = sum((_to_decimal(r.net_cost) for r in good_rows), Decimal("0"))
# --- Interval length in days ---
# --- Interval length in days (window, not elapsed — kept for API compat) ---
total_seconds = (end_utc - start_utc).total_seconds()
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
# --- Active contract version at *end* for standing charges ---
version = active_contract_version_at(session, end_utc)
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
today_local: _date = local_now().date()
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
#
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc)
# AND D ≤ today_local (only elapsed / today days count as "whole days").
#
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
# AND the local midnight is within the billing window."
#
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that
# contains CEST midnight (= 22:00 UTC) contributes 1 day.
#
# Implementation: compute the first and last local date whose midnight is in range.
from app.services.timezone import local_midnight_utc as _lmu
local_start_date: _date = local_date(start_utc)
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day.
if _lmu(local_start_date) >= start_utc:
first_counted: _date = local_start_date
else:
first_counted = local_start_date + _td(days=1)
local_end_date: _date = local_date(end_utc)
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
if _lmu(local_end_date) < end_utc:
last_counted: _date = local_end_date
else:
last_counted = local_end_date - _td(days=1)
# Cap: only elapsed or today's days.
last_counted = min(last_counted, today_local)
# If the range is empty (first_counted > last_counted), no days are counted.
# Fetch all versions of the active contract once (single DB call).
versions = active_contract_versions(session)
fixed_dec = Decimal("0")
credits_dec = Decimal("0")
currency = "EUR"
if version is not None:
currency = version.contract.currency
vals: dict = version.values or {}
standing: dict = vals.get("standing", {})
creds: dict = vals.get("credits", {})
if versions:
# Currency comes from the contract regardless of day count.
currency = versions[0].contract.currency
network_fee = _to_decimal(standing.get("network_fee", 0))
management_fee = _to_decimal(standing.get("management_fee", 0))
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
if versions and first_counted <= last_counted:
# For each version segment, find its overlap with [first_counted, last_counted]
# and accumulate whole days.
version_segments: list[tuple[_date, _date | None, dict]] = []
for v in versions:
v_start_local = local_date(_as_utc(v.effective_from))
v_end_local = local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None
version_segments.append((v_start_local, v_end_local, v.values or {}))
# Standing charges: EUR/month → EUR/day (÷ 30) × days.
fixed_dec = (network_fee + management_fee) / Decimal("30") * days
for v_start, v_end_excl, v_values in version_segments:
# The version covers [v_start, v_end_excl) in local dates,
# where v_end_excl=None means open-ended (no upper bound).
# Intersection with [first_counted, last_counted]:
seg_start = max(first_counted, v_start)
if v_end_excl is not None:
seg_end_excl = min(last_counted + _td(days=1), v_end_excl)
else:
seg_end_excl = last_counted + _td(days=1)
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × days.
credits_dec = heffingskorting / Decimal("365") * days
if seg_start >= seg_end_excl:
continue # no overlap
n_days = (seg_end_excl - seg_start).days
if n_days <= 0:
continue
standing: dict = v_values.get("standing", {})
creds: dict = v_values.get("credits", {})
network_fee = _to_decimal(standing.get("network_fee", 0))
management_fee = _to_decimal(standing.get("management_fee", 0))
heffingskorting = _to_decimal(creds.get("heffingskorting", 0))
# Standing charges: EUR/month → EUR/day (÷ 30) × n_days.
fixed_dec += (network_fee + management_fee) / Decimal("30") * Decimal(str(n_days))
# Energy-tax credit: EUR/year → EUR/day (÷ 365) × n_days.
credits_dec += heffingskorting / Decimal("365") * Decimal(str(n_days))
total_payable = sum_net + fixed_dec - credits_dec