2026-06-23 22:26:56 +02:00
|
|
|
|
"""Billing engine for DSMR 15-minute energy metering periods.
|
|
|
|
|
|
|
|
|
|
|
|
This module implements the two-layer billing model described in §3.4 of the
|
2026-06-25 15:38:21 +02:00
|
|
|
|
M6 design document, extended in M7-T03 to be meter-aware:
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
**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**:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
- *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).
|
2026-06-23 22:26:56 +02:00
|
|
|
|
- *Missing readings* (``register_at`` returns None for start or end
|
2026-06-25 15:38:21 +02:00
|
|
|
|
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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
- *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).
|
2026-06-25 15:38:21 +02:00
|
|
|
|
- **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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
- **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.
|
2026-06-25 15:38:21 +02:00
|
|
|
|
|
|
|
|
|
|
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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-06-25 15:38:21 +02:00
|
|
|
|
from app.models.energy import DsmrReading, EnergyCostPeriod, Meter
|
2026-06-25 11:13:30 +02:00
|
|
|
|
from app.services.contracts import active_contract_version_at, active_contract_versions
|
2026-06-25 15:38:21 +02:00
|
|
|
|
from app.services.meters import meter_at
|
2026-06-25 11:13:30 +02:00
|
|
|
|
from app.services.timezone import local_date, local_now
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Constants
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_PERIOD_MINUTES = 15
|
|
|
|
|
|
_LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
|
|
|
|
|
|
|
2026-06-24 12:44:05 +02:00
|
|
|
|
# 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)
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# 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")
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# register_at — boundary reading lookup (meter-aware)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
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``:
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
- 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).
|
2026-06-23 22:26:56 +02:00
|
|
|
|
- Any of the four register keys is absent from the payload.
|
|
|
|
|
|
- Any of the four register values is ``None`` (null in JSON).
|
2026-06-25 15:38:21 +02:00
|
|
|
|
|
|
|
|
|
|
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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
"""
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# 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)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
)
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# 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()
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-06-24 12:44:05 +02:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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``.
|
2026-06-25 15:38:21 +02:00
|
|
|
|
- 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``.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
- 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
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# --- 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 ---
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# --- Boundary readings within m0's meter window ---
|
|
|
|
|
|
start_regs = register_at(session, t0, m0)
|
|
|
|
|
|
end_regs = register_at(session, t1, m0)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
if start_regs is None or end_regs is None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# Missing readings within the meter window → degraded with m0 attribution.
|
|
|
|
|
|
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# --- 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
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# --- 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
|
2026-06-25 15:38:21 +02:00
|
|
|
|
existing.meter_id = m0.id
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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,
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter_id=m0.id,
|
2026-06-23 22:26:56 +02:00
|
|
|
|
degraded=False,
|
|
|
|
|
|
computed_at=now,
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(period)
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _upsert_degraded(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
t0: datetime,
|
|
|
|
|
|
now: datetime,
|
|
|
|
|
|
existing: EnergyCostPeriod | None,
|
2026-06-25 15:38:21 +02:00
|
|
|
|
*,
|
|
|
|
|
|
meter_id: int | None,
|
2026-06-23 22:26:56 +02:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Insert or update a degraded placeholder for period *t0*.
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
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).
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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.
|
2026-06-25 15:38:21 +02:00
|
|
|
|
|
|
|
|
|
|
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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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
|
2026-06-25 15:38:21 +02:00
|
|
|
|
existing.meter_id = meter_id
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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,
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter_id=meter_id,
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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.
|
2026-06-25 15:38:21 +02:00
|
|
|
|
- Writes degraded rows for periods with no meter or cross-meter boundaries.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
- 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).
|
2026-06-25 15:38:21 +02:00
|
|
|
|
- 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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
- 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)``.
|
2026-06-25 15:38:21 +02:00
|
|
|
|
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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
now = datetime.now(UTC)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
written = 0
|
|
|
|
|
|
while t0 < end_utc:
|
2026-06-24 12:44:05 +02:00
|
|
|
|
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(),
|
|
|
|
|
|
)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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
|
2026-06-25 11:13:30 +02:00
|
|
|
|
+ fixed_costs -- per-day standing charges, cross-version
|
|
|
|
|
|
- credits -- per-day heffingskorting, cross-version
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
**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 1–30 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.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
All arithmetic uses Decimal; the returned dict contains Python floats for
|
|
|
|
|
|
JSON-serialisation convenience.
|
|
|
|
|
|
|
|
|
|
|
|
Parameters
|
|
|
|
|
|
----------
|
|
|
|
|
|
session:
|
|
|
|
|
|
Active read-only SQLAlchemy session.
|
|
|
|
|
|
start:
|
2026-06-25 11:13:30 +02:00
|
|
|
|
Inclusive start of the summary interval (UTC or naive-UTC).
|
2026-06-23 22:26:56 +02:00
|
|
|
|
end:
|
2026-06-25 11:13:30 +02:00
|
|
|
|
Exclusive end of the summary interval (UTC or naive-UTC).
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-25 11:13:30 +02:00
|
|
|
|
fixed_costs float standing charges for elapsed whole local days
|
|
|
|
|
|
credits float energy-tax credit for elapsed whole local days
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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)
|
|
|
|
|
|
"""
|
2026-06-25 11:13:30 +02:00
|
|
|
|
from datetime import timedelta as _td, date as _date
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
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"))
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
# --- Interval length in days (window, not elapsed — kept for API compat) ---
|
2026-06-23 22:26:56 +02:00
|
|
|
|
total_seconds = (end_utc - start_utc).total_seconds()
|
|
|
|
|
|
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
# --- 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)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
fixed_dec = Decimal("0")
|
|
|
|
|
|
credits_dec = Decimal("0")
|
|
|
|
|
|
currency = "EUR"
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
if versions:
|
|
|
|
|
|
# Currency comes from the contract regardless of day count.
|
|
|
|
|
|
currency = versions[0].contract.currency
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
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 {}))
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
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)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
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))
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
}
|