M8-T15: add thermal meter cost engine
This commit is contained in:
+38
-14
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -38,6 +39,7 @@ from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SE
|
||||
from app.services.ha_discovery import publish_discovery, publish_states
|
||||
from app.services.tibber_prices import refresh_prices
|
||||
from app.services.energy_cost import compute_closed_periods
|
||||
from app.services.meter_cost import compute_closed_periods as compute_closed_meter_cost_periods
|
||||
from app.services.warmtelink_worker import warmtelink_worker_manager
|
||||
from app.services.timezone import local_tz
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
@@ -112,22 +114,44 @@ def _run_scheduled_energy_cost() -> None:
|
||||
does not crash the scheduler or affect the other background jobs.
|
||||
"""
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
compute_closed_periods(session)
|
||||
# After billing periods are computed, push fresh energy-cost state values
|
||||
# to MQTT/HA. publish_states is internally guarded by _should_publish
|
||||
# (MQTT disabled / not connected → no-op), so this never raises due to
|
||||
# unconfigured MQTT and does not block the billing job.
|
||||
|
||||
def run_scope(label: str, operation: Callable[[Session], None]) -> None:
|
||||
"""Run one best-effort scope in an isolated transaction/session."""
|
||||
session: Session | None = None
|
||||
try:
|
||||
from app.services.ha_discovery import publish_states
|
||||
publish_states(session)
|
||||
session = session_local()
|
||||
operation(session)
|
||||
except Exception:
|
||||
logger.exception("_run_scheduled_energy_cost: publish_states failed (non-fatal)")
|
||||
except Exception:
|
||||
logger.exception("_run_scheduled_energy_cost: unexpected error")
|
||||
finally:
|
||||
session.close()
|
||||
logger.exception("_run_scheduled_energy_cost: %s failed", label)
|
||||
if session is not None:
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception:
|
||||
# A failed cleanup must not replace the operation/factory
|
||||
# error or prevent the following independent scope.
|
||||
logger.exception("_run_scheduled_energy_cost: %s rollback failed", label)
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
# Sessions are intentionally isolated; close failures are
|
||||
# diagnostic only and must remain best-effort too.
|
||||
logger.exception("_run_scheduled_energy_cost: %s close failed", label)
|
||||
|
||||
# Electricity, thermal and HA publishing must not share failed transaction
|
||||
# state or accidentally commit each other's partially-flushed changes.
|
||||
run_scope("electricity computation", compute_closed_periods)
|
||||
run_scope("thermal computation", compute_closed_meter_cost_periods)
|
||||
|
||||
def publish(session: Session) -> None:
|
||||
# publish_states is internally guarded by _should_publish (MQTT
|
||||
# disabled / disconnected -> no-op), but gets a clean Session anyway.
|
||||
from app.services.ha_discovery import publish_states
|
||||
|
||||
publish_states(session)
|
||||
|
||||
run_scope("publish_states (non-fatal)", publish)
|
||||
|
||||
|
||||
def _run_scheduled_ha_state_publish() -> None:
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""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),
|
||||
}
|
||||
Reference in New Issue
Block a user