M8-T15: add thermal meter cost engine

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 567ddb9779
commit 489e5b596a
5 changed files with 909 additions and 16 deletions
+38 -14
View File
@@ -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: