Files
home-automation/app/services/energy_cost.py
T

848 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Billing engine for DSMR 15-minute energy metering periods.
This module implements the two-layer billing model described in §3.4 of the
M6 design document, extended in M7-T03 to be meter-aware:
**Layer 1 — per-period metering cost (immutable, price-snapshot)**
``compute_period(session, t0)`` computes the import cost, export revenue, and
net cost for the 15-minute period ``[t0, t0+15min)``. The result is written
to ``energy_cost_period`` with a full pricing snapshot so each row is
self-contained and auditable. Existing *successful* rows are never overwritten
by the normal tick path; only an explicit ``recompute_range`` call passes
``overwrite=True``.
**Layer 2 — summary (computed at read time, not stored)**
``summarize(session, start, end)`` aggregates all non-degraded
``energy_cost_period`` rows in ``[start, end)``, then adds the daily
standing charges (network_fee + management_fee, apportioned at EUR/month
÷ 30 per day) and subtracts the energy-tax credit (heffingskorting,
apportioned at EUR/year ÷ 365 per day).
Design notes
------------
- **Decimal arithmetic throughout**: all monetary computations use
``decimal.Decimal`` to avoid float binary rounding errors. Only when
writing to ``EnergyCostPeriod`` columns (Float) are values converted to
float. ``summarize`` converts back to Decimal for summation.
- **UTC quarter-hour grid**: period boundaries are aligned to UTC 00/15/30/45
minutes (``floor_to_quarter``). NL local time (CET/CEST) is always a whole
number of hours from UTC, so the quarter-hour grid is the same in both
timezone representations.
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
for cumulative kWh registers. ``register_at`` converts them to Decimal.
- **Degraded vs skip semantics**:
- *No meter coverage* (``meter_at`` returns None for t0): write a
``degraded=True`` row with ``meter_id=None``.
- *Cross-meter boundary* (m0.id != m1.id for t0/t1): write a ``degraded=True``
row with ``meter_id=m0.id``; losing this one period at the swap boundary is
acceptable (D5 decision).
- *Missing readings* (``register_at`` returns None for start or end
boundary within the meter window): write a ``degraded=True`` row with
``meter_id=m0.id`` so the period is tracked and can be retried by
``compute_closed_periods``.
- *Negative or excessively large delta* (delta sanity guard D6): write a
``degraded=True`` row with ``meter_id=m0.id``; prevents negative costs and
grossly inflated costs from meter resets, DSMR rollover, or data spikes.
- *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do
not write a row); the period will be retried once prices arrive.
- *Missing active contract version*: skip (no contract to compute against).
- **Meter-aware register lookup**: ``register_at`` now accepts a ``meter``
parameter and restricts the DSMR reading query to readings within
``[meter.started_at, meter.ended_at)`` (half-open), preventing old-meter
readings from leaking into a new-meter epoch.
- **Lookback window in ``compute_closed_periods``**: to avoid scanning all
historical DSMR data on every tick, the function looks back at most 7 days
from the current time. This covers typical short outages (no data / no
contract) while staying bounded. Periods older than 7 days must be
recovered via an explicit ``recompute_range`` call.
Meter-aware compute_period ordering rationale (M7-T03)
-------------------------------------------------------
The order of checks inside ``compute_period`` is:
1. **Immutability guard** (existing non-degraded row, overwrite=False) → return False.
2. **Meter determination** (m0 = meter_at(t0), m1 = meter_at(t1)):
- No meter (m0 is None) → write degraded, meter_id=None.
- Cross-meter boundary (m0.id != m1.id) → write degraded, meter_id=m0.id.
3. **Active contract version check** → skip (no write) if absent.
4. **Boundary register readings** within m0's window → write degraded if missing.
5. **Delta sanity guard** → write degraded if any delta < 0 or > _MAX_DELTA_KWH.
6. **Price strategy** → skip (no write) if Tibber price missing.
7. **Upsert billing record** with meter_id=m0.id.
Why meter before contract? The meter is a *structural* prerequisite: without a
known meter epoch we cannot trust the delta at all, so we commit a degraded row
immediately. The contract skip, by contrast, is transient (the period can be
re-computed once a contract is configured), so it produces no row.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.integrations.pricing.strategies import (
PeriodDeltas,
TibberPriceNotFoundError,
get_strategy,
)
from app.models.energy import DsmrReading, EnergyCostPeriod, Meter
from app.services.contracts import active_contract_version_at, active_contract_versions
from app.services.meters import meter_at
from app.services.timezone import local_date, local_now
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_PERIOD_MINUTES = 15
_LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
# Maximum age a DSMR reading may have relative to the boundary being queried.
# Under normal operation DSMR readings arrive every ~10 seconds, so a reading
# more than one period (15 minutes) old at the boundary indicates either a data
# gap or — critically — a *future* boundary being resolved against the last
# historical reading. In both cases the reading is considered stale and
# ``register_at`` returns None, letting the period be marked degraded instead of
# producing a spurious zero-delta "successful" row.
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# Maximum plausible kWh delta for a single 15-minute period (D6 sanity guard).
# A typical Dutch household uses well under 5 kWh per quarter hour even under
# heavy load. 100 kWh per 15 minutes corresponds to ~400 kW — far beyond any
# residential consumption — but is lenient enough to never fire on legitimate
# data. Any delta at or above this threshold indicates a meter reset, DSMR
# rollover, sign error, or other data anomaly, and the period is marked
# degraded to prevent negative costs or grossly inflated charges.
_MAX_DELTA_KWH = Decimal("100")
# DSMR payload register keys (cumulative kWh, JSON string values).
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
_KEY_R1 = "electricity_returned_1" # returned low-tariff
_KEY_R2 = "electricity_returned_2" # returned high-tariff
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def floor_to_quarter(dt: datetime) -> datetime:
"""Return *dt* floored to the nearest UTC quarter-hour boundary.
The result always has seconds=0 and microseconds=0, and minutes in
{0, 15, 30, 45}. Timezone info is preserved if present.
"""
floored_minute = (dt.minute // _PERIOD_MINUTES) * _PERIOD_MINUTES
return dt.replace(minute=floored_minute, second=0, microsecond=0)
def _to_decimal(value: Any) -> Decimal:
"""Convert *value* to Decimal via str() to avoid float binary rounding."""
return Decimal(str(value))
def _as_utc(dt: datetime) -> datetime:
"""Attach UTC tzinfo to a naive datetime (SQLite read-back workaround)."""
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
def _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None:
"""Return the EnergyCostPeriod row for period_start=t0, or None."""
return session.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == t0)
).scalar_one_or_none()
# ---------------------------------------------------------------------------
# register_at — boundary reading lookup (meter-aware)
# ---------------------------------------------------------------------------
def register_at(
session: Session,
boundary: datetime,
meter: Meter,
) -> dict[str, Decimal] | None:
"""Return the four cumulative kWh register values at *boundary*, within *meter*'s window.
Queries the most recent ``DsmrReading`` with:
``recorded_at ≤ boundary``
AND ``recorded_at ≥ meter.started_at``
AND (``meter.ended_at IS NULL`` OR ``recorded_at < meter.ended_at``)
The meter window constraint (half-open ``[started_at, ended_at)``) ensures
that readings from a previous meter epoch are never used to anchor a new
meter's computation. Without this guard, the final reading of the old meter
would be visible at the start of the new meter's epoch and produce a
cross-meter delta, defeating the isolation guarantee.
Extracts the four energy registers from ``payload``:
d1 — electricity_delivered_1 (delivered low-tariff / dal)
d2 — electricity_delivered_2 (delivered high-tariff / normal)
r1 — electricity_returned_1 (returned low-tariff)
r2 — electricity_returned_2 (returned high-tariff)
Returns
-------
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
when:
- No ``DsmrReading`` row exists with ``recorded_at ≤ boundary`` within
*meter*'s epoch window.
- The most recent such reading is older than ``_READING_MAX_STALENESS``
relative to *boundary* (freshness guard).
- Any of the four register keys is absent from the payload.
- Any of the four register values is ``None`` (null in JSON).
SQLite naive datetime note
--------------------------
``recorded_at`` is stored as a naive UTC datetime in SQLite. Comparisons
against *boundary* (always tz-aware UTC) use ``_as_utc()`` for the
freshness check. The SQL ``WHERE`` clause comparisons work correctly
because SQLAlchemy's SQLite dialect strips tzinfo when binding parameters
(leaving the wall-clock UTC value unchanged), consistent with the storage
format.
"""
# Build the meter-window constraints: [started_at, ended_at).
meter_lower = meter.started_at # DsmrReading.recorded_at >= meter.started_at
meter_upper = meter.ended_at # DsmrReading.recorded_at < meter.ended_at (if set)
stmt = (
select(DsmrReading)
.where(
DsmrReading.recorded_at <= boundary,
DsmrReading.recorded_at >= meter_lower,
)
.order_by(DsmrReading.recorded_at.desc())
.limit(1)
)
# Apply the upper bound only when the meter is closed (ended_at is not None).
if meter_upper is not None:
stmt = stmt.where(DsmrReading.recorded_at < meter_upper)
row: DsmrReading | None = session.execute(stmt).scalar_one_or_none()
if row is None:
return None
# Freshness guard: reject readings that are too old relative to *boundary*.
# ``recorded_at`` is stored as a naive UTC datetime in SQLite; attach UTC
# tzinfo before comparing with *boundary* (which is always tz-aware UTC) to
# avoid an "offset-naive vs offset-aware" TypeError.
if _as_utc(row.recorded_at) < _as_utc(boundary) - _READING_MAX_STALENESS:
return None
payload = row.payload or {}
try:
d1_raw = payload[_KEY_D1]
d2_raw = payload[_KEY_D2]
r1_raw = payload[_KEY_R1]
r2_raw = payload[_KEY_R2]
except KeyError:
return None
if any(v is None for v in (d1_raw, d2_raw, r1_raw, r2_raw)):
return None
return {
"d1": _to_decimal(d1_raw),
"d2": _to_decimal(d2_raw),
"r1": _to_decimal(r1_raw),
"r2": _to_decimal(r2_raw),
}
# ---------------------------------------------------------------------------
# compute_period — single 15-minute period
# ---------------------------------------------------------------------------
def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -> bool:
"""Compute and upsert the billing record for the period ``[t0, t0+15min)``.
Parameters
----------
session:
Active SQLAlchemy session. Caller is responsible for committing.
t0:
UTC start of the 15-minute period. **Must** lie on a quarter-hour
grid boundary (minutes ∈ {0, 15, 30, 45}, seconds=0, microseconds=0).
overwrite:
If ``True``, overwrite an existing *successful* row (i.e. re-compute
even when a non-degraded record already exists). The normal tick path
always passes ``False``; only ``recompute_range`` passes ``True``.
Returns
-------
bool
``True`` if a record was written (inserted or updated), ``False`` if
the period was skipped (missing contract or missing Tibber price).
Side-effects
------------
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
- If no meter covers t0 (``meter_at`` returns None for t0): inserts/updates
a degraded row with ``meter_id=None``.
- If the period spans a meter boundary (``meter_at(t0).id != meter_at(t1).id``):
inserts/updates a degraded row with ``meter_id=m0.id`` (D5 decision).
- If readings are missing at either boundary within the meter window:
inserts/updates a degraded row with ``meter_id=m0.id``.
- If any delta is negative or exceeds ``_MAX_DELTA_KWH`` (D6 sanity guard):
inserts/updates a degraded row with ``meter_id=m0.id``.
- If the active contract version is missing: **skips** (returns False, no write).
- If the Tibber price is missing (TibberPriceNotFoundError): **skips**
(returns False, no write).
"""
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
now = datetime.now(UTC)
# Immutability guard: skip if a successful record already exists and we
# are not in overwrite mode.
existing = _existing_period(session, t0)
if existing is not None and not existing.degraded and not overwrite:
return False
# --- Meter determination (structural prerequisite, checked before contract) ---
#
# A missing or cross-boundary meter is a structural problem: we cannot trust
# the delta at all, so we write a degraded row immediately. This is different
# from the contract skip (transient, no write): the degraded row ensures the
# period appears in the history and can be revisited once the meter timeline
# is corrected and a recompute_range is triggered.
#
# Ordering rationale:
# 1. No meter (m0 is None) → degraded(meter_id=None): no epoch for t0.
# 2. Cross-meter boundary (m0.id != m1.id) → degraded(meter_id=m0.id): D5.
# 3. (Single meter, proceed) → contract check → readings → delta guard → price.
#
# We place meter before contract so that "cross-table period" is always
# marked degraded regardless of contract state. If we checked contract
# first, a missing-contract skip would silently discard the cross-table
# evidence; once a contract is added and recompute runs, the engine would
# incorrectly use cross-table reads.
m0 = meter_at(session, t0)
m1 = meter_at(session, t1)
if m0 is None:
# No meter epoch covers t0 — degraded with no meter attribution.
logger.debug(
"compute_period(%s): no active meter at t0 — writing degraded (meter_id=None).",
t0.isoformat(),
)
_upsert_degraded(session, t0, now, existing, meter_id=None)
return True
if m1 is None or m0.id != m1.id:
# Period spans a meter boundary or t1 has no meter. Degrade with m0's id
# (t0's meter attribution): the period's start belongs to m0's epoch.
logger.debug(
"compute_period(%s): period crosses meter boundary "
"(m0.id=%s, m1.id=%s) — writing degraded.",
t0.isoformat(),
m0.id,
m1.id if m1 is not None else None,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Active contract version at t0 ---
# If there is no active contract covering t0, skip the period entirely.
# We do not write a degraded row — there is no meaningful state to recover
# without a contract (we would not know which strategy to apply once data
# arrives). The period can be recovered via an explicit recompute_range once
# a contract is configured and activated.
version = active_contract_version_at(session, t0)
if version is None:
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
return False
# --- Boundary readings within m0's meter window ---
start_regs = register_at(session, t0, m0)
end_regs = register_at(session, t1, m0)
if start_regs is None or end_regs is None:
# Missing readings within the meter window → degraded with m0 attribution.
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True # a record was written (degraded)
# --- Compute deltas (end start) ---
deltas = PeriodDeltas(
d1=end_regs["d1"] - start_regs["d1"],
d2=end_regs["d2"] - start_regs["d2"],
r1=end_regs["r1"] - start_regs["r1"],
r2=end_regs["r2"] - start_regs["r2"],
)
# --- Delta sanity guard (D6) ---
# Any negative delta indicates a meter reset, DSMR rollover, or data error.
# Any delta exceeding _MAX_DELTA_KWH (100 kWh per 15 min = 400 kW average)
# is implausible for residential use and indicates an anomaly.
# Both cases produce a degraded row so no negative or grossly inflated cost
# is ever written to the billing record.
all_deltas = (deltas.d1, deltas.d2, deltas.r1, deltas.r2)
if any(d < Decimal("0") for d in all_deltas) or any(d > _MAX_DELTA_KWH for d in all_deltas):
logger.debug(
"compute_period(%s): delta sanity guard triggered "
"(d1=%s, d2=%s, r1=%s, r2=%s) — writing degraded.",
t0.isoformat(),
deltas.d1,
deltas.d2,
deltas.r1,
deltas.r2,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Price strategy ---
strategy = get_strategy(version.contract.kind)
try:
result = strategy(deltas, t0, version.values, session)
except TibberPriceNotFoundError:
# Missing Tibber price → skip the period; it will be retried once the
# price arrives (e.g. after the next Tibber refresh job runs).
logger.debug(
"compute_period(%s): no Tibber price found — skipping.", t0.isoformat()
)
return False
# --- Upsert the billing record ---
import_cost: Decimal = result["import_cost"]
export_revenue: Decimal = result["export_revenue"]
net_cost: Decimal = result["net_cost"]
pricing: dict = result["pricing"]
if existing is not None:
# Update in-place (overwrite=True or previous record was degraded).
existing.d1_kwh = float(deltas.d1)
existing.d2_kwh = float(deltas.d2)
existing.r1_kwh = float(deltas.r1)
existing.r2_kwh = float(deltas.r2)
existing.import_cost = float(import_cost)
existing.export_revenue = float(export_revenue)
existing.net_cost = float(net_cost)
existing.currency = version.contract.currency
existing.pricing = pricing
existing.contract_version_id = version.id
existing.meter_id = m0.id
existing.degraded = False
existing.computed_at = now
else:
period = EnergyCostPeriod(
period_start=t0,
d1_kwh=float(deltas.d1),
d2_kwh=float(deltas.d2),
r1_kwh=float(deltas.r1),
r2_kwh=float(deltas.r2),
import_cost=float(import_cost),
export_revenue=float(export_revenue),
net_cost=float(net_cost),
currency=version.contract.currency,
pricing=pricing,
contract_version_id=version.id,
meter_id=m0.id,
degraded=False,
computed_at=now,
)
session.add(period)
return True
def _upsert_degraded(
session: Session,
t0: datetime,
now: datetime,
existing: EnergyCostPeriod | None,
*,
meter_id: int | None,
) -> None:
"""Insert or update a degraded placeholder for period *t0*.
Parameters
----------
session:
Active SQLAlchemy session.
t0:
UTC start of the 15-minute period.
now:
Current UTC timestamp for the ``computed_at`` field.
existing:
The existing ``EnergyCostPeriod`` row for this period, or ``None``.
meter_id:
The meter ID to attribute this degraded period to, or ``None`` when
no meter epoch covers the period (no-meter degraded case).
When *existing* is not None (row was previously written — either degraded
or successful), the row is explicitly reset to the standard degraded state.
This is required for the ``recompute_range`` (overwrite=True) path: if the
row was previously a *successful* computation and the boundary readings have
since disappeared, the stale non-zero costs must be cleared so the row
accurately reflects the current "missing readings" state rather than
masquerading as a valid result.
The ``meter_id`` is always updated to reflect the current meter attribution
judgment (the result of ``meter_at`` at the time of recompute). This
ensures that a retroactive ``started_at`` change + ``recompute_range`` will
re-attribute historical degraded periods to the correct meter epoch.
"""
if existing is not None:
# Explicitly reset to degraded state — identical field values to the
# new-row path below. This covers the recompute-over-successful-row
# case where old non-zero costs must not survive the downgrade.
existing.d1_kwh = 0.0
existing.d2_kwh = 0.0
existing.r1_kwh = 0.0
existing.r2_kwh = 0.0
existing.import_cost = 0.0
existing.export_revenue = 0.0
existing.net_cost = 0.0
existing.pricing = {}
existing.contract_version_id = None
existing.meter_id = meter_id
existing.degraded = True
existing.computed_at = now
else:
period = EnergyCostPeriod(
period_start=t0,
d1_kwh=0.0,
d2_kwh=0.0,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.0,
export_revenue=0.0,
net_cost=0.0,
currency="EUR", # placeholder; real currency known after contract lookup
pricing={},
contract_version_id=None,
meter_id=meter_id,
degraded=True,
computed_at=now,
)
session.add(period)
# ---------------------------------------------------------------------------
# compute_closed_periods — periodic tick
# ---------------------------------------------------------------------------
def compute_closed_periods(session: Session) -> int:
"""Find and compute all uncalculated closed 15-minute periods.
A period ``[t0, t1)`` is *closed* when ``t1 ≤ now``. This function:
1. Determines the lookback window: from ``now LOOKBACK_DAYS`` to ``now``,
floored to the nearest quarter-hour. This avoids an unbounded full
historical scan on every tick while still covering the typical recovery
window (short outages, missing contract, etc.). Periods older than
``LOOKBACK_DAYS`` must be recovered via an explicit ``recompute_range``.
2. Iterates over all quarter-hour boundaries in that window where
``t1 ≤ now`` (i.e. the period has already closed).
3. For each boundary, calls ``compute_period(overwrite=False)``, which:
- Skips periods that already have a *successful* (non-degraded) record.
- Retries periods that have a *degraded* record.
- Writes degraded rows for periods with no meter or cross-meter boundaries.
- Skips periods for which no active contract version exists or the
Tibber price is unavailable (without writing a degraded row).
Returns
-------
int
Number of periods for which a record was written (inserted or updated).
Does not count skipped periods.
"""
now = datetime.now(UTC)
# Current period boundary (the one whose t1 has not yet passed).
current_t0 = floor_to_quarter(now)
# Earliest boundary to consider.
earliest_t0 = floor_to_quarter(now - timedelta(days=_LOOKBACK_DAYS))
written = 0
t0 = earliest_t0
while t0 < current_t0:
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
if t1 <= now:
try:
did_write = compute_period(session, t0, overwrite=False)
if did_write:
written += 1
except Exception:
logger.exception(
"compute_closed_periods: unexpected error for t0=%s — continuing.",
t0.isoformat(),
)
t0 += timedelta(minutes=_PERIOD_MINUTES)
if written:
session.commit()
logger.info("compute_closed_periods: wrote %d period(s).", written)
return written
# ---------------------------------------------------------------------------
# recompute_range — explicit full recompute
# ---------------------------------------------------------------------------
def recompute_range(session: Session, start: datetime, end: datetime) -> int:
"""Recompute (overwrite) all 15-minute periods in ``[start, end)``.
This is the *explicit opt-in* path for recovering from:
- Periods where readings or prices arrived late.
- Price corrections (new contract version retroactively applied).
- Retroactive meter changes (``update_meter`` with new ``started_at``) —
re-running this function will re-judge meter attribution and re-compute
costs using the corrected epoch boundaries.
- Any other reason to override the immutability guard.
The function iterates over every UTC quarter-hour boundary in
``[floor(start), end)`` and calls ``compute_period(overwrite=True)``.
Existing rows (including successful ones) are overwritten; their
``meter_id`` fields will reflect the *current* ``meter_at`` judgment for
each period's start timestamp, naturally re-attributing periods when meter
``started_at`` values have been retroactively corrected.
Parameters
----------
session:
Active SQLAlchemy session. The function commits after all periods
have been processed.
start:
Inclusive start datetime (floored to the nearest quarter-hour internally).
end:
Exclusive end datetime.
Returns
-------
int
Number of periods for which a record was written (inserted or updated).
Periods skipped due to missing contract or missing Tibber price are
*not* counted.
"""
t0 = floor_to_quarter(_as_utc(start))
end_utc = _as_utc(end)
now = datetime.now(UTC)
written = 0
while t0 < end_utc:
t1 = t0 + timedelta(minutes=_PERIOD_MINUTES)
# Only recompute periods that have already closed (t1 ≤ now). Even
# when the caller passes a future *end*, we must not write speculative
# "future" rows: register_at would resolve to the latest historical
# reading for both boundaries, producing a zero-delta fake-success row
# that blocks the real computation once the period actually closes.
if t1 <= now:
try:
did_write = compute_period(session, t0, overwrite=True)
if did_write:
written += 1
except Exception:
logger.exception(
"recompute_range: unexpected error for t0=%s — continuing.",
t0.isoformat(),
)
t0 += timedelta(minutes=_PERIOD_MINUTES)
session.commit()
logger.info(
"recompute_range(%s, %s): wrote %d period(s).",
start.isoformat(),
end.isoformat(),
written,
)
return written
# ---------------------------------------------------------------------------
# summarize — layer-2 aggregation (read-time, not stored)
# ---------------------------------------------------------------------------
def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any]:
"""Aggregate billing for the interval ``[start, end)``.
Computes the total payable as:
total_payable = Σ(net_cost) -- metered electricity
+ fixed_costs -- per-day standing charges, cross-version
- credits -- per-day heffingskorting, cross-version
**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.
Parameters
----------
session:
Active read-only SQLAlchemy session.
start:
Inclusive start of the summary interval (UTC or naive-UTC).
end:
Exclusive end of the summary interval (UTC or naive-UTC).
Returns
-------
dict with keys:
currency str ISO 4217 currency (from contract, or "EUR" fallback)
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 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)
# --- Fetch all EnergyCostPeriod rows in [start, end) ---
rows = session.execute(
select(EnergyCostPeriod).where(
EnergyCostPeriod.period_start >= start_utc,
EnergyCostPeriod.period_start < end_utc,
)
).scalars().all()
good_rows = [r for r in rows if not r.degraded]
degraded_rows = [r for r in rows if r.degraded]
# Σ monetary amounts (Decimal arithmetic).
sum_import = sum((_to_decimal(r.import_cost) for r in good_rows), Decimal("0"))
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 (window, not elapsed — kept for API compat) ---
total_seconds = (end_utc - start_utc).total_seconds()
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
# --- 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 versions:
# Currency comes from the contract regardless of day count.
currency = versions[0].contract.currency
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 {}))
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)
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
return {
"currency": currency,
"metered_import": float(sum_import),
"metered_export": float(sum_export),
"metered_net": float(sum_net),
"fixed_costs": float(fixed_dec),
"credits": float(credits_dec),
"total_payable": float(total_payable),
"period_count": len(good_rows),
"degraded_count": len(degraded_rows),
"days": float(days),
}