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

299 lines
13 KiB
Python
Raw Normal View History

2026-08-23 10:43:35 +02:00
"""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
2026-08-23 11:50:48 +02:00
def recompute_range(session: Session, start: datetime, end: datetime, *, commit: bool = True) -> int:
"""Recompute a thermal range.
The historical service entry point remains self-committing for the scheduler
and direct callers. HTTP callers pass ``commit=False`` so validation,
recomputation, response statistics, and the single commit share one
transaction owned by the route.
"""
2026-08-23 10:43:35 +02:00
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
2026-08-23 11:50:48 +02:00
if commit:
session.commit()
2026-08-23 10:43:35 +02:00
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)
2026-08-23 11:50:48 +02:00
fixed_breakdown: dict[str, Decimal] = {key: Decimal("0") for key in (
"heating_network", "metering", "delivery_set", "hot_water_network", "other"
)}
2026-08-23 10:43:35 +02:00
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"]
fraction = Decimal(str((segment_end - segment_start).total_seconds())) / local_day_seconds
2026-08-23 11:50:48 +02:00
for key in fixed_breakdown:
fixed_breakdown[key] += _decimal(values.get(key, "0")) / Decimal("365") * fraction
2026-08-23 10:43:35 +02:00
day += timedelta(days=1)
2026-08-23 11:50:48 +02:00
fixed = sum(fixed_breakdown.values(), Decimal("0"))
2026-08-23 10:43:35 +02:00
return {
"currency": good[0].currency if good else "EUR", "variable_cost": variable,
2026-08-23 11:50:48 +02:00
"fixed_cost": fixed, "fixed_breakdown": fixed_breakdown,
"total_cost": variable + fixed, "breakdown": breakdown,
2026-08-23 10:43:35 +02:00
"period_count": len(good), "degraded_count": len(rows) - len(good),
}