M8-T15: add thermal meter cost engine
This commit is contained in:
+35
-11
@@ -2,6 +2,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse
|
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.ha_discovery import publish_discovery, publish_states
|
||||||
from app.services.tibber_prices import refresh_prices
|
from app.services.tibber_prices import refresh_prices
|
||||||
from app.services.energy_cost import compute_closed_periods
|
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.warmtelink_worker import warmtelink_worker_manager
|
||||||
from app.services.timezone import local_tz
|
from app.services.timezone import local_tz
|
||||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
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.
|
does not crash the scheduler or affect the other background jobs.
|
||||||
"""
|
"""
|
||||||
session_local = get_session_local()
|
session_local = get_session_local()
|
||||||
session: Session = session_local()
|
|
||||||
|
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:
|
try:
|
||||||
compute_closed_periods(session)
|
session = session_local()
|
||||||
# After billing periods are computed, push fresh energy-cost state values
|
operation(session)
|
||||||
# to MQTT/HA. publish_states is internally guarded by _should_publish
|
except Exception:
|
||||||
# (MQTT disabled / not connected → no-op), so this never raises due to
|
logger.exception("_run_scheduled_energy_cost: %s failed", label)
|
||||||
# unconfigured MQTT and does not block the billing job.
|
if session is not None:
|
||||||
try:
|
try:
|
||||||
from app.services.ha_discovery import publish_states
|
session.rollback()
|
||||||
publish_states(session)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("_run_scheduled_energy_cost: publish_states failed (non-fatal)")
|
# A failed cleanup must not replace the operation/factory
|
||||||
except Exception:
|
# error or prevent the following independent scope.
|
||||||
logger.exception("_run_scheduled_energy_cost: unexpected error")
|
logger.exception("_run_scheduled_energy_cost: %s rollback failed", label)
|
||||||
finally:
|
finally:
|
||||||
|
if session is not None:
|
||||||
|
try:
|
||||||
session.close()
|
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:
|
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),
|
||||||
|
}
|
||||||
@@ -850,7 +850,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### M8-T15 — Thermal 15 分钟成本引擎与调度 [structural]
|
### M8-T15 — Thermal 15 分钟成本引擎与调度 [structural]
|
||||||
|
|
||||||
- **Status**: `todo`
|
- **Status**: `done`
|
||||||
- **Depends**: M8-T14
|
- **Depends**: M8-T14
|
||||||
- **Context**: 基于两个独立累计 Meter 生成可审计 variable 账本,并在 summary 层只计一次固定费。
|
- **Context**: 基于两个独立累计 Meter 生成可审计 variable 账本,并在 summary 层只计一次固定费。
|
||||||
|
|
||||||
|
|||||||
+180
-1
@@ -32,7 +32,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from sqlalchemy import create_engine, select
|
from sqlalchemy import create_engine, event, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.integrations.pricing.strategies import PeriodDeltas # noqa: F401
|
from app.integrations.pricing.strategies import PeriodDeltas # noqa: F401
|
||||||
@@ -57,6 +57,28 @@ from app.services.energy_cost import (
|
|||||||
from app.services import timezone as tz_module
|
from app.services import timezone as tz_module
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_isolates_electricity_and_thermal_cost_scopes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""A thermal/electricity failure must not suppress the other ledger or HA publish."""
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
class DummySession:
|
||||||
|
def rollback(self) -> None:
|
||||||
|
calls.append("rollback")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
calls.append("close")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_session_local", lambda: lambda: DummySession())
|
||||||
|
monkeypatch.setattr(main, "compute_closed_periods", lambda _session: (_ for _ in ()).throw(ValueError()))
|
||||||
|
monkeypatch.setattr(main, "compute_closed_meter_cost_periods", lambda _session: calls.append("thermal"))
|
||||||
|
from app.services import ha_discovery
|
||||||
|
monkeypatch.setattr(ha_discovery, "publish_states", lambda _session: calls.append("publish"))
|
||||||
|
main._run_scheduled_energy_cost()
|
||||||
|
assert calls == ["rollback", "close", "thermal", "close", "publish", "close"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -82,6 +104,163 @@ def energy_db(tmp_path: Path):
|
|||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failing_scope", ["electricity", "thermal"])
|
||||||
|
def test_scheduler_uses_clean_sessions_after_real_flush_failure(
|
||||||
|
energy_db: Session, monkeypatch: pytest.MonkeyPatch, failing_scope: str
|
||||||
|
) -> None:
|
||||||
|
"""A DB failed-transaction in either ledger cannot poison the next scope or HA."""
|
||||||
|
from app import main
|
||||||
|
from app.services import ha_discovery
|
||||||
|
|
||||||
|
assert energy_db.bind is not None
|
||||||
|
sessions: list[Session] = []
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def factory() -> Session:
|
||||||
|
session = Session(energy_db.bind)
|
||||||
|
sessions.append(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def flush_failure(session: Session) -> None:
|
||||||
|
session.add(EnergyContract())
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
def successful(name: str):
|
||||||
|
def operation(session: Session) -> None:
|
||||||
|
session.execute(select(EnergyContract)).all()
|
||||||
|
calls.append(name)
|
||||||
|
|
||||||
|
return operation
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_session_local", lambda: factory)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "compute_closed_periods",
|
||||||
|
flush_failure if failing_scope == "electricity" else successful("electricity"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main, "compute_closed_meter_cost_periods",
|
||||||
|
flush_failure if failing_scope == "thermal" else successful("thermal"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(ha_discovery, "publish_states", lambda session: successful("ha")(session))
|
||||||
|
|
||||||
|
main._run_scheduled_energy_cost()
|
||||||
|
|
||||||
|
assert calls == (["thermal", "ha"] if failing_scope == "electricity" else ["electricity", "ha"])
|
||||||
|
assert len({id(session) for session in sessions}) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failing_scope", ["electricity", "thermal"])
|
||||||
|
def test_scheduler_isolates_real_commit_failure_and_rolls_back_write(
|
||||||
|
energy_db: Session, monkeypatch: pytest.MonkeyPatch, failing_scope: str
|
||||||
|
) -> None:
|
||||||
|
"""A commit exception leaves no partial write and the later scopes still run."""
|
||||||
|
from app import main
|
||||||
|
from app.services import ha_discovery
|
||||||
|
|
||||||
|
assert energy_db.bind is not None
|
||||||
|
sessions: list[Session] = []
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def factory() -> Session:
|
||||||
|
session = Session(energy_db.bind)
|
||||||
|
sessions.append(session)
|
||||||
|
if len(sessions) == (1 if failing_scope == "electricity" else 2):
|
||||||
|
event.listen(session, "before_commit", lambda _session: (_ for _ in ()).throw(RuntimeError("commit")))
|
||||||
|
return session
|
||||||
|
|
||||||
|
def write(name: str):
|
||||||
|
def operation(session: Session) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
session.add(EnergyContract(
|
||||||
|
name=name, kind="manual", scope="electricity", active=False, currency="EUR",
|
||||||
|
created_at=now, updated_at=now,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
calls.append(name)
|
||||||
|
|
||||||
|
return operation
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_session_local", lambda: factory)
|
||||||
|
monkeypatch.setattr(main, "compute_closed_periods", write("electricity"))
|
||||||
|
monkeypatch.setattr(main, "compute_closed_meter_cost_periods", write("thermal"))
|
||||||
|
monkeypatch.setattr(ha_discovery, "publish_states", lambda _session: calls.append("ha"))
|
||||||
|
main._run_scheduled_energy_cost()
|
||||||
|
|
||||||
|
assert calls == (["thermal", "ha"] if failing_scope == "electricity" else ["electricity", "ha"])
|
||||||
|
persisted = Session(energy_db.bind)
|
||||||
|
try:
|
||||||
|
assert persisted.scalars(select(EnergyContract.name)).all() == (
|
||||||
|
["thermal"] if failing_scope == "electricity" else ["electricity"]
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
persisted.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure", ["factory", "rollback", "close"])
|
||||||
|
def test_scheduler_lifecycle_failures_are_non_fatal_and_keep_later_scopes_independent(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, failure: str
|
||||||
|
) -> None:
|
||||||
|
"""Factory and cleanup errors are logged locally, never allowed to escape."""
|
||||||
|
from app import main
|
||||||
|
from app.services import ha_discovery
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
created = 0
|
||||||
|
|
||||||
|
class DummySession:
|
||||||
|
def rollback(self) -> None:
|
||||||
|
calls.append("rollback")
|
||||||
|
if failure == "rollback":
|
||||||
|
raise RuntimeError("rollback")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
calls.append("close")
|
||||||
|
if failure == "close":
|
||||||
|
raise RuntimeError("close")
|
||||||
|
|
||||||
|
def factory() -> DummySession:
|
||||||
|
nonlocal created
|
||||||
|
created += 1
|
||||||
|
if failure == "factory" and created == 1:
|
||||||
|
raise RuntimeError("factory")
|
||||||
|
return DummySession()
|
||||||
|
|
||||||
|
def electricity(_session: DummySession) -> None:
|
||||||
|
if failure in {"rollback", "close"}:
|
||||||
|
raise RuntimeError("operation")
|
||||||
|
calls.append("electricity")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_session_local", lambda: factory)
|
||||||
|
monkeypatch.setattr(main, "compute_closed_periods", electricity)
|
||||||
|
monkeypatch.setattr(main, "compute_closed_meter_cost_periods", lambda _session: calls.append("thermal"))
|
||||||
|
monkeypatch.setattr(ha_discovery, "publish_states", lambda _session: calls.append("ha"))
|
||||||
|
main._run_scheduled_energy_cost()
|
||||||
|
|
||||||
|
assert "thermal" in calls and "ha" in calls
|
||||||
|
assert created == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_swallows_ha_failure_after_two_successful_scopes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from app import main
|
||||||
|
from app.services import ha_discovery
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
class DummySession:
|
||||||
|
def rollback(self) -> None:
|
||||||
|
calls.append("rollback")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
calls.append("close")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "get_session_local", lambda: lambda: DummySession())
|
||||||
|
monkeypatch.setattr(main, "compute_closed_periods", lambda _session: calls.append("electricity"))
|
||||||
|
monkeypatch.setattr(main, "compute_closed_meter_cost_periods", lambda _session: calls.append("thermal"))
|
||||||
|
monkeypatch.setattr(ha_discovery, "publish_states", lambda _session: (_ for _ in ()).throw(RuntimeError("ha")))
|
||||||
|
main._run_scheduled_energy_cost()
|
||||||
|
assert calls == ["electricity", "close", "thermal", "close", "rollback", "close"]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Data-builder helpers
|
# Data-builder helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
"""M8-T15 tests for the binding-aware thermal ledger."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.energy import EnergyContract, EnergyContractVersion, Meter, MeterCostPeriod
|
||||||
|
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel, WarmteLinkReading
|
||||||
|
from app.services import timezone as timezone_service
|
||||||
|
from app.services.meter_cost import compute_closed_periods, compute_period, recompute_range, summarize
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db(tmp_path: Path) -> Session:
|
||||||
|
url = f"sqlite:///{tmp_path / 'meter_cost.db'}"
|
||||||
|
cfg = Config("alembic_app.ini")
|
||||||
|
cfg.set_main_option("sqlalchemy.url", url)
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
engine = create_engine(url)
|
||||||
|
session = Session(engine)
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
T0 = datetime(2026, 6, 23, 10, tzinfo=UTC)
|
||||||
|
|
||||||
|
VALUES = {
|
||||||
|
"variable": {"heating": "20.0", "hot_water_heating": "4.0", "hot_water": "2.0", "hot_water_tax": "1.0"},
|
||||||
|
"standing": {"heating_network": "365", "metering": "73", "delivery_set": "0", "hot_water_network": "0", "other": "0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _contract(session: Session, *, values: dict = VALUES, start: datetime = T0 - timedelta(days=1)) -> EnergyContractVersion:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
contract = EnergyContract(name="thermal", kind="district_heating", scope="thermal", active=True,
|
||||||
|
currency="EUR", created_at=now, updated_at=now)
|
||||||
|
session.add(contract)
|
||||||
|
session.flush()
|
||||||
|
version = EnergyContractVersion(contract_id=contract.id, effective_from=start, values=values, created_at=now)
|
||||||
|
session.add(version)
|
||||||
|
session.flush()
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _domain(session: Session, commodity: str, *, start: datetime = T0 - timedelta(days=1), end: datetime | None = None):
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
meter = Meter(label=commodity, commodity=commodity, started_at=start, ended_at=end,
|
||||||
|
reason="initial", created_at=now)
|
||||||
|
source = MeterSource(name=f"{commodity}-source", kind="warmtelink_serial", enabled=True, config={},
|
||||||
|
status="online", created_at=now, updated_at=now)
|
||||||
|
session.add_all((meter, source))
|
||||||
|
session.flush()
|
||||||
|
unit = "GJ" if commodity == "heating" else "m3"
|
||||||
|
channel = MeterSourceChannel(source_id=source.id, channel_key=commodity, label=commodity,
|
||||||
|
suggested_commodity=commodity, unit=unit, latest_quality="valid",
|
||||||
|
created_at=now, updated_at=now)
|
||||||
|
session.add(channel)
|
||||||
|
session.flush()
|
||||||
|
binding = MeterSourceBinding(meter_id=meter.id, channel_id=channel.id, started_at=start, ended_at=end,
|
||||||
|
created_at=now, updated_at=now)
|
||||||
|
session.add(binding)
|
||||||
|
session.flush()
|
||||||
|
return meter, channel, binding
|
||||||
|
|
||||||
|
|
||||||
|
def _reading(session: Session, channel: MeterSourceChannel, at: datetime, value: str, quality: str = "valid") -> None:
|
||||||
|
session.add(WarmteLinkReading(channel_id=channel.id, recorded_at=at, received_at=at, value=Decimal(value),
|
||||||
|
unit=channel.unit, quality=quality, equipment_fingerprint="test"))
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_good(session: Session) -> None:
|
||||||
|
_contract(session)
|
||||||
|
_, heating, _ = _domain(session, "heating")
|
||||||
|
_, water, _ = _domain(session, "hot_water")
|
||||||
|
_reading(session, heating, T0, "10.000")
|
||||||
|
_reading(session, heating, T0 + timedelta(minutes=15), "10.050")
|
||||||
|
_reading(session, water, T0, "20.000")
|
||||||
|
_reading(session, water, T0 + timedelta(minutes=15), "20.200")
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(session: Session, commodity: str) -> MeterCostPeriod:
|
||||||
|
return session.execute(select(MeterCostPeriod).where(MeterCostPeriod.commodity == commodity)).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def _row_at(session: Session, commodity: str, start: datetime) -> MeterCostPeriod:
|
||||||
|
return session.execute(select(MeterCostPeriod).where(
|
||||||
|
MeterCostPeriod.commodity == commodity, MeterCostPeriod.period_start == start
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def _version(
|
||||||
|
session: Session, contract: EnergyContract, *, start: datetime, values: dict
|
||||||
|
) -> EnergyContractVersion:
|
||||||
|
version = EnergyContractVersion(
|
||||||
|
contract_id=contract.id, effective_from=start, values=values, created_at=datetime.now(UTC)
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.flush()
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_commodity_decimal_breakdown_and_scheduler_idempotency(db: Session) -> None:
|
||||||
|
_setup_good(db)
|
||||||
|
assert compute_period(db, "heating", T0)
|
||||||
|
assert compute_period(db, "hot_water", T0)
|
||||||
|
db.commit()
|
||||||
|
heating, water = _row(db, "heating"), _row(db, "hot_water")
|
||||||
|
assert heating.quantity == Decimal("0.050000")
|
||||||
|
assert heating.cost == Decimal("1.000000000")
|
||||||
|
assert water.cost == Decimal("1.400000000")
|
||||||
|
assert {key: Decimal(value) for key, value in water.cost_breakdown.items()} == {
|
||||||
|
"hot_water_heating": Decimal("0.8"), "hot_water": Decimal("0.4"), "hot_water_tax": Decimal("0.2")
|
||||||
|
}
|
||||||
|
assert not compute_period(db, "heating", T0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("commodity,start,end,reason", [
|
||||||
|
("heating", "10", "9.99", "negative_delta"),
|
||||||
|
("heating", "10", "10.101", "delta_limit_exceeded"),
|
||||||
|
("hot_water", "10", "11.001", "delta_limit_exceeded"),
|
||||||
|
])
|
||||||
|
def test_bad_deltas_are_degraded(db: Session, commodity: str, start: str, end: str, reason: str) -> None:
|
||||||
|
_contract(db)
|
||||||
|
_, channel, _ = _domain(db, commodity)
|
||||||
|
_reading(db, channel, T0, start)
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15), end)
|
||||||
|
db.commit()
|
||||||
|
compute_period(db, commodity, T0)
|
||||||
|
db.commit()
|
||||||
|
row = _row(db, commodity)
|
||||||
|
assert row.degraded and row.degraded_reason == reason and row.contract_version_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_freshness_quality_binding_and_contract_fail_closed(db: Session) -> None:
|
||||||
|
_contract(db)
|
||||||
|
meter, channel, binding = _domain(db, "heating")
|
||||||
|
_reading(db, channel, T0 - timedelta(seconds=121), "1")
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15), "1.01")
|
||||||
|
db.commit()
|
||||||
|
compute_period(db, "heating", T0)
|
||||||
|
assert _row(db, "heating").degraded_reason == "missing_stale_or_invalid_reading"
|
||||||
|
# Recompute sees invalid quality independently at the end boundary.
|
||||||
|
db.query(WarmteLinkReading).delete()
|
||||||
|
_reading(db, channel, T0, "1")
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15), "1.01", "invalid")
|
||||||
|
db.commit()
|
||||||
|
recompute_range(db, T0, T0 + timedelta(minutes=15))
|
||||||
|
assert _row(db, "heating").degraded_reason == "missing_stale_or_invalid_reading"
|
||||||
|
binding.ended_at = T0 + timedelta(minutes=15)
|
||||||
|
db.commit()
|
||||||
|
recompute_range(db, T0, T0 + timedelta(minutes=15))
|
||||||
|
assert _row(db, "heating").degraded_reason == "missing_or_ambiguous_binding"
|
||||||
|
assert meter.id is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("first_quality", "last_quality", "accepted", "period_quality"),
|
||||||
|
[
|
||||||
|
("valid", "valid", True, "valid"),
|
||||||
|
("valid", "unverifiable", True, "unverifiable"),
|
||||||
|
("unverifiable", "valid", True, "unverifiable"),
|
||||||
|
("unverifiable", "unverifiable", True, "unverifiable"),
|
||||||
|
("invalid", "valid", False, "invalid"),
|
||||||
|
("valid", "invalid", False, "invalid"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_accepted_reading_quality_is_preserved_without_promotion(
|
||||||
|
db: Session, first_quality: str, last_quality: str, accepted: bool, period_quality: str
|
||||||
|
) -> None:
|
||||||
|
_contract(db)
|
||||||
|
_, channel, _ = _domain(db, "heating")
|
||||||
|
_reading(db, channel, T0, "10", first_quality)
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15), "10.05", last_quality)
|
||||||
|
db.commit()
|
||||||
|
assert compute_period(db, "heating", T0)
|
||||||
|
db.commit()
|
||||||
|
row = _row(db, "heating")
|
||||||
|
assert row.degraded is not accepted
|
||||||
|
assert row.quality == period_quality
|
||||||
|
if not accepted:
|
||||||
|
assert row.degraded_reason == "missing_stale_or_invalid_reading"
|
||||||
|
|
||||||
|
|
||||||
|
def test_readings_must_be_inside_binding_and_meter_windows(db: Session) -> None:
|
||||||
|
_contract(db)
|
||||||
|
meter, channel, binding = _domain(db, "heating", start=T0)
|
||||||
|
_reading(db, channel, T0 - timedelta(seconds=60), "10")
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15), "10.05")
|
||||||
|
db.commit()
|
||||||
|
compute_period(db, "heating", T0)
|
||||||
|
assert _row(db, "heating").degraded_reason == "missing_stale_or_invalid_reading"
|
||||||
|
|
||||||
|
# A candidate after a closed binding is equally outside the cumulative domain.
|
||||||
|
binding.started_at = T0 - timedelta(days=1)
|
||||||
|
binding.ended_at = T0 + timedelta(minutes=15, seconds=30)
|
||||||
|
meter.started_at = T0 - timedelta(days=1)
|
||||||
|
db.query(WarmteLinkReading).delete()
|
||||||
|
_reading(db, channel, T0, "10")
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15, seconds=60), "10.05")
|
||||||
|
db.commit()
|
||||||
|
recompute_range(db, T0, T0 + timedelta(minutes=15))
|
||||||
|
assert _row(db, "heating").degraded_reason == "missing_stale_or_invalid_reading"
|
||||||
|
|
||||||
|
|
||||||
|
def test_freshness_is_independent_and_inclusive_with_nearest_candidate(db: Session) -> None:
|
||||||
|
_contract(db)
|
||||||
|
_, channel, _ = _domain(db, "heating")
|
||||||
|
_reading(db, channel, T0 - timedelta(seconds=120), "10")
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15, seconds=120), "10.05")
|
||||||
|
db.commit()
|
||||||
|
compute_period(db, "heating", T0)
|
||||||
|
db.commit()
|
||||||
|
row = _row(db, "heating")
|
||||||
|
assert not row.degraded and row.quantity == Decimal("0.050000")
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_scheduler_retries_degraded_but_not_normal(db: Session) -> None:
|
||||||
|
_contract(db)
|
||||||
|
_, channel, _ = _domain(db, "heating")
|
||||||
|
# The first closed scheduler pass records a degraded row. Supplying the
|
||||||
|
# missing boundary inputs later must let its next pass repair that row.
|
||||||
|
db.commit()
|
||||||
|
assert compute_closed_periods(db, now=T0 + timedelta(minutes=16)) > 0
|
||||||
|
assert _row_at(db, "heating", T0).degraded
|
||||||
|
_reading(db, channel, T0, "10")
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15), "10.05")
|
||||||
|
db.commit()
|
||||||
|
assert compute_closed_periods(db, now=T0 + timedelta(minutes=16)) > 0
|
||||||
|
normal = _row_at(db, "heating", T0)
|
||||||
|
assert not normal.degraded
|
||||||
|
frozen = (normal.cost, dict(normal.pricing_snapshot), normal.updated_at)
|
||||||
|
end_reading = db.execute(select(WarmteLinkReading).where(
|
||||||
|
WarmteLinkReading.channel_id == channel.id,
|
||||||
|
WarmteLinkReading.recorded_at == T0 + timedelta(minutes=15),
|
||||||
|
)).scalar_one()
|
||||||
|
end_reading.value = Decimal("10.090")
|
||||||
|
version = db.get(EnergyContractVersion, normal.contract_version_id)
|
||||||
|
assert version is not None
|
||||||
|
version.values = {**version.values, "variable": {**version.values["variable"], "heating": "99"}}
|
||||||
|
db.commit()
|
||||||
|
compute_closed_periods(db, now=T0 + timedelta(minutes=16))
|
||||||
|
db.expire_all()
|
||||||
|
unchanged = db.execute(select(MeterCostPeriod).where(MeterCostPeriod.id == normal.id)).scalar_one()
|
||||||
|
assert (unchanged.cost, unchanged.pricing_snapshot, unchanged.updated_at) == frozen
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_recompute_overwrites_and_source_switch_degrades(db: Session) -> None:
|
||||||
|
_setup_good(db)
|
||||||
|
compute_period(db, "heating", T0)
|
||||||
|
db.commit()
|
||||||
|
row = _row(db, "heating")
|
||||||
|
old_cost = row.cost
|
||||||
|
channel = db.get(MeterSourceChannel, row.source_binding.channel_id)
|
||||||
|
end_reading = db.execute(select(WarmteLinkReading).where(
|
||||||
|
WarmteLinkReading.channel_id == channel.id,
|
||||||
|
WarmteLinkReading.recorded_at == T0 + timedelta(minutes=15),
|
||||||
|
)).scalar_one()
|
||||||
|
end_reading.value = Decimal("10.080")
|
||||||
|
db.commit()
|
||||||
|
assert recompute_range(db, T0, T0 + timedelta(minutes=15)) == 2
|
||||||
|
assert _row(db, "heating").cost != old_cost
|
||||||
|
# A hand-off precisely at the right boundary cannot form a single domain.
|
||||||
|
binding = _row(db, "heating").source_binding
|
||||||
|
binding.ended_at = T0 + timedelta(minutes=15)
|
||||||
|
db.commit()
|
||||||
|
recompute_range(db, T0, T0 + timedelta(minutes=15))
|
||||||
|
assert _row(db, "heating").degraded_reason == "missing_or_ambiguous_binding"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_source_binding_handoff_never_crosses_cumulative_registers(db: Session) -> None:
|
||||||
|
_contract(db)
|
||||||
|
meter, old_channel, old_binding = _domain(db, "heating")
|
||||||
|
handoff = T0 + timedelta(minutes=15)
|
||||||
|
old_binding.ended_at = handoff
|
||||||
|
new_meter, new_channel, new_binding = _domain(db, "heating", start=handoff, end=handoff)
|
||||||
|
# Keep the same Meter: this is a source/channel/binding handoff, not a meter swap.
|
||||||
|
new_binding.meter_id = meter.id
|
||||||
|
new_binding.ended_at = None
|
||||||
|
assert new_meter.id != meter.id
|
||||||
|
_reading(db, old_channel, T0, "1000")
|
||||||
|
_reading(db, old_channel, handoff - timedelta(seconds=1), "1000.01")
|
||||||
|
_reading(db, new_channel, handoff, "7")
|
||||||
|
_reading(db, new_channel, handoff + timedelta(seconds=1), "7.01")
|
||||||
|
db.commit()
|
||||||
|
compute_period(db, "heating", T0)
|
||||||
|
assert _row(db, "heating").degraded_reason == "cross_source_binding"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_meter_epoch_handoff_never_crosses_cumulative_registers(db: Session) -> None:
|
||||||
|
_contract(db)
|
||||||
|
handoff = T0 + timedelta(minutes=15)
|
||||||
|
_, old_channel, _ = _domain(db, "heating", end=handoff)
|
||||||
|
_, new_channel, _ = _domain(db, "heating", start=handoff)
|
||||||
|
_reading(db, old_channel, T0, "1000")
|
||||||
|
_reading(db, old_channel, handoff - timedelta(seconds=1), "1000.01")
|
||||||
|
_reading(db, new_channel, handoff, "2")
|
||||||
|
_reading(db, new_channel, handoff + timedelta(seconds=1), "2.01")
|
||||||
|
db.commit()
|
||||||
|
compute_period(db, "heating", T0)
|
||||||
|
assert _row(db, "heating").degraded_reason == "cross_meter_epoch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_fixed_once_per_contract_day_and_dst(db: Session, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_setup_good(db)
|
||||||
|
compute_period(db, "heating", T0)
|
||||||
|
compute_period(db, "hot_water", T0)
|
||||||
|
db.commit()
|
||||||
|
monkeypatch.setattr(timezone_service, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
result = summarize(db, T0, T0 + timedelta(days=1), now=datetime(2026, 6, 24, 1, 6, tzinfo=UTC))
|
||||||
|
assert result["variable_cost"] == Decimal("2.400000000")
|
||||||
|
assert result["fixed_cost"] == Decimal("2.4") # two local days, each charged once not per commodity
|
||||||
|
# DST local day is charged once too, despite being 23 hours long.
|
||||||
|
db.execute(select(EnergyContractVersion)).scalar_one().effective_from = datetime(2026, 3, 1, tzinfo=UTC)
|
||||||
|
db.commit()
|
||||||
|
dst_start = datetime(2026, 3, 28, 23, tzinfo=UTC)
|
||||||
|
dst = summarize(db, dst_start, dst_start + timedelta(days=2), now=datetime(2026, 3, 31, tzinfo=UTC))
|
||||||
|
assert dst["fixed_cost"] == Decimal("3.6")
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_is_half_open_at_local_midnight_and_settlement(db: Session, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_contract(db)
|
||||||
|
monkeypatch.setattr(timezone_service, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
local = ZoneInfo("Europe/Amsterdam")
|
||||||
|
start = datetime(2026, 6, 24, 0, tzinfo=local).astimezone(UTC)
|
||||||
|
end = datetime(2026, 6, 25, 0, tzinfo=local).astimezone(UTC)
|
||||||
|
assert summarize(db, start, start, now=end + timedelta(hours=2))["fixed_cost"] == Decimal("0")
|
||||||
|
assert summarize(db, end, start, now=end + timedelta(hours=2))["fixed_cost"] == Decimal("0")
|
||||||
|
before = datetime(2026, 6, 24, 1, 4, 59, tzinfo=local).astimezone(UTC)
|
||||||
|
after = datetime(2026, 6, 24, 1, 5, tzinfo=local).astimezone(UTC)
|
||||||
|
assert summarize(db, start, end, now=before)["fixed_cost"] == Decimal("0")
|
||||||
|
assert summarize(db, start, end, now=after)["fixed_cost"] == Decimal("1.2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_versions_cover_first_day_intra_day_and_cross_day(db: Session, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(timezone_service, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
local = ZoneInfo("Europe/Amsterdam")
|
||||||
|
day = datetime(2026, 6, 24, tzinfo=local)
|
||||||
|
first = _contract(db, start=(day + timedelta(hours=6)).astimezone(UTC))
|
||||||
|
first.values = {**VALUES, "standing": {**VALUES["standing"], "heating_network": "365"}}
|
||||||
|
second_values = {**VALUES, "standing": {**VALUES["standing"], "heating_network": "730"}}
|
||||||
|
switch = (day + timedelta(hours=18)).astimezone(UTC)
|
||||||
|
first.effective_to = switch
|
||||||
|
_version(db, first.contract, start=switch, values=second_values)
|
||||||
|
db.commit()
|
||||||
|
start = day.astimezone(UTC)
|
||||||
|
next_day = (day + timedelta(days=1)).astimezone(UTC)
|
||||||
|
following = (day + timedelta(days=2)).astimezone(UTC)
|
||||||
|
# Initial-version day only owns 18h: V1 owns 12h, V2 6h; on the following
|
||||||
|
# day V2 owns the full daily fixed amount.
|
||||||
|
assert summarize(db, start, next_day, now=following + timedelta(hours=2))["fixed_cost"] == Decimal("1.15")
|
||||||
|
assert summarize(db, start, following, now=following + timedelta(hours=2))["fixed_cost"] == Decimal("3.35")
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_row_has_full_audit_snapshot_and_single_commodity_scheduler(db: Session) -> None:
|
||||||
|
version = _contract(db)
|
||||||
|
meter, channel, binding = _domain(db, "heating")
|
||||||
|
_reading(db, channel, T0, "10")
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15), "10.05")
|
||||||
|
db.commit()
|
||||||
|
compute_closed_periods(db, now=T0 + timedelta(minutes=16))
|
||||||
|
row = _row_at(db, "heating", T0)
|
||||||
|
assert (row.meter_id, row.source_binding_id, row.contract_version_id) == (meter.id, binding.id, version.id)
|
||||||
|
assert row.pricing_snapshot == VALUES
|
||||||
|
assert row.currency == "EUR" and {
|
||||||
|
key: Decimal(value) for key, value in row.cost_breakdown.items()
|
||||||
|
} == {"heating": Decimal("1.0")}
|
||||||
|
assert not row.degraded
|
||||||
|
assert _row_at(db, "hot_water", T0).degraded
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("commodity", "end_value"), [("heating", "10.1"), ("hot_water", "11")])
|
||||||
|
def test_delta_limit_is_inclusive_at_exact_boundary(db: Session, commodity: str, end_value: str) -> None:
|
||||||
|
_contract(db)
|
||||||
|
_, channel, _ = _domain(db, commodity)
|
||||||
|
_reading(db, channel, T0, "10")
|
||||||
|
_reading(db, channel, T0 + timedelta(minutes=15), end_value)
|
||||||
|
db.commit()
|
||||||
|
compute_period(db, commodity, T0)
|
||||||
|
assert not _row(db, commodity).degraded
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("day", [datetime(2026, 3, 29), datetime(2026, 10, 25)])
|
||||||
|
def test_summary_dst_local_midnights_charge_one_daily_rate(
|
||||||
|
db: Session, monkeypatch: pytest.MonkeyPatch, day: datetime
|
||||||
|
) -> None:
|
||||||
|
_contract(db, start=datetime(2026, 1, 1, tzinfo=UTC))
|
||||||
|
monkeypatch.setattr(timezone_service, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
|
||||||
|
local = ZoneInfo("Europe/Amsterdam")
|
||||||
|
start = day.replace(tzinfo=local).astimezone(UTC)
|
||||||
|
end = (day + timedelta(days=1)).replace(tzinfo=local).astimezone(UTC)
|
||||||
|
assert summarize(db, start, end, now=end + timedelta(hours=2))["fixed_cost"] == Decimal("1.2")
|
||||||
Reference in New Issue
Block a user