M6-T07: add metering billing engine + period job + summary + recompute

- app/services/energy_cost.py: register_at (register values at a UTC boundary),
  compute_period (register deltas x active-version strategy, snapshot price +
  contract_version_id, immutable unless overwrite), compute_closed_periods
  (bounded lookback), recompute_range (explicit overwrite), summarize
  (sum net + standing fees month/30 - heffingskorting year/365).
- Missing tibber price -> skip; missing reading -> degraded (zeroed amounts,
  null version), consistently on both create and recompute paths.
- main.py: 1-minute energy-cost job (existing jobs/MQTT untouched). Tests added.
This commit is contained in:
2026-06-23 22:26:56 +02:00
parent ef48032adb
commit d35ac7d752
4 changed files with 1801 additions and 1 deletions
+34
View File
@@ -33,6 +33,7 @@ from app.services.public_ip import check_public_ipv4_and_notify
from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
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 scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
logger = logging.getLogger(__name__)
@@ -92,6 +93,28 @@ def _run_scheduled_tibber_refresh() -> None:
session.close()
def _run_scheduled_energy_cost() -> None:
"""Scheduled job: compute billing records for all uncalculated closed 15-minute periods.
Runs every minute so that a new period is picked up within 1 minute of
closing. The job is a no-op when:
- No active energy contract with a version covering the period exists.
- DSMR data has not yet arrived for the period boundaries.
- The period's billing record already exists and is not degraded.
Any unexpected exceptions are caught and logged so that a single failure
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)
except Exception:
logger.exception("_run_scheduled_energy_cost: unexpected error")
finally:
session.close()
def _run_scheduled_ha_state_publish() -> None:
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
@@ -188,6 +211,17 @@ async def lifespan(_: FastAPI):
max_instances=1,
coalesce=True,
)
# Energy cost billing: compute uncalculated closed 15-minute periods every minute.
# The job is a no-op when no active contract or DSMR data is present, so it is
# safe to register unconditionally.
scheduler.add_job(
_run_scheduled_energy_cost,
trigger=IntervalTrigger(minutes=1),
id="energy-cost",
replace_existing=True,
max_instances=1,
coalesce=True,
)
scheduler.start()
# MQTT: connect using DB-merged runtime settings so broker configured via UI