289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""Thermal (WarmteLink) 15-minute cost ledger.
|
|||
|
|
|
||
|
|
This module deliberately does not share the electricity ledger: thermal has
|
||
|
|
two independently-bound cumulative domains and Decimal database columns.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
from datetime import UTC, date, datetime, time, timedelta
|
||
|
|
from decimal import Decimal
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app.models.energy import Meter, MeterCostPeriod
|
||
|
|
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel, WarmteLinkReading
|
||
|
|
from app.services.contracts import active_contract_version_at, active_contract_versions
|
||
|
|
from app.services.energy_cost import floor_to_quarter
|
||
|
|
from app.services import timezone as timezone_service
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
_PERIOD = timedelta(minutes=15)
|
||
|
|
_FRESHNESS = timedelta(seconds=120)
|
||
|
|
_LIMITS = {"heating": Decimal("0.1"), "hot_water": Decimal("1")}
|
||
|
|
_ACCEPTED_QUALITIES = {"valid", "unverifiable"}
|
||
|
|
_SETTLEMENT_TIME = time(1, 5)
|
||
|
|
|
||
|
|
|
||
|
|
def _utc(value: datetime) -> datetime:
|
||
|
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||
|
|
|
||
|
|
|
||
|
|
def _decimal(value: Any) -> Decimal:
|
||
|
|
return value if isinstance(value, Decimal) else Decimal(str(value))
|
||
|
|
|
||
|
|
|
||
|
|
def _existing(session: Session, commodity: str, start: datetime) -> MeterCostPeriod | None:
|
||
|
|
return session.execute(
|
||
|
|
select(MeterCostPeriod).where(
|
||
|
|
MeterCostPeriod.commodity == commodity, MeterCostPeriod.period_start == start
|
||
|
|
)
|
||
|
|
).scalar_one_or_none()
|
||
|
|
|
||
|
|
|
||
|
|
def _meter_at(session: Session, commodity: str, instant: datetime) -> Meter | None:
|
||
|
|
meters = session.execute(
|
||
|
|
select(Meter).where(
|
||
|
|
Meter.commodity == commodity,
|
||
|
|
Meter.started_at <= instant,
|
||
|
|
(Meter.ended_at.is_(None)) | (Meter.ended_at > instant),
|
||
|
|
)
|
||
|
|
).scalars().all()
|
||
|
|
return meters[0] if len(meters) == 1 else None
|
||
|
|
|
||
|
|
|
||
|
|
def _binding_at(
|
||
|
|
session: Session, meter: Meter, instant: datetime
|
||
|
|
) -> tuple[MeterSourceBinding, MeterSourceChannel] | None:
|
||
|
|
rows = session.execute(
|
||
|
|
select(MeterSourceBinding, MeterSourceChannel)
|
||
|
|
.join(MeterSourceChannel, MeterSourceChannel.id == MeterSourceBinding.channel_id)
|
||
|
|
.join(MeterSource, MeterSource.id == MeterSourceChannel.source_id)
|
||
|
|
.where(
|
||
|
|
MeterSourceBinding.meter_id == meter.id,
|
||
|
|
MeterSourceBinding.started_at <= instant,
|
||
|
|
(MeterSourceBinding.ended_at.is_(None)) | (MeterSourceBinding.ended_at > instant),
|
||
|
|
MeterSource.kind == "warmtelink_serial",
|
||
|
|
)
|
||
|
|
).all()
|
||
|
|
return rows[0] if len(rows) == 1 else None
|
||
|
|
|
||
|
|
|
||
|
|
def _reading_at(
|
||
|
|
session: Session,
|
||
|
|
channel: MeterSourceChannel,
|
||
|
|
meter: Meter,
|
||
|
|
binding: MeterSourceBinding,
|
||
|
|
target: datetime,
|
||
|
|
) -> WarmteLinkReading | None:
|
||
|
|
"""Choose the nearest accepted reading inside this cumulative domain.
|
||
|
|
|
||
|
|
Freshness alone is insufficient: a frame immediately before a meter or
|
||
|
|
source hand-off belongs to a different cumulative register and must never
|
||
|
|
be used as the other side of a delta.
|
||
|
|
"""
|
||
|
|
window_start, window_end = target - _FRESHNESS, target + _FRESHNESS
|
||
|
|
rows = session.execute(
|
||
|
|
select(WarmteLinkReading)
|
||
|
|
.where(
|
||
|
|
WarmteLinkReading.channel_id == channel.id,
|
||
|
|
WarmteLinkReading.recorded_at >= window_start,
|
||
|
|
WarmteLinkReading.recorded_at <= window_end,
|
||
|
|
)
|
||
|
|
).scalars().all()
|
||
|
|
domain_start = max(_utc(meter.started_at), _utc(binding.started_at))
|
||
|
|
domain_ends = (meter.ended_at, binding.ended_at)
|
||
|
|
domain_end = min((_utc(value) for value in domain_ends if value is not None), default=None)
|
||
|
|
accepted = [
|
||
|
|
row for row in rows
|
||
|
|
if row.quality in _ACCEPTED_QUALITIES
|
||
|
|
and _utc(row.recorded_at) >= domain_start
|
||
|
|
and (domain_end is None or _utc(row.recorded_at) < domain_end)
|
||
|
|
]
|
||
|
|
if not accepted:
|
||
|
|
return None
|
||
|
|
return min(
|
||
|
|
accepted,
|
||
|
|
key=lambda row: (abs((_utc(row.recorded_at) - target).total_seconds()), _utc(row.recorded_at)),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _degrade(
|
||
|
|
session: Session,
|
||
|
|
commodity: str,
|
||
|
|
start: datetime,
|
||
|
|
end: datetime,
|
||
|
|
existing: MeterCostPeriod | None,
|
||
|
|
reason: str,
|
||
|
|
meter_id: int | None = None,
|
||
|
|
binding_id: int | None = None,
|
||
|
|
) -> None:
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
fields = dict(
|
||
|
|
period_end=end, meter_id=meter_id, source_binding_id=binding_id,
|
||
|
|
contract_version_id=None, quantity=Decimal("0"), cost=Decimal("0"), currency="EUR",
|
||
|
|
cost_breakdown={}, pricing_snapshot={}, quality="invalid", degraded=True,
|
||
|
|
degraded_reason=reason, updated_at=now,
|
||
|
|
)
|
||
|
|
if existing is None:
|
||
|
|
session.add(MeterCostPeriod(commodity=commodity, period_start=start, created_at=now, **fields))
|
||
|
|
else:
|
||
|
|
for key, value in fields.items():
|
||
|
|
setattr(existing, key, value)
|
||
|
|
|
||
|
|
|
||
|
|
def compute_period(
|
||
|
|
session: Session, commodity: str, period_start: datetime, *, overwrite: bool = False
|
||
|
|
) -> bool:
|
||
|
|
"""Compute one closed thermal period, recording every unsafe input as degraded."""
|
||
|
|
if commodity not in _LIMITS:
|
||
|
|
raise ValueError("commodity must be heating or hot_water")
|
||
|
|
start = floor_to_quarter(_utc(period_start))
|
||
|
|
end = start + _PERIOD
|
||
|
|
existing = _existing(session, commodity, start)
|
||
|
|
if existing is not None and not existing.degraded and not overwrite:
|
||
|
|
return False
|
||
|
|
|
||
|
|
meter0, meter1 = _meter_at(session, commodity, start), _meter_at(session, commodity, end)
|
||
|
|
if meter0 is None:
|
||
|
|
_degrade(session, commodity, start, end, existing, "missing_or_ambiguous_meter")
|
||
|
|
return True
|
||
|
|
if meter1 is None or meter1.id != meter0.id:
|
||
|
|
_degrade(session, commodity, start, end, existing, "cross_meter_epoch", meter0.id)
|
||
|
|
return True
|
||
|
|
bound0, bound1 = _binding_at(session, meter0, start), _binding_at(session, meter1, end)
|
||
|
|
if bound0 is None or bound1 is None:
|
||
|
|
_degrade(session, commodity, start, end, existing, "missing_or_ambiguous_binding", meter0.id)
|
||
|
|
return True
|
||
|
|
binding, channel = bound0
|
||
|
|
if bound1[0].id != binding.id or bound1[1].id != channel.id:
|
||
|
|
_degrade(session, commodity, start, end, existing, "cross_source_binding", meter0.id, binding.id)
|
||
|
|
return True
|
||
|
|
first = _reading_at(session, channel, meter0, binding, start)
|
||
|
|
last = _reading_at(session, channel, meter0, binding, end)
|
||
|
|
if first is None or last is None:
|
||
|
|
_degrade(session, commodity, start, end, existing, "missing_stale_or_invalid_reading", meter0.id, binding.id)
|
||
|
|
return True
|
||
|
|
delta = _decimal(last.value) - _decimal(first.value)
|
||
|
|
if delta < 0:
|
||
|
|
_degrade(session, commodity, start, end, existing, "negative_delta", meter0.id, binding.id)
|
||
|
|
return True
|
||
|
|
if delta > _LIMITS[commodity]:
|
||
|
|
_degrade(session, commodity, start, end, existing, "delta_limit_exceeded", meter0.id, binding.id)
|
||
|
|
return True
|
||
|
|
version = active_contract_version_at(session, start, scope="thermal")
|
||
|
|
if version is None:
|
||
|
|
_degrade(session, commodity, start, end, existing, "missing_contract", meter0.id, binding.id)
|
||
|
|
return True
|
||
|
|
|
||
|
|
values = {key: _decimal(value) for key, value in version.values["variable"].items()}
|
||
|
|
if commodity == "heating":
|
||
|
|
breakdown = {"heating": delta * values["heating"]}
|
||
|
|
else:
|
||
|
|
breakdown = {
|
||
|
|
key: delta * values[key]
|
||
|
|
for key in ("hot_water_heating", "hot_water", "hot_water_tax")
|
||
|
|
}
|
||
|
|
cost = sum(breakdown.values(), Decimal("0"))
|
||
|
|
now = datetime.now(UTC)
|
||
|
|
fields = dict(
|
||
|
|
period_end=end, meter_id=meter0.id, source_binding_id=binding.id,
|
||
|
|
contract_version_id=version.id, quantity=delta, cost=cost, currency=version.contract.currency,
|
||
|
|
cost_breakdown=breakdown, pricing_snapshot=dict(version.values),
|
||
|
|
quality="valid" if first.quality == last.quality == "valid" else "unverifiable",
|
||
|
|
degraded=False, degraded_reason=None, updated_at=now,
|
||
|
|
)
|
||
|
|
if existing is None:
|
||
|
|
session.add(MeterCostPeriod(commodity=commodity, period_start=start, created_at=now, **fields))
|
||
|
|
else:
|
||
|
|
for key, value in fields.items():
|
||
|
|
setattr(existing, key, value)
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def compute_closed_periods(session: Session, *, now: datetime | None = None) -> int:
|
||
|
|
"""Retry incomplete thermal rows and fill recent closed periods without touching good rows."""
|
||
|
|
now = _utc(now or datetime.now(UTC))
|
||
|
|
first = floor_to_quarter(now - timedelta(days=7))
|
||
|
|
written = 0
|
||
|
|
cursor = first
|
||
|
|
while cursor + _PERIOD <= now:
|
||
|
|
for commodity in ("heating", "hot_water"):
|
||
|
|
if compute_period(session, commodity, cursor):
|
||
|
|
written += 1
|
||
|
|
cursor += _PERIOD
|
||
|
|
session.commit()
|
||
|
|
return written
|
||
|
|
|
||
|
|
|
||
|
|
def recompute_range(session: Session, start: datetime, end: datetime) -> int:
|
||
|
|
cursor, end = floor_to_quarter(_utc(start)), _utc(end)
|
||
|
|
now, written = datetime.now(UTC), 0
|
||
|
|
while cursor < end:
|
||
|
|
if cursor + _PERIOD <= now:
|
||
|
|
for commodity in ("heating", "hot_water"):
|
||
|
|
if compute_period(session, commodity, cursor, overwrite=True):
|
||
|
|
written += 1
|
||
|
|
cursor += _PERIOD
|
||
|
|
session.commit()
|
||
|
|
return written
|
||
|
|
|
||
|
|
|
||
|
|
def _settled_end_date(now: datetime) -> date:
|
||
|
|
local_now = timezone_service.to_local(now)
|
||
|
|
return local_now.date() if local_now.timetz().replace(tzinfo=None) >= _SETTLEMENT_TIME else local_now.date() - timedelta(days=1)
|
||
|
|
|
||
|
|
|
||
|
|
def summarize(session: Session, start: datetime, end: datetime, *, now: datetime | None = None) -> dict[str, Any]:
|
||
|
|
"""Return thermal variable/fixed totals; standing is charged once per contract/day."""
|
||
|
|
start, end = _utc(start), _utc(end)
|
||
|
|
rows = session.execute(select(MeterCostPeriod).where(
|
||
|
|
MeterCostPeriod.period_start >= start, MeterCostPeriod.period_start < end
|
||
|
|
)).scalars().all()
|
||
|
|
good = [row for row in rows if not row.degraded]
|
||
|
|
variable = sum((_decimal(row.cost) for row in good), Decimal("0"))
|
||
|
|
breakdown: dict[str, Decimal] = {key: Decimal("0") for key in (
|
||
|
|
"heating", "hot_water_heating", "hot_water", "hot_water_tax")}
|
||
|
|
for row in good:
|
||
|
|
for key, value in row.cost_breakdown.items():
|
||
|
|
breakdown[key] = breakdown.get(key, Decimal("0")) + _decimal(value)
|
||
|
|
|
||
|
|
# A summary is half-open. ``end`` at local midnight has no overlap with
|
||
|
|
# that next local date, and an empty/reversed range owns no standing day.
|
||
|
|
final_day = timezone_service.local_date(end - timedelta(microseconds=1))
|
||
|
|
final_day = min(final_day, _settled_end_date(now or datetime.now(UTC)))
|
||
|
|
day = timezone_service.local_date(start)
|
||
|
|
fixed = Decimal("0")
|
||
|
|
versions = active_contract_versions(session, scope="thermal")
|
||
|
|
while start < end and day <= final_day:
|
||
|
|
day_start = datetime.combine(day, time.min, tzinfo=timezone_service.local_tz()).astimezone(UTC)
|
||
|
|
next_day_start = datetime.combine(
|
||
|
|
day + timedelta(days=1), time.min, tzinfo=timezone_service.local_tz()
|
||
|
|
).astimezone(UTC)
|
||
|
|
local_day_seconds = Decimal(str((next_day_start - day_start).total_seconds()))
|
||
|
|
# A rate revision part-way through a local date is attributable only
|
||
|
|
# to its effective interval. This preserves one contract-level daily
|
||
|
|
# charge while correctly handling first-version and intra-day changes.
|
||
|
|
for version in versions:
|
||
|
|
segment_start = max(day_start, _utc(version.effective_from))
|
||
|
|
version_end = _utc(version.effective_to) if version.effective_to is not None else next_day_start
|
||
|
|
segment_end = min(next_day_start, version_end)
|
||
|
|
if segment_start >= segment_end:
|
||
|
|
continue
|
||
|
|
values = version.values["standing"]
|
||
|
|
annual = sum((_decimal(values.get(key, "0")) for key in (
|
||
|
|
"heating_network", "metering", "delivery_set", "hot_water_network", "other"
|
||
|
|
)), Decimal("0"))
|
||
|
|
fraction = Decimal(str((segment_end - segment_start).total_seconds())) / local_day_seconds
|
||
|
|
fixed += annual / Decimal("365") * fraction
|
||
|
|
day += timedelta(days=1)
|
||
|
|
return {
|
||
|
|
"currency": good[0].currency if good else "EUR", "variable_cost": variable,
|
||
|
|
"fixed_cost": fixed, "total_cost": variable + fixed, "breakdown": breakdown,
|
||
|
|
"period_count": len(good), "degraded_count": len(rows) - len(good),
|
||
|
|
}
|