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:
+34
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user