2026-06-25 11:13:30 +02:00
|
|
|
|
"""Tests for M6-T07 + FU10: billing engine (app/services/energy_cost.py).
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
Acceptance criteria covered
|
|
|
|
|
|
----------------------------
|
|
|
|
|
|
1. ``compute_period`` with a ``manual`` dual-tariff contract: correct
|
|
|
|
|
|
import_cost / export_revenue / net_cost; pricing snapshot and
|
|
|
|
|
|
contract_version_id stored.
|
|
|
|
|
|
2. ``compute_period`` idempotency: calling twice with overwrite=False leaves
|
|
|
|
|
|
the row unchanged; overwrite=True re-computes and updates it.
|
|
|
|
|
|
3. ``compute_period`` with a ``tibber`` contract + a matching TibberPrice:
|
|
|
|
|
|
correct calculation using ``total`` as buy price.
|
|
|
|
|
|
4. Missing Tibber price → period is **skipped** (no row written).
|
|
|
|
|
|
5. Missing readings (no DsmrReading covering a boundary) → degraded row.
|
|
|
|
|
|
6. Cross-version selection: two contract versions with different effective
|
|
|
|
|
|
dates; each t0 picks the correct version (asserts contract_version_id).
|
2026-06-25 11:13:30 +02:00
|
|
|
|
7. ``summarize`` (Principle C): fixed costs & credits count only elapsed whole
|
|
|
|
|
|
*local* calendar days, cross-version. Sub-day windows → 0 fixed/credit.
|
|
|
|
|
|
One whole local day → full rate. Future dates → 0. Cross-version → sum.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
8. ``compute_closed_periods``: only processes closed and uncalculated periods;
|
|
|
|
|
|
does not touch already-computed (non-degraded) rows.
|
|
|
|
|
|
9. ``recompute_range``: explicitly overwrites all periods in [start, end)
|
|
|
|
|
|
including already-computed rows.
|
|
|
|
|
|
10. ``floor_to_quarter`` helper returns correct UTC quarter-hour boundaries.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from alembic import command
|
|
|
|
|
|
from alembic.config import Config
|
|
|
|
|
|
from sqlalchemy import create_engine, select
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
from app.integrations.pricing.strategies import PeriodDeltas # noqa: F401
|
|
|
|
|
|
from app.models.energy import (
|
|
|
|
|
|
DsmrReading,
|
|
|
|
|
|
EnergyContract,
|
|
|
|
|
|
EnergyContractVersion,
|
|
|
|
|
|
EnergyCostPeriod,
|
2026-06-25 15:38:21 +02:00
|
|
|
|
Meter,
|
2026-06-23 22:26:56 +02:00
|
|
|
|
TibberPrice,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.services.energy_cost import (
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_MAX_DELTA_KWH,
|
2026-06-23 22:26:56 +02:00
|
|
|
|
compute_closed_periods,
|
|
|
|
|
|
compute_period,
|
|
|
|
|
|
floor_to_quarter,
|
|
|
|
|
|
register_at,
|
|
|
|
|
|
recompute_range,
|
|
|
|
|
|
summarize,
|
|
|
|
|
|
)
|
2026-06-25 11:13:30 +02:00
|
|
|
|
from app.services import timezone as tz_module
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Fixtures
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_app_alembic_config(database_url: str) -> Config:
|
|
|
|
|
|
cfg = Config("alembic_app.ini")
|
|
|
|
|
|
cfg.set_main_option("sqlalchemy.url", database_url)
|
|
|
|
|
|
return cfg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
|
def energy_db(tmp_path: Path):
|
|
|
|
|
|
"""Temporary SQLite DB upgraded to head; yields an open Session."""
|
|
|
|
|
|
db_path = tmp_path / "energy_cost_test.db"
|
|
|
|
|
|
db_url = f"sqlite:///{db_path}"
|
|
|
|
|
|
alembic_cfg = _make_app_alembic_config(db_url)
|
|
|
|
|
|
command.upgrade(alembic_cfg, "head")
|
|
|
|
|
|
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
|
|
|
|
|
session = Session(engine)
|
|
|
|
|
|
yield session
|
|
|
|
|
|
session.close()
|
|
|
|
|
|
engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Data-builder helpers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_UTC = UTC
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ts(hour: int, minute: int = 0, second: int = 0) -> datetime:
|
|
|
|
|
|
"""Return a UTC datetime on 2026-06-23 at the given time."""
|
|
|
|
|
|
return datetime(2026, 6, 23, hour, minute, second, tzinfo=_UTC)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Manual contract values used across most tests.
|
|
|
|
|
|
_MANUAL_VALUES = {
|
|
|
|
|
|
"energy": {
|
|
|
|
|
|
"buy": {"normal": 0.133, "dal": 0.127},
|
|
|
|
|
|
"sell": {"normal": 0.05, "dal": 0.05},
|
|
|
|
|
|
"energy_tax": 0.11,
|
|
|
|
|
|
"ode": 0.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
"standing": {
|
|
|
|
|
|
"network_fee": 9.87,
|
|
|
|
|
|
"management_fee": 9.87,
|
|
|
|
|
|
},
|
|
|
|
|
|
"credits": {
|
|
|
|
|
|
"heffingskorting": 600.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_contract(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
*,
|
|
|
|
|
|
kind: str = "manual",
|
|
|
|
|
|
active: bool = True,
|
|
|
|
|
|
currency: str = "EUR",
|
|
|
|
|
|
) -> EnergyContract:
|
|
|
|
|
|
"""Insert and flush an EnergyContract; return the ORM object."""
|
|
|
|
|
|
now = datetime.now(_UTC)
|
|
|
|
|
|
c = EnergyContract(
|
|
|
|
|
|
name=f"Test Contract ({kind})",
|
|
|
|
|
|
kind=kind,
|
|
|
|
|
|
active=active,
|
|
|
|
|
|
currency=currency,
|
|
|
|
|
|
created_at=now,
|
|
|
|
|
|
updated_at=now,
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(c)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_version(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
contract: EnergyContract,
|
|
|
|
|
|
values: dict,
|
|
|
|
|
|
*,
|
|
|
|
|
|
effective_from: datetime,
|
|
|
|
|
|
effective_to: datetime | None = None,
|
|
|
|
|
|
) -> EnergyContractVersion:
|
|
|
|
|
|
"""Insert and flush an EnergyContractVersion; return the ORM object."""
|
|
|
|
|
|
v = EnergyContractVersion(
|
|
|
|
|
|
contract_id=contract.id,
|
|
|
|
|
|
effective_from=effective_from,
|
|
|
|
|
|
effective_to=effective_to,
|
|
|
|
|
|
values=values,
|
|
|
|
|
|
created_at=datetime.now(_UTC),
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(v)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
return v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_reading(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
*,
|
|
|
|
|
|
recorded_at: datetime,
|
|
|
|
|
|
d1: str = "20000.000",
|
|
|
|
|
|
d2: str = "10000.000",
|
|
|
|
|
|
r1: str = "5000.000",
|
|
|
|
|
|
r2: str = "3000.000",
|
|
|
|
|
|
source_id: int | None = None,
|
|
|
|
|
|
) -> DsmrReading:
|
|
|
|
|
|
"""Insert and flush a DsmrReading with the given register values."""
|
|
|
|
|
|
r = DsmrReading(
|
|
|
|
|
|
recorded_at=recorded_at,
|
|
|
|
|
|
source_id=source_id,
|
|
|
|
|
|
payload={
|
|
|
|
|
|
"electricity_delivered_1": d1,
|
|
|
|
|
|
"electricity_delivered_2": d2,
|
|
|
|
|
|
"electricity_returned_1": r1,
|
|
|
|
|
|
"electricity_returned_2": r2,
|
|
|
|
|
|
"current_electricity_usage": "1.234",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(r)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
def _make_meter(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
*,
|
|
|
|
|
|
started_at: datetime,
|
|
|
|
|
|
ended_at: datetime | None = None,
|
|
|
|
|
|
label: str = "Test Meter",
|
|
|
|
|
|
commodity: str = "electricity",
|
|
|
|
|
|
reason: str = "initial",
|
|
|
|
|
|
note: str | None = None,
|
|
|
|
|
|
) -> Meter:
|
|
|
|
|
|
"""Insert and flush a Meter row; return the ORM object."""
|
|
|
|
|
|
now = datetime.now(_UTC)
|
|
|
|
|
|
m = Meter(
|
|
|
|
|
|
label=label,
|
|
|
|
|
|
commodity=commodity,
|
|
|
|
|
|
started_at=started_at,
|
|
|
|
|
|
ended_at=ended_at,
|
|
|
|
|
|
reason=reason,
|
|
|
|
|
|
note=note,
|
|
|
|
|
|
created_at=now,
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(m)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
return m
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_active_meter(session: Session, *, started_at: datetime | None = None) -> Meter:
|
|
|
|
|
|
"""Insert and flush an active electricity meter covering the full test day.
|
|
|
|
|
|
|
|
|
|
|
|
By default the meter starts at 2026-06-23 00:00 UTC (the beginning of the
|
|
|
|
|
|
test day), covering all boundaries used by the standard test helpers
|
|
|
|
|
|
(_T0 = 10:00, _T1 = 10:15, etc.).
|
|
|
|
|
|
"""
|
|
|
|
|
|
if started_at is None:
|
|
|
|
|
|
# Start well before any test boundary to cover the whole test day.
|
|
|
|
|
|
started_at = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC)
|
|
|
|
|
|
return _make_meter(session, started_at=started_at, ended_at=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
def _make_tibber_price(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
*,
|
|
|
|
|
|
starts_at: datetime,
|
|
|
|
|
|
total: float,
|
|
|
|
|
|
energy: float = 0.18,
|
|
|
|
|
|
currency: str = "EUR",
|
|
|
|
|
|
) -> TibberPrice:
|
|
|
|
|
|
"""Insert and flush a TibberPrice row; return the ORM object."""
|
|
|
|
|
|
tax = round(total - energy, 6)
|
|
|
|
|
|
row = TibberPrice(
|
|
|
|
|
|
starts_at=starts_at,
|
|
|
|
|
|
resolution="QUARTER_HOURLY",
|
|
|
|
|
|
energy=energy,
|
|
|
|
|
|
tax=tax,
|
|
|
|
|
|
total=total,
|
|
|
|
|
|
level="NORMAL",
|
|
|
|
|
|
currency=currency,
|
|
|
|
|
|
fetched_at=datetime.now(_UTC),
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(row)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 10. floor_to_quarter helper
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFloorToQuarter:
|
|
|
|
|
|
def test_already_on_boundary(self) -> None:
|
|
|
|
|
|
dt = _ts(10, 0)
|
|
|
|
|
|
assert floor_to_quarter(dt) == dt
|
|
|
|
|
|
|
|
|
|
|
|
def test_15_boundary(self) -> None:
|
|
|
|
|
|
dt = _ts(10, 15)
|
|
|
|
|
|
assert floor_to_quarter(dt) == dt
|
|
|
|
|
|
|
|
|
|
|
|
def test_floor_mid_period(self) -> None:
|
|
|
|
|
|
dt = _ts(10, 7, 30)
|
|
|
|
|
|
assert floor_to_quarter(dt) == _ts(10, 0)
|
|
|
|
|
|
|
|
|
|
|
|
def test_floor_minute_16(self) -> None:
|
|
|
|
|
|
dt = _ts(10, 16)
|
|
|
|
|
|
assert floor_to_quarter(dt) == _ts(10, 15)
|
|
|
|
|
|
|
|
|
|
|
|
def test_floor_minute_44(self) -> None:
|
|
|
|
|
|
dt = _ts(10, 44, 59)
|
|
|
|
|
|
assert floor_to_quarter(dt) == _ts(10, 30)
|
|
|
|
|
|
|
|
|
|
|
|
def test_floor_minute_45(self) -> None:
|
|
|
|
|
|
dt = _ts(10, 45)
|
|
|
|
|
|
assert floor_to_quarter(dt) == _ts(10, 45)
|
|
|
|
|
|
|
|
|
|
|
|
def test_seconds_zeroed(self) -> None:
|
|
|
|
|
|
dt = _ts(10, 3, 45)
|
|
|
|
|
|
result = floor_to_quarter(dt)
|
|
|
|
|
|
assert result.second == 0
|
|
|
|
|
|
assert result.microsecond == 0
|
|
|
|
|
|
|
|
|
|
|
|
def test_timezone_preserved(self) -> None:
|
|
|
|
|
|
dt = _ts(10, 7)
|
|
|
|
|
|
result = floor_to_quarter(dt)
|
|
|
|
|
|
assert result.tzinfo is _UTC
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# register_at tests
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRegisterAt:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
"""register_at now requires a Meter argument; all tests use an active meter."""
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
def test_returns_none_when_no_readings(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
result = register_at(energy_db, _ts(10, 0), meter)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_returns_most_recent_at_or_before_boundary(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# Insert two readings: one before boundary, one after.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(9, 55), d1="100.0", d2="200.0", r1="10.0", r2="20.0", source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(10, 5), d1="999.0", d2="999.0", r1="999.0", r2="999.0", source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, _ts(10, 0), meter)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
assert result is not None
|
|
|
|
|
|
assert result["d1"] == Decimal("100.0")
|
|
|
|
|
|
assert result["d2"] == Decimal("200.0")
|
|
|
|
|
|
|
|
|
|
|
|
def test_exact_boundary_included(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="500.0", d2="600.0", r1="50.0", r2="60.0", source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, _ts(10, 0), meter)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
assert result is not None
|
|
|
|
|
|
assert result["d1"] == Decimal("500.0")
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_register_key_returns_none(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
r = DsmrReading(
|
|
|
|
|
|
recorded_at=_ts(10, 0),
|
|
|
|
|
|
source_id=99,
|
|
|
|
|
|
payload={"electricity_delivered_1": "100.0"}, # missing d2, r1, r2
|
|
|
|
|
|
)
|
|
|
|
|
|
energy_db.add(r)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, _ts(10, 0), meter)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_null_register_value_returns_none(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
r = DsmrReading(
|
|
|
|
|
|
recorded_at=_ts(10, 0),
|
|
|
|
|
|
source_id=88,
|
|
|
|
|
|
payload={
|
|
|
|
|
|
"electricity_delivered_1": None,
|
|
|
|
|
|
"electricity_delivered_2": "200.0",
|
|
|
|
|
|
"electricity_returned_1": "10.0",
|
|
|
|
|
|
"electricity_returned_2": "20.0",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
energy_db.add(r)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, _ts(10, 0), meter)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_values_are_decimal(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="20915.154", d2="18372.099",
|
|
|
|
|
|
r1="1234.567", r2="890.123", source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, _ts(10, 0), meter)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
assert result is not None
|
|
|
|
|
|
assert isinstance(result["d1"], Decimal)
|
|
|
|
|
|
assert result["d1"] == Decimal("20915.154")
|
|
|
|
|
|
assert result["r2"] == Decimal("890.123")
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
def test_reading_outside_meter_window_excluded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A reading before meter.started_at must not be returned (cross-meter isolation)."""
|
|
|
|
|
|
# Meter starts at 10:00 — a reading at 09:55 is from the old epoch.
|
|
|
|
|
|
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=None)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(9, 55), d1="100.0", d2="200.0",
|
|
|
|
|
|
r1="10.0", r2="20.0", source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Boundary is 10:00; the reading at 09:55 is before meter.started_at.
|
|
|
|
|
|
result = register_at(energy_db, _ts(10, 0), meter)
|
|
|
|
|
|
assert result is None, (
|
|
|
|
|
|
"register_at must not return a reading from before meter.started_at"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_reading_at_meter_started_at_included(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A reading exactly at meter.started_at must be included (half-open lower bound)."""
|
|
|
|
|
|
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=None)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="500.0", d2="600.0",
|
|
|
|
|
|
r1="50.0", r2="60.0", source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = register_at(energy_db, _ts(10, 0), meter)
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
|
assert result["d1"] == Decimal("500.0")
|
|
|
|
|
|
|
|
|
|
|
|
def test_reading_at_meter_ended_at_excluded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A reading exactly at meter.ended_at must be excluded (half-open upper bound)."""
|
|
|
|
|
|
# Meter covers [10:00, 10:15) — a reading at 10:15 belongs to the next epoch.
|
|
|
|
|
|
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=_ts(10, 15))
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(10, 15), d1="500.0", d2="600.0",
|
|
|
|
|
|
r1="50.0", r2="60.0", source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Boundary is 10:15, reading is at 10:15 = ended_at → excluded.
|
|
|
|
|
|
result = register_at(energy_db, _ts(10, 15), meter)
|
|
|
|
|
|
assert result is None, (
|
|
|
|
|
|
"register_at must exclude a reading exactly at meter.ended_at "
|
|
|
|
|
|
"(half-open upper bound)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 1-2. compute_period — manual dual-tariff
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# Hand-calculation reference for the tests below:
|
|
|
|
|
|
#
|
|
|
|
|
|
# Start registers (t0 = 10:00): d1=20000.000, d2=10000.000, r1=5000.000, r2=3000.000
|
|
|
|
|
|
# End registers (t1 = 10:15): d1=20000.500, d2=10001.200, r1=5000.000, r2=3000.100
|
|
|
|
|
|
#
|
|
|
|
|
|
# Deltas: Δd1=0.500, Δd2=1.200, Δr1=0.000, Δr2=0.100
|
|
|
|
|
|
#
|
|
|
|
|
|
# buy_dal = 0.127 + 0.11 + 0.0 = 0.237
|
|
|
|
|
|
# buy_normal = 0.133 + 0.11 + 0.0 = 0.243
|
|
|
|
|
|
# sell_dal = 0.05
|
|
|
|
|
|
# sell_normal = 0.05
|
|
|
|
|
|
#
|
|
|
|
|
|
# import_cost = 0.500×0.237 + 1.200×0.243 = 0.1185 + 0.2916 = 0.4101
|
|
|
|
|
|
# export_revenue = 0.000×0.05 + 0.100×0.05 = 0.000 + 0.005 = 0.005
|
|
|
|
|
|
# net_cost = 0.4101 − 0.005 = 0.4051
|
|
|
|
|
|
|
|
|
|
|
|
_T0 = _ts(10, 0)
|
|
|
|
|
|
_T1 = _ts(10, 15)
|
|
|
|
|
|
|
|
|
|
|
|
_START_D1 = "20000.000"
|
|
|
|
|
|
_START_D2 = "10000.000"
|
|
|
|
|
|
_START_R1 = "5000.000"
|
|
|
|
|
|
_START_R2 = "3000.000"
|
|
|
|
|
|
|
|
|
|
|
|
_END_D1 = "20000.500"
|
|
|
|
|
|
_END_D2 = "10001.200"
|
|
|
|
|
|
_END_R1 = "5000.000"
|
|
|
|
|
|
_END_R2 = "3000.100"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _setup_manual_scenario(session: Session) -> EnergyContractVersion:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
"""Create active manual contract + active meter + two boundary readings; return the version."""
|
2026-06-23 22:26:56 +02:00
|
|
|
|
contract = _make_contract(session, kind="manual", active=True)
|
|
|
|
|
|
version = _make_version(
|
|
|
|
|
|
session, contract, _MANUAL_VALUES,
|
|
|
|
|
|
effective_from=_ts(0, 0), # covers t0=10:00
|
|
|
|
|
|
)
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# Active meter covering the full test day (started before T0).
|
|
|
|
|
|
_make_active_meter(session)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# Start reading (at t0)
|
|
|
|
|
|
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
# End reading (at t1)
|
|
|
|
|
|
_make_reading(session, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
return version
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComputePeriodManual:
|
|
|
|
|
|
def test_correct_import_cost(self, energy_db: Session) -> None:
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# import_cost = 0.500×0.237 + 1.200×0.243 = 0.4101
|
|
|
|
|
|
assert abs(row.import_cost - 0.4101) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_correct_export_revenue(self, energy_db: Session) -> None:
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# export_revenue = 0.000×0.05 + 0.100×0.05 = 0.005
|
|
|
|
|
|
assert abs(row.export_revenue - 0.005) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_correct_net_cost(self, energy_db: Session) -> None:
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# net_cost = 0.4101 − 0.005 = 0.4051
|
|
|
|
|
|
assert abs(row.net_cost - 0.4051) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_pricing_snapshot_stored(self, energy_db: Session) -> None:
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.pricing["kind"] == "manual"
|
|
|
|
|
|
assert "buy_dal" in row.pricing
|
|
|
|
|
|
assert "buy_normal" in row.pricing
|
|
|
|
|
|
|
|
|
|
|
|
def test_contract_version_id_stored(self, energy_db: Session) -> None:
|
|
|
|
|
|
version = _setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.contract_version_id == version.id
|
|
|
|
|
|
|
|
|
|
|
|
def test_not_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_kwh_deltas_stored(self, energy_db: Session) -> None:
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert abs(row.d1_kwh - 0.5) < 1e-9
|
|
|
|
|
|
assert abs(row.d2_kwh - 1.2) < 1e-9
|
|
|
|
|
|
assert abs(row.r1_kwh - 0.0) < 1e-9
|
|
|
|
|
|
assert abs(row.r2_kwh - 0.1) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_currency_stored(self, energy_db: Session) -> None:
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.currency == "EUR"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 2. Idempotency (overwrite=False / overwrite=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComputePeriodIdempotency:
|
|
|
|
|
|
def test_no_overwrite_does_not_change_row(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Calling compute_period twice with overwrite=False must leave the row unchanged."""
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row_before = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
first_computed_at = row_before.computed_at
|
|
|
|
|
|
|
|
|
|
|
|
# Modify an end reading — but overwrite=False should ignore it.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1 + timedelta(seconds=1),
|
|
|
|
|
|
d1="99999.0", d2="99999.0", r1="99999.0", r2="99999.0",
|
|
|
|
|
|
source_id=99)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0, overwrite=False)
|
|
|
|
|
|
assert result is False # did not overwrite
|
|
|
|
|
|
|
|
|
|
|
|
row_after = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# computed_at must be unchanged (row not touched).
|
|
|
|
|
|
assert row_after.computed_at == first_computed_at
|
|
|
|
|
|
|
|
|
|
|
|
def test_overwrite_true_updates_row(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""compute_period with overwrite=True must write to an existing successful row.
|
|
|
|
|
|
|
|
|
|
|
|
We verify overwrite=True by checking that the function returns True
|
|
|
|
|
|
(a write occurred) even though a non-degraded row already exists.
|
|
|
|
|
|
The row's computed_at timestamp will change because we call compute_period
|
|
|
|
|
|
again — that is the observable side-effect of overwriting.
|
|
|
|
|
|
"""
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
|
|
|
|
|
|
row_before = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_before.degraded is False, "Pre-condition: row must be non-degraded"
|
|
|
|
|
|
|
|
|
|
|
|
# overwrite=True: must return True even though row already exists
|
|
|
|
|
|
result = compute_period(energy_db, _T0, overwrite=True)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
def test_only_one_row_per_period(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""There must never be two EnergyCostPeriod rows for the same period_start."""
|
|
|
|
|
|
_setup_manual_scenario(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
compute_period(energy_db, _T0, overwrite=True)
|
|
|
|
|
|
|
|
|
|
|
|
rows = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalars().all()
|
|
|
|
|
|
assert len(rows) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 3. compute_period — tibber contract + tibber_price
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# Hand-calculation:
|
|
|
|
|
|
# Δd1=0.500, Δd2=1.200 → total_delivered = 1.700 kWh
|
|
|
|
|
|
# Δr1=0.000, Δr2=0.100 → total_returned = 0.100 kWh
|
|
|
|
|
|
# tibber total = 0.25 EUR/kWh
|
|
|
|
|
|
# buy = 0.25; sell = 0.25 − 0.10 (energy_tax) − 0.0 (sell_adjust) = 0.15
|
|
|
|
|
|
# import_cost = 1.700 × 0.25 = 0.425
|
|
|
|
|
|
# export_revenue = 0.100 × 0.15 = 0.015
|
|
|
|
|
|
# net_cost = 0.425 − 0.015 = 0.410
|
|
|
|
|
|
|
|
|
|
|
|
_TIBBER_VALUES = {
|
|
|
|
|
|
"energy": {
|
|
|
|
|
|
"energy_tax": 0.10,
|
|
|
|
|
|
"sell_adjust": 0.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
"standing": {
|
|
|
|
|
|
"management_fee": 5.99,
|
|
|
|
|
|
"network_fee": 9.87,
|
|
|
|
|
|
},
|
|
|
|
|
|
"credits": {
|
|
|
|
|
|
"heffingskorting": 600.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComputePeriodTibber:
|
|
|
|
|
|
def _setup(self, session: Session, total: float = 0.25) -> tuple[EnergyContractVersion, TibberPrice]:
|
|
|
|
|
|
contract = _make_contract(session, kind="tibber", active=True)
|
|
|
|
|
|
version = _make_version(session, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(session)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(session, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
price = _make_tibber_price(session, starts_at=_ts(9, 45), total=total)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
return version, price
|
|
|
|
|
|
|
|
|
|
|
|
def test_correct_import_cost(self, energy_db: Session) -> None:
|
|
|
|
|
|
self._setup(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# import_cost = 1.700 × 0.25 = 0.425
|
|
|
|
|
|
assert abs(row.import_cost - 0.425) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_correct_export_revenue(self, energy_db: Session) -> None:
|
|
|
|
|
|
self._setup(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# sell = 0.25 - 0.10 = 0.15; export_revenue = 0.100 × 0.15 = 0.015
|
|
|
|
|
|
assert abs(row.export_revenue - 0.015) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_correct_net_cost(self, energy_db: Session) -> None:
|
|
|
|
|
|
self._setup(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# net_cost = 0.425 - 0.015 = 0.410
|
|
|
|
|
|
assert abs(row.net_cost - 0.410) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_contract_version_id_stored(self, energy_db: Session) -> None:
|
|
|
|
|
|
version, _ = self._setup(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.contract_version_id == version.id
|
|
|
|
|
|
|
|
|
|
|
|
def test_pricing_snapshot_has_tibber_fields(self, energy_db: Session) -> None:
|
|
|
|
|
|
self._setup(energy_db)
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.pricing["kind"] == "tibber"
|
|
|
|
|
|
assert "buy" in row.pricing
|
|
|
|
|
|
assert "sell" in row.pricing
|
|
|
|
|
|
assert "tibber_price_starts_at" in row.pricing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 4. Missing Tibber price → skip (no row written)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComputePeriodMissingTibberPrice:
|
|
|
|
|
|
def test_no_row_written_when_price_missing(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""When Tibber price is absent for the period, no EnergyCostPeriod is written."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="tibber", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
# No TibberPrice inserted.
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is False
|
|
|
|
|
|
|
|
|
|
|
|
rows = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalars().all()
|
|
|
|
|
|
assert len(rows) == 0, "No row should be written when Tibber price is missing"
|
|
|
|
|
|
|
|
|
|
|
|
def test_tibber_price_after_t0_counts_as_missing(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A TibberPrice with starts_at > t0 must NOT be used; period is skipped."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="tibber", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
# Price starts AFTER t0 — must not be used.
|
|
|
|
|
|
_make_tibber_price(energy_db, starts_at=_ts(10, 15), total=0.25)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is False
|
|
|
|
|
|
|
|
|
|
|
|
rows = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalars().all()
|
|
|
|
|
|
assert len(rows) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 5. Missing readings → degraded row
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComputePeriodMissingReadings:
|
|
|
|
|
|
def test_degraded_when_start_reading_missing(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
"""No DsmrReading at or before t0 within the meter window → degraded row written."""
|
2026-06-23 22:26:56 +02:00
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# Only an end reading; no start reading.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True
|
|
|
|
|
|
assert row.import_cost == 0.0
|
|
|
|
|
|
assert row.export_revenue == 0.0
|
|
|
|
|
|
assert row.net_cost == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
def test_degraded_when_end_reading_missing(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
"""No DsmrReading at or before t1 within the meter window → degraded row written.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
We place a reading BEFORE t0 (so t0 boundary has data) but the first
|
|
|
|
|
|
reading AT OR AFTER t1 is only after t1+5min, leaving the t1 boundary
|
|
|
|
|
|
without a reading ≤ t1. This forces ``register_at(t1)`` to return the
|
|
|
|
|
|
same row as ``register_at(t0)`` — both map to the same pre-t0 reading —
|
|
|
|
|
|
which means deltas = 0 but NOT degraded.
|
|
|
|
|
|
|
|
|
|
|
|
Actually the degraded condition for a *missing end reading* occurs when
|
|
|
|
|
|
there is NO DsmrReading in the DB at all with ``recorded_at ≤ t1``.
|
|
|
|
|
|
To achieve that, we only add a reading after t1.
|
|
|
|
|
|
"""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# Only a reading AFTER t1 — no reading at or before t1.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(10, 20), d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True
|
|
|
|
|
|
|
|
|
|
|
|
def test_degraded_has_no_contract_version_id(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Degraded rows written due to missing readings have contract_version_id=None."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# No readings at all.
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.contract_version_id is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_degraded_retried_on_second_compute(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A degraded row is retried (overwritten) when readings become available.
|
|
|
|
|
|
|
|
|
|
|
|
First pass: no readings at all → degraded row (both start and end missing).
|
|
|
|
|
|
Second pass: add both boundary readings → row transitions to non-degraded.
|
|
|
|
|
|
"""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# First compute: no readings at all → degraded.
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
row_degraded = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_degraded.degraded is True
|
|
|
|
|
|
|
|
|
|
|
|
# Now add both boundary readings and retry with overwrite=False.
|
|
|
|
|
|
# Degraded rows are retried by compute_period (overwrite=False still re-tries degraded).
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0, overwrite=False)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row_fixed = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_fixed.degraded is False
|
|
|
|
|
|
assert row_fixed.import_cost > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 6. Cross-version selection
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCrossVersionSelection:
|
|
|
|
|
|
"""Two contract versions with non-overlapping effective dates.
|
|
|
|
|
|
|
|
|
|
|
|
Version 1: effective [00:00, 08:00) → lower buy prices
|
|
|
|
|
|
Version 2: effective [08:00, ∞) → higher buy prices
|
|
|
|
|
|
|
|
|
|
|
|
Periods before 08:00 must use version 1; periods at or after 08:00 use v2.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
# Different rate sets so we can distinguish which version was used.
|
|
|
|
|
|
_VALUES_V1 = {
|
|
|
|
|
|
"energy": {
|
|
|
|
|
|
"buy": {"normal": 0.10, "dal": 0.10},
|
|
|
|
|
|
"sell": {"normal": 0.05, "dal": 0.05},
|
|
|
|
|
|
"energy_tax": 0.0,
|
|
|
|
|
|
"ode": 0.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
"standing": {"network_fee": 5.0, "management_fee": 5.0},
|
|
|
|
|
|
"credits": {"heffingskorting": 300.0},
|
|
|
|
|
|
}
|
|
|
|
|
|
_VALUES_V2 = {
|
|
|
|
|
|
"energy": {
|
|
|
|
|
|
"buy": {"normal": 0.50, "dal": 0.50},
|
|
|
|
|
|
"sell": {"normal": 0.10, "dal": 0.10},
|
|
|
|
|
|
"energy_tax": 0.0,
|
|
|
|
|
|
"ode": 0.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
"standing": {"network_fee": 5.0, "management_fee": 5.0},
|
|
|
|
|
|
"credits": {"heffingskorting": 300.0},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _setup(self, session: Session) -> tuple[EnergyContractVersion, EnergyContractVersion]:
|
|
|
|
|
|
contract = _make_contract(session, kind="manual", active=True)
|
|
|
|
|
|
v1 = _make_version(
|
|
|
|
|
|
session, contract, self._VALUES_V1,
|
|
|
|
|
|
effective_from=_ts(0, 0),
|
|
|
|
|
|
effective_to=_ts(8, 0),
|
|
|
|
|
|
)
|
|
|
|
|
|
v2 = _make_version(
|
|
|
|
|
|
session, contract, self._VALUES_V2,
|
|
|
|
|
|
effective_from=_ts(8, 0),
|
|
|
|
|
|
effective_to=None,
|
|
|
|
|
|
)
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# Active meter covering the full test day.
|
|
|
|
|
|
_make_active_meter(session)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
session.commit()
|
|
|
|
|
|
return v1, v2
|
|
|
|
|
|
|
|
|
|
|
|
def test_period_before_version_boundary_uses_v1(self, energy_db: Session) -> None:
|
|
|
|
|
|
v1, v2 = self._setup(energy_db)
|
|
|
|
|
|
t0_early = _ts(7, 0)
|
|
|
|
|
|
t1_early = _ts(7, 15)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=t0_early, d1="1000.0", d2="1000.0",
|
|
|
|
|
|
r1="0.0", r2="0.0", source_id=10)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=t1_early, d1="1001.0", d2="1000.0",
|
|
|
|
|
|
r1="0.0", r2="0.0", source_id=11)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
compute_period(energy_db, t0_early)
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == t0_early)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.contract_version_id == v1.id, (
|
|
|
|
|
|
f"Expected version v1 (id={v1.id}), got {row.contract_version_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
# import_cost = 1.0 kWh × buy_normal(v1)=0.10 = 0.10
|
|
|
|
|
|
assert abs(row.import_cost - 0.10) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_period_at_version_boundary_uses_v2(self, energy_db: Session) -> None:
|
|
|
|
|
|
v1, v2 = self._setup(energy_db)
|
|
|
|
|
|
t0_late = _ts(8, 0)
|
|
|
|
|
|
t1_late = _ts(8, 15)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=t0_late, d1="2000.0", d2="2000.0",
|
|
|
|
|
|
r1="0.0", r2="0.0", source_id=20)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=t1_late, d1="2001.0", d2="2000.0",
|
|
|
|
|
|
r1="0.0", r2="0.0", source_id=21)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
compute_period(energy_db, t0_late)
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == t0_late)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.contract_version_id == v2.id, (
|
|
|
|
|
|
f"Expected version v2 (id={v2.id}), got {row.contract_version_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
# import_cost = 1.0 kWh × buy_normal(v2)=0.50 = 0.50
|
|
|
|
|
|
assert abs(row.import_cost - 0.50) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 7. summarize — hand-verified
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# Hand-calculation for TestSummarize:
|
|
|
|
|
|
#
|
|
|
|
|
|
# Interval: 2026-06-23 00:00 UTC to 2026-06-24 00:00 UTC (exactly 1 day)
|
|
|
|
|
|
# days = 1.0
|
|
|
|
|
|
#
|
|
|
|
|
|
# Active contract values (from _MANUAL_VALUES):
|
|
|
|
|
|
# standing: network_fee=9.87, management_fee=9.87
|
|
|
|
|
|
# credits: heffingskorting=600.0
|
|
|
|
|
|
#
|
|
|
|
|
|
# Fixed costs per day = (9.87 + 9.87) / 30 × 1.0 = 19.74 / 30 = 0.658
|
|
|
|
|
|
# Credits per day = 600.0 / 365 × 1.0 = 600.0 / 365 ≈ 1.6438...
|
|
|
|
|
|
#
|
|
|
|
|
|
# Metered net (from two periods, each net_cost=0.4051):
|
|
|
|
|
|
# Σnet = 2 × 0.4051 = 0.8102
|
|
|
|
|
|
# (slightly approximate: actual computed floats from compute_period)
|
|
|
|
|
|
#
|
|
|
|
|
|
# total_payable = 0.8102 + 0.658 − 1.6438... ≈ −0.1756...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSummarize:
|
|
|
|
|
|
def _setup_two_periods(self, session: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
"""Insert contract + meter + readings for two consecutive 15-min periods and compute them."""
|
2026-06-23 22:26:56 +02:00
|
|
|
|
contract = _make_contract(session, kind="manual", active=True)
|
|
|
|
|
|
_make_version(session, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(session)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
# Period 1: [10:00, 10:15)
|
|
|
|
|
|
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(session, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
|
|
|
|
|
|
# Period 2: [10:15, 10:30) — same delta as period 1
|
|
|
|
|
|
t2 = _ts(10, 30)
|
|
|
|
|
|
_make_reading(session, recorded_at=t2,
|
|
|
|
|
|
d1=str(float(_END_D1) + 0.5),
|
|
|
|
|
|
d2=str(float(_END_D2) + 1.2),
|
|
|
|
|
|
r1=str(float(_END_R1)),
|
|
|
|
|
|
r2=str(float(_END_R2) + 0.1),
|
|
|
|
|
|
source_id=3)
|
|
|
|
|
|
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
compute_period(session, _T0)
|
|
|
|
|
|
compute_period(session, _T1)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
def test_metered_net_sum(self, energy_db: Session) -> None:
|
|
|
|
|
|
self._setup_two_periods(energy_db)
|
|
|
|
|
|
# Summarise over the two computed periods.
|
|
|
|
|
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
|
|
|
|
|
# Σnet ≈ 2 × 0.4051 = 0.8102
|
|
|
|
|
|
assert abs(result["metered_net"] - 0.8102) < 1e-6
|
|
|
|
|
|
|
|
|
|
|
|
def test_period_count(self, energy_db: Session) -> None:
|
|
|
|
|
|
self._setup_two_periods(energy_db)
|
|
|
|
|
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
|
|
|
|
|
assert result["period_count"] == 2
|
|
|
|
|
|
assert result["degraded_count"] == 0
|
|
|
|
|
|
|
2026-06-25 17:57:55 +02:00
|
|
|
|
def test_fixed_costs_one_day_for_sub_day_window(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
2026-06-25 17:57:55 +02:00
|
|
|
|
Under the symmetric-begin/end fix, fixed charges are assessed on a
|
|
|
|
|
|
"service-is-active" basis: if the window falls within a local calendar day,
|
|
|
|
|
|
that entire day's charge applies. The begin side no longer requires a local
|
|
|
|
|
|
midnight to fall *inside* the window — the local date of start_utc itself is
|
|
|
|
|
|
always counted as the first day.
|
|
|
|
|
|
|
|
|
|
|
|
Window [10:00, 10:30) UTC in Europe/Amsterdam (CEST = UTC+2):
|
|
|
|
|
|
local date of start: June 23 (12:00 CEST)
|
|
|
|
|
|
local date of end: June 23 (12:30 CEST)
|
|
|
|
|
|
→ first_counted = last_counted = June 23 → 1 day
|
|
|
|
|
|
→ fixed_costs = (9.87 + 9.87) / 30 × 1
|
2026-06-25 11:13:30 +02:00
|
|
|
|
"""
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
2026-06-25 17:57:55 +02:00
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
self._setup_two_periods(energy_db)
|
2026-06-25 11:13:30 +02:00
|
|
|
|
# Pin the local timezone so the test is deterministic on any CI host.
|
2026-06-25 17:57:55 +02:00
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
2026-06-25 11:13:30 +02:00
|
|
|
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
2026-06-25 17:57:55 +02:00
|
|
|
|
|
|
|
|
|
|
expected_fixed = (9.87 + 9.87) / 30 # 1 day
|
|
|
|
|
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: sub-day window in one local day should count 1 day of fixed costs; "
|
|
|
|
|
|
f"expected {expected_fixed:.6f}, got {result['fixed_costs']}"
|
2026-06-25 11:13:30 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-25 17:57:55 +02:00
|
|
|
|
def test_credits_one_day_for_sub_day_window(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day of credits.
|
|
|
|
|
|
|
|
|
|
|
|
Same rationale as test_fixed_costs_one_day_for_sub_day_window.
|
|
|
|
|
|
credits = 600.0 / 365 × 1 day.
|
|
|
|
|
|
"""
|
2026-06-25 11:13:30 +02:00
|
|
|
|
from zoneinfo import ZoneInfo
|
2026-06-25 17:57:55 +02:00
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
self._setup_two_periods(energy_db)
|
2026-06-25 17:57:55 +02:00
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
2026-06-25 11:13:30 +02:00
|
|
|
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
2026-06-25 17:57:55 +02:00
|
|
|
|
|
|
|
|
|
|
expected_credits = 600.0 / 365 # 1 day
|
|
|
|
|
|
assert abs(result["credits"] - expected_credits) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: sub-day window in one local day should count 1 day of credits; "
|
|
|
|
|
|
f"expected {expected_credits:.6f}, got {result['credits']}"
|
2026-06-25 11:13:30 +02:00
|
|
|
|
)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
def test_total_payable_formula(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""total_payable = metered_net + fixed_costs − credits."""
|
|
|
|
|
|
self._setup_two_periods(energy_db)
|
|
|
|
|
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
|
|
|
|
|
expected = result["metered_net"] + result["fixed_costs"] - result["credits"]
|
|
|
|
|
|
assert abs(result["total_payable"] - expected) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_active_contract_returns_zero_standing(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""When no active contract exists, fixed_costs and credits are both 0."""
|
|
|
|
|
|
# No contract at all — just compute a period manually and summarise.
|
|
|
|
|
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
|
|
|
|
|
assert result["fixed_costs"] == 0.0
|
|
|
|
|
|
assert result["credits"] == 0.0
|
|
|
|
|
|
assert result["metered_net"] == 0.0
|
|
|
|
|
|
assert result["total_payable"] == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
def test_currency_from_contract(self, energy_db: Session) -> None:
|
|
|
|
|
|
self._setup_two_periods(energy_db)
|
|
|
|
|
|
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
|
|
|
|
|
assert result["currency"] == "EUR"
|
|
|
|
|
|
|
|
|
|
|
|
def test_degraded_excluded_from_metered_sum(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Degraded rows must not contribute to the metered sums.
|
|
|
|
|
|
|
|
|
|
|
|
We produce a degraded row by calling compute_period when no readings
|
|
|
|
|
|
exist at all for the period boundaries.
|
|
|
|
|
|
"""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# No readings at all → degraded period.
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = summarize(energy_db, _T0, _T1)
|
|
|
|
|
|
assert result["metered_net"] == 0.0
|
|
|
|
|
|
assert result["degraded_count"] == 1
|
|
|
|
|
|
assert result["period_count"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None:
|
2026-06-25 17:57:55 +02:00
|
|
|
|
"""Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day UTC interval.
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
2026-06-25 17:57:55 +02:00
|
|
|
|
FUE-T01 (symmetric begin/end): the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC)
|
|
|
|
|
|
in Europe/Amsterdam (CEST=UTC+2):
|
|
|
|
|
|
- local date of start (June 23 00:00 UTC = June 23 02:00 CEST) = June 23
|
|
|
|
|
|
→ first_counted = June 23 (anchor day always counted)
|
|
|
|
|
|
- local midnight of June 24 = June 23 22:00 UTC, which is < end_utc (June 24 00:00 UTC)
|
|
|
|
|
|
→ last_counted = June 24
|
|
|
|
|
|
- today (2026-06-25) ≥ June 24 → cap doesn't apply
|
|
|
|
|
|
→ 2 local calendar days counted (June 23 + June 24)
|
|
|
|
|
|
|
|
|
|
|
|
Note: a 1-day UTC window centered at non-midnight UTC spans **two** local days
|
|
|
|
|
|
in Amsterdam (CEST=UTC+2) because local midnight (June 23 22:00 UTC) falls
|
|
|
|
|
|
inside the window. This is correct under the symmetric begin/end principle.
|
2026-06-25 11:13:30 +02:00
|
|
|
|
"""
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
# Summarize over exactly 1 UTC day (no periods in DB — only standing/credits).
|
|
|
|
|
|
# Pin timezone to Europe/Amsterdam for deterministic local-date mapping.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
start = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC)
|
|
|
|
|
|
end = datetime(2026, 6, 24, 0, 0, 0, tzinfo=_UTC)
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
|
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
|
|
|
|
|
result = summarize(energy_db, start, end)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
2026-06-25 17:57:55 +02:00
|
|
|
|
# 2 local days (June 23 start day + June 24 because local midnight of June 24
|
|
|
|
|
|
# = June 23 22:00 UTC falls within [start, end)).
|
|
|
|
|
|
n_days = 2
|
|
|
|
|
|
assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30 * n_days) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: 1-day UTC window = 2 local days in Amsterdam; "
|
|
|
|
|
|
f"expected fixed={((9.87 + 9.87) / 30 * n_days):.6f}, got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - 600.0 / 365 * n_days) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: expected credits={600.0 / 365 * n_days:.6f}, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
# total = 0 + fixed − credits
|
|
|
|
|
|
expected_total = (9.87 + 9.87) / 30 * n_days - 600.0 / 365 * n_days
|
2026-06-23 22:26:56 +02:00
|
|
|
|
assert abs(result["total_payable"] - expected_total) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 7b. summarize — Principle C (whole-local-day counting, cross-version)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# All tests in this section pin the local timezone to Europe/Amsterdam via
|
|
|
|
|
|
# monkeypatch so assertions are deterministic regardless of CI host timezone.
|
|
|
|
|
|
# "Today" in these tests is 2026-06-25 (CEST = UTC+2).
|
|
|
|
|
|
#
|
|
|
|
|
|
# Reference rates:
|
|
|
|
|
|
# network_fee = 9.87 EUR/month, management_fee = 9.87 EUR/month
|
|
|
|
|
|
# daily_fixed = (9.87 + 9.87) / 30 = 0.658 EUR/day
|
|
|
|
|
|
# heffingskorting = 600.0 EUR/year
|
|
|
|
|
|
# daily_credit = 600.0 / 365 ≈ 1.6438... EUR/day
|
|
|
|
|
|
#
|
|
|
|
|
|
# Window semantics:
|
|
|
|
|
|
# start_local_date = local_date(start_utc) (inclusive)
|
|
|
|
|
|
# end_local_date = local_date(end_utc) (exclusive upper bound)
|
|
|
|
|
|
# cap = min(end_local_date, today_local + 1 day)
|
|
|
|
|
|
# counted_days = max(0, (cap - start_local_date).days)
|
|
|
|
|
|
# restricted to the version's [v_start, v_end) range
|
|
|
|
|
|
#
|
|
|
|
|
|
# CEST = UTC+2; local midnight June D = UTC June (D-1) 22:00.
|
|
|
|
|
|
# So:
|
|
|
|
|
|
# start "This Month" (June 1 local 00:00) = May 31 22:00 UTC
|
|
|
|
|
|
# end "Next Month" (July 1 local 00:00) = June 30 22:00 UTC
|
|
|
|
|
|
# In UTC we feed actual UTC boundaries.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Table (today = June 25 local, effective_from = June 1 local 00:00 = May 31 22:00 UTC):
|
|
|
|
|
|
#
|
|
|
|
|
|
# | Window (UTC) | Local dates | Elapsed | Expected |
|
|
|
|
|
|
# |----------------------------------|-------------------------|---------|----------|
|
|
|
|
|
|
# | June 25 UTC+2 today (1 local day)| [June 25, June 26) | 1 day | 1 day |
|
|
|
|
|
|
# | June 25 UTC+2 → June 28 UTC+2 | [June 25, June 28) | 1 day* | 1 day |
|
|
|
|
|
|
# | June 1 CEST → July 1 CEST (mon) | [June 1, July 1) | 25 days*| 25 days |
|
|
|
|
|
|
# | May 1 → June 1 (all past) | [May 1, June 1) | 31 days | 31 days |
|
|
|
|
|
|
# | July 1 → Aug 1 (all future) | [July 1, Aug 1) | 0 days | 0 days |
|
|
|
|
|
|
#
|
|
|
|
|
|
# *today local = June 25: "today" counts but June 26+ does not.
|
|
|
|
|
|
|
|
|
|
|
|
_AMS = None # ZoneInfo resolved lazily
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ams():
|
|
|
|
|
|
global _AMS
|
|
|
|
|
|
if _AMS is None:
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
_AMS = ZoneInfo("Europe/Amsterdam")
|
|
|
|
|
|
return _AMS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _local_midnight_utc_ams(year: int, month: int, day: int) -> datetime:
|
|
|
|
|
|
"""Return UTC instant for Europe/Amsterdam local midnight on the given date."""
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
ams = ZoneInfo("Europe/Amsterdam")
|
|
|
|
|
|
return datetime(year, month, day, 0, 0, 0, tzinfo=ams).replace(tzinfo=None).replace(
|
|
|
|
|
|
tzinfo=ams
|
|
|
|
|
|
).astimezone(_UTC)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Simpler helper using ZoneInfo directly:
|
|
|
|
|
|
def _ams_midnight(year: int, month: int, day: int) -> datetime:
|
|
|
|
|
|
"""UTC datetime corresponding to Europe/Amsterdam local midnight on year/month/day."""
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
ams = ZoneInfo("Europe/Amsterdam")
|
|
|
|
|
|
return datetime(year, month, day, 0, 0, 0, tzinfo=ams).astimezone(_UTC)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSummarizePrincipleC:
|
2026-06-26 11:44:34 +02:00
|
|
|
|
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone.
|
|
|
|
|
|
|
|
|
|
|
|
Tests that assert a specific "today" (e.g. June 25) must also pin
|
|
|
|
|
|
``local_now`` via *pinned_now* to remain deterministic as wall-clock
|
|
|
|
|
|
time advances. Tests that only care about windows entirely in the past
|
|
|
|
|
|
or entirely in the future do not need to pin ``local_now``.
|
|
|
|
|
|
"""
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
|
|
|
|
|
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
|
2026-06-26 11:44:34 +02:00
|
|
|
|
# Reference "today" pinned in individual tests = June 25 CEST.
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
|
|
|
|
|
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
|
|
|
|
|
|
"""Create an active manual contract with a single version."""
|
|
|
|
|
|
contract = _make_contract(session, kind="manual", active=True)
|
|
|
|
|
|
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
2026-06-26 11:44:34 +02:00
|
|
|
|
def _run_summarize_ams(
|
|
|
|
|
|
self,
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
start_utc: datetime,
|
|
|
|
|
|
end_utc: datetime,
|
|
|
|
|
|
*,
|
|
|
|
|
|
pinned_now: datetime | None = None,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.
|
|
|
|
|
|
|
|
|
|
|
|
If *pinned_now* is given, also patches ``app.services.energy_cost.local_now``
|
|
|
|
|
|
to that fixed value, making settlement-offset logic deterministic
|
|
|
|
|
|
regardless of wall-clock time.
|
|
|
|
|
|
"""
|
2026-06-25 11:13:30 +02:00
|
|
|
|
from unittest.mock import patch
|
2026-06-26 11:44:34 +02:00
|
|
|
|
import app.services.energy_cost as _ec
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
2026-06-26 11:44:34 +02:00
|
|
|
|
if pinned_now is not None:
|
|
|
|
|
|
with patch.object(_ec, "local_now", return_value=pinned_now):
|
|
|
|
|
|
return summarize(session, start_utc, end_utc)
|
2026-06-25 11:13:30 +02:00
|
|
|
|
return summarize(session, start_utc, end_utc)
|
|
|
|
|
|
|
|
|
|
|
|
def test_today_window_counts_1_day(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Today's window (local today 00:00 → local tomorrow 00:00) counts 1 day.
|
|
|
|
|
|
|
|
|
|
|
|
Matches table row: Today 6/25→6/26 → 1 day.
|
|
|
|
|
|
"""
|
|
|
|
|
|
eff_utc = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
|
|
|
|
|
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 25)
|
|
|
|
|
|
end = _ams_midnight(2026, 6, 26)
|
|
|
|
|
|
result = self._run_summarize_ams(energy_db, start, end)
|
|
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
|
|
|
|
|
f"Expected 1 day of fixed costs, got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
|
|
|
|
|
f"Expected 1 day of credits, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A fully future window (all local dates > today) counts 0 days.
|
|
|
|
|
|
|
|
|
|
|
|
Matches table row: 7/1→8/1 (all future) → 0 days.
|
|
|
|
|
|
"""
|
|
|
|
|
|
eff_utc = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
|
|
|
|
|
|
|
|
|
|
|
start = _ams_midnight(2026, 7, 1)
|
|
|
|
|
|
end = _ams_midnight(2026, 8, 1)
|
|
|
|
|
|
result = self._run_summarize_ams(energy_db, start, end)
|
|
|
|
|
|
|
|
|
|
|
|
assert result["fixed_costs"] == 0.0, (
|
|
|
|
|
|
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert result["credits"] == 0.0, (
|
|
|
|
|
|
f"All-future window must count 0 days; got credits={result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
|
|
|
|
|
|
|
2026-06-26 11:44:34 +02:00
|
|
|
|
Pins ``local_now`` to June 25 noon AMS so the test is deterministic
|
|
|
|
|
|
regardless of the actual wall-clock date. June 25 is well past the
|
|
|
|
|
|
01:05 settlement offset, so ``settled_cap = June 25``.
|
|
|
|
|
|
Today = June 25 local, so June 26–30 are not yet elapsed → 25 days.
|
2026-06-25 11:13:30 +02:00
|
|
|
|
Matches table row: 6/1→7/1 (This Month) → 25 days.
|
|
|
|
|
|
"""
|
|
|
|
|
|
eff_utc = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
|
|
|
|
|
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
end = _ams_midnight(2026, 7, 1)
|
2026-06-26 11:44:34 +02:00
|
|
|
|
# Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
|
|
|
|
|
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
|
|
|
|
|
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 25) < 1e-9, (
|
|
|
|
|
|
f"Expected 25 days of fixed costs (June 1-25 elapsed), got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 25) < 1e-9, (
|
|
|
|
|
|
f"Expected 25 days of credits, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_past_month_window_counts_all_31_days(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Last month (May 1 → June 1 local) counts all 31 days (fully past).
|
|
|
|
|
|
|
|
|
|
|
|
Matches table row: 5/1→6/1 (all past) → 31 days.
|
|
|
|
|
|
effective_from is June 1 so May is before the contract — 0 days from contract.
|
|
|
|
|
|
Use an earlier effective_from to cover May.
|
|
|
|
|
|
"""
|
|
|
|
|
|
eff_utc = _ams_midnight(2026, 1, 1) # contract starts Jan 1 → covers May
|
|
|
|
|
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
|
|
|
|
|
|
|
|
|
|
|
start = _ams_midnight(2026, 5, 1)
|
|
|
|
|
|
end = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
result = self._run_summarize_ams(energy_db, start, end)
|
|
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 31) < 1e-9, (
|
|
|
|
|
|
f"Expected 31 days of fixed costs (May 1-31 all past), got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 31) < 1e-9, (
|
|
|
|
|
|
f"Expected 31 days of credits, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A window partially in the future caps at today (only elapsed days counted).
|
|
|
|
|
|
|
2026-06-26 11:44:34 +02:00
|
|
|
|
Pins ``local_now`` to June 25 noon AMS so the test is deterministic.
|
2026-06-25 11:13:30 +02:00
|
|
|
|
Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
|
2026-06-26 11:44:34 +02:00
|
|
|
|
Today = June 25 → settled_cap = June 25 → only 1 day elapsed (June 25).
|
2026-06-25 11:13:30 +02:00
|
|
|
|
Matches table row: 6/25→6/28 → 1 day.
|
|
|
|
|
|
"""
|
|
|
|
|
|
eff_utc = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
|
|
|
|
|
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 25)
|
|
|
|
|
|
end = _ams_midnight(2026, 6, 28)
|
2026-06-26 11:44:34 +02:00
|
|
|
|
# Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
|
|
|
|
|
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
|
|
|
|
|
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
|
|
|
|
|
f"Expected 1 day of fixed costs (only June 25 elapsed), got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
|
|
|
|
|
f"Expected 1 day of credits, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_cross_version_integration(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Cross-version integration: V1 June 1-24, V2 June 25+ (today's boundary).
|
|
|
|
|
|
|
|
|
|
|
|
Window: This Month (June 1 → July 1 local) = 25 days total.
|
|
|
|
|
|
- V1: June 1-24 (24 days) at rate1
|
|
|
|
|
|
- V2: June 25-25 (1 day, today) at rate2
|
|
|
|
|
|
|
|
|
|
|
|
V1 rates: network_fee=6.0, management_fee=6.0 → daily = 12/30 = 0.4
|
|
|
|
|
|
heffingskorting=300 → daily = 300/365
|
|
|
|
|
|
V2 rates: network_fee=12.0, management_fee=12.0 → daily = 24/30 = 0.8
|
|
|
|
|
|
heffingskorting=600 → daily = 600/365
|
|
|
|
|
|
|
|
|
|
|
|
Expected:
|
|
|
|
|
|
fixed_costs = 24 × 0.4 + 1 × 0.8 = 9.6 + 0.8 = 10.4
|
|
|
|
|
|
credits = 24 × (300/365) + 1 × (600/365) = (7200+600)/365 = 7800/365
|
|
|
|
|
|
"""
|
|
|
|
|
|
_VALUES_V1 = {
|
|
|
|
|
|
"energy": {"buy": {"normal": 0.10, "dal": 0.10}, "sell": {"normal": 0.05, "dal": 0.05},
|
|
|
|
|
|
"energy_tax": 0.0, "ode": 0.0},
|
|
|
|
|
|
"standing": {"network_fee": 6.0, "management_fee": 6.0},
|
|
|
|
|
|
"credits": {"heffingskorting": 300.0},
|
|
|
|
|
|
}
|
|
|
|
|
|
_VALUES_V2 = {
|
|
|
|
|
|
"energy": {"buy": {"normal": 0.20, "dal": 0.20}, "sell": {"normal": 0.08, "dal": 0.08},
|
|
|
|
|
|
"energy_tax": 0.0, "ode": 0.0},
|
|
|
|
|
|
"standing": {"network_fee": 12.0, "management_fee": 12.0},
|
|
|
|
|
|
"credits": {"heffingskorting": 600.0},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# V1: effective June 1 CEST local. V2: effective June 25 CEST local.
|
|
|
|
|
|
v1_from = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
v2_from = _ams_midnight(2026, 6, 25)
|
|
|
|
|
|
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from,
|
|
|
|
|
|
effective_to=v2_from)
|
|
|
|
|
|
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
end = _ams_midnight(2026, 7, 1)
|
|
|
|
|
|
from unittest.mock import patch
|
2026-06-26 11:44:34 +02:00
|
|
|
|
import app.services.energy_cost as _ec
|
|
|
|
|
|
# Pin local_now to June 25 2026 noon AMS: today=June 25, settled_cap=June 25.
|
|
|
|
|
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
2026-06-25 11:13:30 +02:00
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
2026-06-26 11:44:34 +02:00
|
|
|
|
with patch.object(_ec, "local_now", return_value=pinned_now):
|
|
|
|
|
|
result = summarize(energy_db, start, end)
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
|
|
|
|
|
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
|
|
|
|
|
|
expected_fixed = 24 * 12 / 30 + 1 * 24 / 30
|
|
|
|
|
|
expected_credits = 24 * 300 / 365 + 1 * 600 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
|
|
|
|
|
|
f"Cross-version fixed_costs wrong: expected {expected_fixed}, got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - expected_credits) < 1e-9, (
|
|
|
|
|
|
f"Cross-version credits wrong: expected {expected_credits}, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_version_switch_does_not_reset_counter(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Adding a new version does not reset the cumulative counter.
|
|
|
|
|
|
|
|
|
|
|
|
With V1 from June 1 and V2 from June 25, querying June 25 → July 1
|
|
|
|
|
|
still returns V2's 1-day rate without losing the historical V1 days
|
|
|
|
|
|
(the MQTT getter anchors at V1.effective_from to accumulate everything).
|
|
|
|
|
|
This test just checks that V2-only window returns V2 rate × 1 day.
|
|
|
|
|
|
"""
|
|
|
|
|
|
_VALUES_V1 = {
|
|
|
|
|
|
"energy": {"buy": {"normal": 0.10, "dal": 0.10}, "sell": {"normal": 0.05, "dal": 0.05},
|
|
|
|
|
|
"energy_tax": 0.0, "ode": 0.0},
|
|
|
|
|
|
"standing": {"network_fee": 6.0, "management_fee": 6.0},
|
|
|
|
|
|
"credits": {"heffingskorting": 300.0},
|
|
|
|
|
|
}
|
|
|
|
|
|
_VALUES_V2 = {
|
|
|
|
|
|
"energy": {"buy": {"normal": 0.20, "dal": 0.20}, "sell": {"normal": 0.08, "dal": 0.08},
|
|
|
|
|
|
"energy_tax": 0.0, "ode": 0.0},
|
|
|
|
|
|
"standing": {"network_fee": 12.0, "management_fee": 12.0},
|
|
|
|
|
|
"credits": {"heffingskorting": 600.0},
|
|
|
|
|
|
}
|
|
|
|
|
|
v1_from = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
v2_from = _ams_midnight(2026, 6, 25)
|
|
|
|
|
|
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from,
|
|
|
|
|
|
effective_to=v2_from)
|
|
|
|
|
|
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-26 11:44:34 +02:00
|
|
|
|
# Window = June 25 only (today, 1 day elapsed at V2 rate).
|
|
|
|
|
|
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
|
2026-06-25 11:13:30 +02:00
|
|
|
|
start = _ams_midnight(2026, 6, 25)
|
|
|
|
|
|
end = _ams_midnight(2026, 7, 1)
|
|
|
|
|
|
from unittest.mock import patch
|
2026-06-26 11:44:34 +02:00
|
|
|
|
import app.services.energy_cost as _ec
|
|
|
|
|
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
2026-06-25 11:13:30 +02:00
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
2026-06-26 11:44:34 +02:00
|
|
|
|
with patch.object(_ec, "local_now", return_value=pinned_now):
|
|
|
|
|
|
result = summarize(energy_db, start, end)
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
|
|
|
|
|
# Only 1 day at V2 rate
|
|
|
|
|
|
expected_fixed = 1 * 24 / 30
|
|
|
|
|
|
expected_credits = 1 * 600 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
|
|
|
|
|
|
assert abs(result["credits"] - expected_credits) < 1e-9
|
|
|
|
|
|
|
2026-06-25 17:57:55 +02:00
|
|
|
|
def test_morning_anchor_first_day_counted(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""FUE-T01: anchor falling above local midnight counts its local day as day 1.
|
|
|
|
|
|
|
|
|
|
|
|
Bug scenario: meter.started_at = June 24 09:18 local time (07:18 UTC, CEST=UTC+2).
|
|
|
|
|
|
Old code: local_date(07:18 UTC) = June 24; _lmu(June 24) = June 23 22:00 UTC;
|
|
|
|
|
|
June 23 22:00 UTC < June 24 07:18 UTC → False (22:00 < 07:18? No — 22:00 UTC June 23 is
|
|
|
|
|
|
*before* 07:18 UTC June 24; so the condition >= start_utc was False);
|
|
|
|
|
|
first_counted bumped to June 25. June 24's charges were silently dropped.
|
|
|
|
|
|
|
|
|
|
|
|
Fix: first_counted = local_date(start_utc) = June 24 always, regardless of where
|
|
|
|
|
|
within the day start_utc falls. June 24 is the anchor day → its charge is counted.
|
|
|
|
|
|
|
|
|
|
|
|
Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)).
|
2026-06-26 11:44:34 +02:00
|
|
|
|
Pins local_now to June 25 noon AMS → today=June 25, settled_cap=June 25.
|
2026-06-25 17:57:55 +02:00
|
|
|
|
first_counted = June 24 (anchor day, previously dropped).
|
|
|
|
|
|
last_counted = min(June 26, June 25) = June 25.
|
|
|
|
|
|
n_days = June 25 - June 24 + 1 = 2.
|
|
|
|
|
|
"""
|
|
|
|
|
|
eff_utc = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
|
|
|
|
|
|
|
|
|
|
|
# anchor = June 24 07:18 UTC = June 24 09:18 CEST (above local midnight)
|
|
|
|
|
|
anchor_utc = datetime(2026, 6, 24, 7, 18, 0, tzinfo=_UTC)
|
|
|
|
|
|
# end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped)
|
|
|
|
|
|
end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
|
|
|
|
|
|
|
2026-06-26 11:44:34 +02:00
|
|
|
|
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
|
|
|
|
|
|
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
|
|
|
|
|
|
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc, pinned_now=pinned_now)
|
2026-06-25 17:57:55 +02:00
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
# June 24 (anchor day) + June 25 (today) = 2 days
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: morning anchor should count anchor day + today = 2 days of fixed costs; "
|
|
|
|
|
|
f"expected {daily_fixed * 2:.6f}, got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: morning anchor should count anchor day + today = 2 days of credits; "
|
|
|
|
|
|
f"expected {daily_credit * 2:.6f}, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_daily_window_midnight_aligned_unaffected(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""FUE-T01: the daily getter window [today midnight, tomorrow midnight) is not affected.
|
|
|
|
|
|
|
|
|
|
|
|
The *_today getters use windows exactly aligned to local midnight:
|
|
|
|
|
|
start = local_midnight_utc(today) = June 24 22:00 UTC (for June 25 local)
|
|
|
|
|
|
end = local_midnight_utc(tomorrow) = June 25 22:00 UTC
|
|
|
|
|
|
|
|
|
|
|
|
With either old or new logic, first_counted = June 25 (today):
|
|
|
|
|
|
- Old: local_date(June 24 22:00 UTC in Amsterdam) = June 25;
|
|
|
|
|
|
_lmu(June 25) = June 24 22:00 UTC >= June 24 22:00 UTC → True → first = June 25.
|
|
|
|
|
|
- New: first_counted = local_date(June 24 22:00 UTC) = June 25.
|
|
|
|
|
|
Both give the same result: 1 day counted.
|
|
|
|
|
|
|
|
|
|
|
|
This test explicitly verifies daily window behaviour is unchanged.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
|
|
eff_utc = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
|
|
|
|
|
|
|
|
|
|
|
# Today's window aligned to local midnight (June 25 CEST = June 24 22:00 UTC).
|
|
|
|
|
|
today_start_utc = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
|
|
|
|
|
|
tomorrow_start_utc = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
|
|
|
|
|
result = self._run_summarize_ams(energy_db, today_start_utc, tomorrow_start_utc)
|
|
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
# Exactly 1 day counted (June 25 only; June 26 is future, capped).
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: daily window should count exactly 1 day; "
|
|
|
|
|
|
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: daily window should count exactly 1 day of credits; "
|
|
|
|
|
|
f"expected {daily_credit:.6f}, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_single_day_morning_anchor_counts_one_day(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""FUE-T01: a window starting mid-morning on a single local day counts that 1 day.
|
|
|
|
|
|
|
|
|
|
|
|
Window: [June 24 08:00 UTC (= 10:00 CEST), June 24 22:00 UTC (= June 25 00:00 CEST)).
|
|
|
|
|
|
Both endpoints resolve to June 24 local (end is exactly midnight of June 25,
|
|
|
|
|
|
which is June 24 22:00 UTC, so _lmu(June 25) = June 24 22:00 UTC is NOT < end_utc
|
|
|
|
|
|
but equal → last_counted = June 24).
|
|
|
|
|
|
first_counted = June 24.
|
|
|
|
|
|
n_days = 1. Today (June 25) is past June 24 → elapsed.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
|
|
eff_utc = _ams_midnight(2026, 6, 1)
|
|
|
|
|
|
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
|
|
|
|
|
|
|
|
|
|
|
start_utc = datetime(2026, 6, 24, 8, 0, 0, tzinfo=_UTC) # 10:00 CEST = mid-morning June 24
|
|
|
|
|
|
end_utc = datetime(2026, 6, 24, 22, 0, 0, tzinfo=_UTC) # = June 25 00:00 CEST midnight
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
|
|
|
|
|
result = self._run_summarize_ams(energy_db, start_utc, end_utc)
|
|
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
# Exactly 1 day: June 24 (start day, counted as full day; elapsed since today is June 25).
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: mid-morning to midnight window on one day should count 1 day; "
|
|
|
|
|
|
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
|
|
|
|
|
f"FUE-T01: mid-morning to midnight window on one day should count 1 day of credits; "
|
|
|
|
|
|
f"expected {daily_credit:.6f}, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-25 11:13:30 +02:00
|
|
|
|
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 8. compute_closed_periods
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComputeClosedPeriods:
|
|
|
|
|
|
def test_skips_future_periods(self, energy_db: Session) -> None:
|
2026-06-25 15:38:21 +02:00
|
|
|
|
"""Periods whose t1 > now must not be computed.
|
|
|
|
|
|
|
|
|
|
|
|
The meter covers from before the lookback window so that all past periods
|
|
|
|
|
|
have a meter; the contract effective_from=now ensures that every past
|
|
|
|
|
|
period's contract-version lookup returns None, causing them all to be
|
|
|
|
|
|
skipped (no row written). Only the current open period [now, now+15min)
|
|
|
|
|
|
is a "future" period — and the normal tick never writes it.
|
|
|
|
|
|
|
|
|
|
|
|
Written = 0 confirms that no row was written (neither the future period
|
|
|
|
|
|
nor any past period without a contract).
|
|
|
|
|
|
"""
|
|
|
|
|
|
now = datetime.now(_UTC)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=now)
|
|
|
|
|
|
# Meter covering from well before the 7-day lookback window so every past
|
|
|
|
|
|
# period has a meter. Without a matching contract version those past
|
|
|
|
|
|
# periods are all skipped (not written as degraded).
|
|
|
|
|
|
_make_meter(energy_db, started_at=now - timedelta(days=10), ended_at=None)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# The period [now, now+15min) is still open — should not be computed.
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# All closed past periods: meter OK, but no contract version → skip, no write.
|
2026-06-23 22:26:56 +02:00
|
|
|
|
written = compute_closed_periods(energy_db)
|
|
|
|
|
|
assert written == 0 # nothing written (no closed periods with data)
|
|
|
|
|
|
|
|
|
|
|
|
def test_does_not_overwrite_successful_period(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A non-degraded row must not be overwritten by the regular tick.
|
|
|
|
|
|
|
|
|
|
|
|
Uses a period from 2 days ago so it is definitely closed and within
|
|
|
|
|
|
the 7-day lookback window.
|
|
|
|
|
|
"""
|
|
|
|
|
|
past_t0 = floor_to_quarter(datetime.now(_UTC) - timedelta(days=2))
|
|
|
|
|
|
past_t1 = past_t0 + timedelta(minutes=15)
|
|
|
|
|
|
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=past_t0 - timedelta(hours=1))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# Meter covering from before past_t0.
|
|
|
|
|
|
_make_meter(energy_db, started_at=past_t0 - timedelta(hours=1), ended_at=None)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=past_t0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=past_t1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# First compute: direct call.
|
|
|
|
|
|
compute_period(energy_db, past_t0)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
row_before = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == past_t0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
original_computed_at = row_before.computed_at
|
|
|
|
|
|
|
|
|
|
|
|
# compute_closed_periods runs — the row should NOT be touched.
|
|
|
|
|
|
compute_closed_periods(energy_db)
|
|
|
|
|
|
|
|
|
|
|
|
row_after = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == past_t0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_after.computed_at == original_computed_at
|
|
|
|
|
|
|
|
|
|
|
|
def test_retries_degraded_rows(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A degraded row is retried when readings become available on the next tick.
|
|
|
|
|
|
|
|
|
|
|
|
We use a period from 2 days ago so it is definitely closed and within the
|
|
|
|
|
|
7-day lookback window, regardless of what time the test runs today.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Use a period that is definitely closed (2 days ago, early morning UTC).
|
|
|
|
|
|
past_t0 = floor_to_quarter(datetime.now(_UTC) - timedelta(days=2))
|
|
|
|
|
|
past_t1 = past_t0 + timedelta(minutes=15)
|
|
|
|
|
|
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=past_t0 - timedelta(hours=1))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# Meter covering from before past_t0.
|
|
|
|
|
|
_make_meter(energy_db, started_at=past_t0 - timedelta(hours=1), ended_at=None)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
# First compute: no readings → degraded.
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
compute_period(energy_db, past_t0)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
row_before = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == past_t0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_before.degraded is True
|
|
|
|
|
|
|
|
|
|
|
|
# Now add both boundary readings.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=past_t0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=past_t1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# compute_closed_periods should retry the degraded row.
|
|
|
|
|
|
compute_closed_periods(energy_db)
|
|
|
|
|
|
|
|
|
|
|
|
row_after = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == past_t0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_after.degraded is False
|
|
|
|
|
|
assert row_after.import_cost > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 9. recompute_range
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRecomputeRange:
|
|
|
|
|
|
def test_overwrites_existing_rows(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""recompute_range must overwrite non-degraded rows (explicit opt-in)."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Initial compute.
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Simulate "new" end reading with higher values.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1 - timedelta(seconds=5),
|
|
|
|
|
|
d1="20020.0", d2="10020.0", r1="5000.0", r2="3000.0",
|
|
|
|
|
|
source_id=77)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
count = recompute_range(energy_db, _T0, _T1)
|
|
|
|
|
|
assert count == 1
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# The row must have been updated (import_cost changes because deltas changed).
|
|
|
|
|
|
assert row.degraded is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_returns_count_of_written_periods(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""recompute_range returns the number of periods actually written."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
|
|
|
|
|
|
# Add readings for two consecutive periods: [10:00,10:15), [10:15,10:30).
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
t2 = _ts(10, 30)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=t2,
|
|
|
|
|
|
d1=str(float(_END_D1) + 0.5),
|
|
|
|
|
|
d2=str(float(_END_D2) + 1.2),
|
|
|
|
|
|
r1=_END_R1, r2=str(float(_END_R2) + 0.1),
|
|
|
|
|
|
source_id=3)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
count = recompute_range(energy_db, _T0, t2)
|
|
|
|
|
|
assert count == 2
|
|
|
|
|
|
|
|
|
|
|
|
def test_idempotent_on_multiple_calls(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Calling recompute_range twice must not create duplicate rows."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
recompute_range(energy_db, _T0, _T1)
|
|
|
|
|
|
recompute_range(energy_db, _T0, _T1)
|
|
|
|
|
|
|
|
|
|
|
|
rows = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalars().all()
|
|
|
|
|
|
assert len(rows) == 1
|
|
|
|
|
|
|
|
|
|
|
|
def test_recompute_downgrades_successful_row_when_readings_disappear(
|
|
|
|
|
|
self, energy_db: Session
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""recompute_range must downgrade a previously successful row to degraded
|
|
|
|
|
|
when boundary readings no longer exist.
|
|
|
|
|
|
|
|
|
|
|
|
Scenario (reproduces REWORK 1 from reviewer probe3.py):
|
|
|
|
|
|
1. Compute period [10:00, 10:15) successfully — row is non-degraded, costs > 0.
|
|
|
|
|
|
2. Delete both boundary readings (simulate data loss / correction).
|
|
|
|
|
|
3. Call recompute_range over that window (overwrite=True path).
|
|
|
|
|
|
4. Row must now be degraded=True, all cost/kWh fields = 0,
|
|
|
|
|
|
contract_version_id = None, pricing = {}.
|
|
|
|
|
|
|
|
|
|
|
|
This verifies the "缺读数→degraded" contract holds even for the explicit
|
|
|
|
|
|
recompute path, i.e. stale successful values are never silently preserved.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Step 1: successful compute.
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-23 22:26:56 +02:00
|
|
|
|
start_reading = _make_reading(
|
|
|
|
|
|
energy_db, recorded_at=_T0,
|
|
|
|
|
|
d1=_START_D1, d2=_START_D2, r1=_START_R1, r2=_START_R2, source_id=1,
|
|
|
|
|
|
)
|
|
|
|
|
|
end_reading = _make_reading(
|
|
|
|
|
|
energy_db, recorded_at=_T1,
|
|
|
|
|
|
d1=_END_D1, d2=_END_D2, r1=_END_R1, r2=_END_R2, source_id=2,
|
|
|
|
|
|
)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
row_initial = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
# Pre-condition: row is successful with non-zero import_cost.
|
|
|
|
|
|
assert row_initial.degraded is False
|
|
|
|
|
|
assert row_initial.import_cost > 0, "Pre-condition: import_cost must be non-zero"
|
|
|
|
|
|
assert row_initial.contract_version_id is not None
|
|
|
|
|
|
|
|
|
|
|
|
# Step 2: delete the boundary readings to simulate data loss.
|
|
|
|
|
|
energy_db.delete(start_reading)
|
|
|
|
|
|
energy_db.delete(end_reading)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Step 3: explicit recompute (overwrite=True path).
|
|
|
|
|
|
count = recompute_range(energy_db, _T0, _T1)
|
|
|
|
|
|
assert count == 1, "recompute_range must report one period written (degraded)"
|
|
|
|
|
|
|
|
|
|
|
|
# Step 4: row must now reflect degraded state — no stale costs.
|
|
|
|
|
|
energy_db.expire_all()
|
|
|
|
|
|
row_after = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_after.degraded is True
|
|
|
|
|
|
assert row_after.import_cost == 0.0
|
|
|
|
|
|
assert row_after.export_revenue == 0.0
|
|
|
|
|
|
assert row_after.net_cost == 0.0
|
|
|
|
|
|
assert row_after.d1_kwh == 0.0
|
|
|
|
|
|
assert row_after.d2_kwh == 0.0
|
|
|
|
|
|
assert row_after.r1_kwh == 0.0
|
|
|
|
|
|
assert row_after.r2_kwh == 0.0
|
|
|
|
|
|
assert row_after.contract_version_id is None
|
|
|
|
|
|
assert row_after.pricing == {}
|
2026-06-24 12:44:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Bug-fix regression tests: register_at freshness + recompute_range future guard
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRegisterAtFreshness:
|
|
|
|
|
|
"""Tests for the staleness guard added to register_at (Fix A).
|
|
|
|
|
|
|
|
|
|
|
|
Normal DSMR readings arrive every ~10 seconds. A reading more than
|
|
|
|
|
|
``_READING_MAX_STALENESS`` (15 minutes) old at a given boundary is treated
|
|
|
|
|
|
as absent so the period is marked degraded rather than producing a
|
|
|
|
|
|
zero-delta fake-success row.
|
2026-06-25 15:38:21 +02:00
|
|
|
|
|
|
|
|
|
|
All tests now pass a meter that covers the reading and boundary times.
|
2026-06-24 12:44:05 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_stale_reading_returns_none(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A reading older than 15 min before the boundary must be rejected."""
|
|
|
|
|
|
reading_time = _ts(10, 0) # 10:00
|
|
|
|
|
|
boundary = _ts(10, 30) # 10:30 — 30 min later (> 15 min staleness)
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=reading_time, source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, boundary, meter)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
assert result is None, (
|
|
|
|
|
|
"register_at must return None when the closest reading is more than "
|
|
|
|
|
|
"15 minutes before the boundary"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_fresh_reading_within_window_returned(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A reading within 15 min of the boundary must be returned normally."""
|
|
|
|
|
|
reading_time = _ts(10, 5) # 10:05
|
|
|
|
|
|
boundary = _ts(10, 15) # 10:15 — only 10 min gap (within window)
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=reading_time,
|
|
|
|
|
|
d1="30000.0", d2="15000.0", r1="1000.0", r2="500.0",
|
|
|
|
|
|
source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, boundary, meter)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
assert result is not None, (
|
|
|
|
|
|
"register_at must return the reading when it is within the 15-min staleness window"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert result["d1"] == Decimal("30000.0")
|
|
|
|
|
|
|
|
|
|
|
|
def test_exact_15min_boundary_is_accepted(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A reading exactly 15 min before the boundary sits at the edge — accepted."""
|
|
|
|
|
|
reading_time = _ts(10, 0) # 10:00
|
|
|
|
|
|
boundary = _ts(10, 15) # 10:15 — exactly 15 min gap
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=reading_time,
|
|
|
|
|
|
d1="40000.0", d2="20000.0", r1="2000.0", r2="1000.0",
|
|
|
|
|
|
source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, boundary, meter)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
# boundary - reading == 15 min == staleness limit → NOT stale (strict <)
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
|
|
|
|
|
|
|
def test_future_boundary_returns_none(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A far-future boundary must return None (not the latest historical reading).
|
|
|
|
|
|
|
|
|
|
|
|
This is the root cause of the production bug: without the freshness
|
|
|
|
|
|
guard, register_at(far_future) returned the last available reading,
|
|
|
|
|
|
causing both period boundaries to resolve to the same row and producing
|
|
|
|
|
|
a zero-delta fake-success period.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Insert a reading at a realistic "now" time.
|
|
|
|
|
|
reading_time = _ts(10, 0) # 10:00 on 2026-06-23
|
2026-06-25 15:38:21 +02:00
|
|
|
|
meter = _make_active_meter(energy_db)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
_make_reading(energy_db, recorded_at=reading_time, source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Query with a far-future boundary (e.g. end of month, same day +8 hours)
|
|
|
|
|
|
far_future_boundary = _ts(18, 0) # 18:00 — 8 hours later
|
2026-06-25 15:38:21 +02:00
|
|
|
|
result = register_at(energy_db, far_future_boundary, meter)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
assert result is None, (
|
|
|
|
|
|
"register_at must return None for a far-future boundary rather than "
|
|
|
|
|
|
"the latest historical reading"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFuturePeriodDegraded:
|
|
|
|
|
|
"""Ensure that a period whose boundaries fall entirely outside DSMR data
|
|
|
|
|
|
is written as degraded=True (not as a fake zero-cost success).
|
|
|
|
|
|
|
|
|
|
|
|
This covers the production scenario where recompute_range was called with
|
|
|
|
|
|
a future end date, causing compute_period to be called on not-yet-closed
|
|
|
|
|
|
periods. With Fix A (register_at freshness) the boundary lookups return
|
|
|
|
|
|
None → degraded; with Fix B (recompute_range t1<=now guard) such periods
|
|
|
|
|
|
are skipped entirely. This test exercises the Fix A path directly via
|
|
|
|
|
|
compute_period(overwrite=True) on a future-like period with no nearby data.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_out_of_range_period_is_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A period whose boundaries have no fresh DSMR readings → degraded=True.
|
|
|
|
|
|
|
|
|
|
|
|
We insert a reading at 10:00 and call compute_period for a period
|
|
|
|
|
|
starting at 14:00 (4 hours later). The boundary readings (14:00 and
|
|
|
|
|
|
14:15) are both more than 15 min from any available data, so
|
|
|
|
|
|
register_at returns None for both → degraded row written.
|
|
|
|
|
|
"""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
# Only a reading at 10:00 — far from the 14:00/14:15 boundaries.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(10, 0), source_id=1)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
future_t0 = _ts(14, 0)
|
|
|
|
|
|
result = compute_period(energy_db, future_t0, overwrite=True)
|
|
|
|
|
|
assert result is True # a record was written
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == future_t0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True, (
|
|
|
|
|
|
"compute_period must write degraded=True when both boundaries lack fresh readings"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert row.import_cost == 0.0
|
|
|
|
|
|
assert row.net_cost == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRecomputeRangeNoFuturePeriods:
|
|
|
|
|
|
"""Verify that recompute_range never writes periods whose t1 > now (Fix B)."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_recompute_range_skips_future_periods(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""When end is in the future, recompute_range must not write any future rows.
|
|
|
|
|
|
|
|
|
|
|
|
This is the direct test for Fix B. We call recompute_range with a
|
|
|
|
|
|
future end datetime; the only periods that may be written are those
|
|
|
|
|
|
where t1 <= now. No row with period_start > now should appear in the DB.
|
|
|
|
|
|
"""
|
2026-06-25 15:38:21 +02:00
|
|
|
|
now = datetime.now(UTC)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# Meter covering from before now.
|
|
|
|
|
|
_make_meter(energy_db, started_at=now - timedelta(hours=1), ended_at=None)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
# No readings needed — we only care that future rows are NOT written.
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
far_future_end = now + timedelta(days=7)
|
|
|
|
|
|
# Use a past start so there are some candidate periods.
|
|
|
|
|
|
past_start = floor_to_quarter(now - timedelta(minutes=30))
|
|
|
|
|
|
|
|
|
|
|
|
recompute_range(energy_db, past_start, far_future_end)
|
|
|
|
|
|
|
|
|
|
|
|
# Assert no row has period_start beyond now.
|
|
|
|
|
|
rows = energy_db.execute(select(EnergyCostPeriod)).scalars().all()
|
|
|
|
|
|
future_rows = [
|
|
|
|
|
|
r for r in rows
|
|
|
|
|
|
if (r.period_start if r.period_start.tzinfo else r.period_start.replace(tzinfo=UTC)) > now
|
|
|
|
|
|
]
|
|
|
|
|
|
assert future_rows == [], (
|
|
|
|
|
|
f"recompute_range must not write future periods; "
|
|
|
|
|
|
f"found {len(future_rows)} row(s) with period_start > now"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_recompute_range_chain_no_slot_squatting(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Simulate the full production failure scenario and verify it is fixed.
|
|
|
|
|
|
|
|
|
|
|
|
Scenario:
|
|
|
|
|
|
1. recompute_range is called with end=far_future (reproduces the bug trigger).
|
|
|
|
|
|
2. compute_closed_periods is then called (reproduces the normal tick).
|
|
|
|
|
|
3. After the fix, step 1 must not have written any future rows, so the
|
|
|
|
|
|
tick in step 2 is free to compute periods normally.
|
|
|
|
|
|
|
|
|
|
|
|
We only verify that no future rows exist after step 1 (Fix B), because
|
|
|
|
|
|
that is the core slot-squatting prevention.
|
|
|
|
|
|
"""
|
2026-06-25 15:38:21 +02:00
|
|
|
|
now = datetime.now(UTC)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
# Contract effective from far past so all periods in scope have a version.
|
|
|
|
|
|
_make_version(
|
|
|
|
|
|
energy_db, contract, _MANUAL_VALUES,
|
|
|
|
|
|
effective_from=datetime(2020, 1, 1, tzinfo=UTC),
|
|
|
|
|
|
)
|
2026-06-25 15:38:21 +02:00
|
|
|
|
# Meter covering from far past.
|
|
|
|
|
|
_make_meter(
|
|
|
|
|
|
energy_db,
|
|
|
|
|
|
started_at=datetime(2020, 1, 1, tzinfo=UTC),
|
|
|
|
|
|
ended_at=None,
|
|
|
|
|
|
)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
far_future = now + timedelta(days=30)
|
|
|
|
|
|
past_start = floor_to_quarter(now - timedelta(hours=1))
|
|
|
|
|
|
|
|
|
|
|
|
# Step 1: buggy call with future end.
|
|
|
|
|
|
recompute_range(energy_db, past_start, far_future)
|
|
|
|
|
|
|
|
|
|
|
|
# Verify no future rows were squatted.
|
|
|
|
|
|
all_rows = energy_db.execute(select(EnergyCostPeriod)).scalars().all()
|
|
|
|
|
|
future_rows = [
|
|
|
|
|
|
r for r in all_rows
|
|
|
|
|
|
if (r.period_start if r.period_start.tzinfo else r.period_start.replace(tzinfo=UTC)) > now
|
|
|
|
|
|
]
|
|
|
|
|
|
assert future_rows == [], (
|
|
|
|
|
|
f"After recompute_range with future end, found {len(future_rows)} future row(s). "
|
|
|
|
|
|
"These would block the real tick from computing those periods."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestNormalPeriodsUnaffected:
|
|
|
|
|
|
"""Regression: the freshness guard must not break computation of normal
|
|
|
|
|
|
closed periods where readings are available close to the boundaries.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_normal_closed_period_still_computes_ok(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A normal period with readings seconds before each boundary → non-degraded."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
2026-06-25 15:38:21 +02:00
|
|
|
|
_make_active_meter(energy_db)
|
2026-06-24 12:44:05 +02:00
|
|
|
|
|
|
|
|
|
|
# Readings placed very close to boundaries (as DSMR normally delivers them).
|
|
|
|
|
|
# t0=10:00, reading at 09:59:50 (10 s before); t1=10:15, reading at 10:14:55 (5 s before)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(9, 59, 50),
|
|
|
|
|
|
d1=_START_D1, d2=_START_D2, r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_ts(10, 14, 55),
|
|
|
|
|
|
d1=_END_D1, d2=_END_D2, r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is False, (
|
|
|
|
|
|
"A normal period with readings just before each boundary must not be degraded"
|
|
|
|
|
|
)
|
|
|
|
|
|
# import_cost matches hand-calc: same deltas as standard scenario
|
|
|
|
|
|
assert abs(row.import_cost - 0.4101) < 1e-6
|
2026-06-25 15:38:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# M7-T03 New tests: meter-aware billing engine
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMeterAwareComputePeriod:
|
|
|
|
|
|
"""M7-T03 Acceptance criteria: meter-aware compute_period behaviour.
|
|
|
|
|
|
|
|
|
|
|
|
Covers:
|
|
|
|
|
|
① Same-meter period: delta computed correctly, meter_id attributed.
|
|
|
|
|
|
② Cross-meter boundary period: degraded.
|
|
|
|
|
|
③ No active meter coverage: degraded with meter_id=None.
|
|
|
|
|
|
④ Negative delta: degraded (D6 guard — no negative costs).
|
|
|
|
|
|
⑤ Super-large delta exceeding _MAX_DELTA_KWH: degraded (D6 guard).
|
|
|
|
|
|
⑥ Recompute re-judges meter attribution.
|
|
|
|
|
|
⑦ No-contract skip preserved inside single-meter path.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
# ① Same-meter period: delta correct + meter_id attributed
|
|
|
|
|
|
def test_same_meter_period_computes_correctly_with_meter_id(
|
|
|
|
|
|
self, energy_db: Session
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""A normal period within a single meter epoch: costs correct, meter_id set."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
meter = _make_active_meter(energy_db)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
|
|
|
|
|
|
# Cost correctness (same as manual scenario hand-calc).
|
|
|
|
|
|
assert row.degraded is False
|
|
|
|
|
|
assert abs(row.import_cost - 0.4101) < 1e-9
|
|
|
|
|
|
assert abs(row.export_revenue - 0.005) < 1e-9
|
|
|
|
|
|
assert abs(row.net_cost - 0.4051) < 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
# meter_id must be set to the active meter's id.
|
|
|
|
|
|
assert row.meter_id == meter.id, (
|
|
|
|
|
|
f"Expected meter_id={meter.id}, got {row.meter_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ② Cross-meter boundary: degraded
|
|
|
|
|
|
def test_cross_meter_boundary_period_is_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A period spanning two meter epochs must be written as degraded.
|
|
|
|
|
|
|
|
|
|
|
|
Scenario:
|
|
|
|
|
|
- Old meter: [09:00, 10:15) — covers t0=10:00 but NOT t1=10:15.
|
|
|
|
|
|
- New meter: [10:15, ∞) — covers t1=10:15.
|
|
|
|
|
|
- Period [10:00, 10:15): m0 != m1 → degraded with meter_id=m0.id.
|
|
|
|
|
|
"""
|
|
|
|
|
|
old_meter = _make_meter(energy_db, started_at=_ts(9, 0), ended_at=_T1)
|
|
|
|
|
|
new_meter = _make_meter(energy_db, started_at=_T1, ended_at=None)
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
# Readings exist, but the period still spans two meters.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True # a row was written
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True, (
|
|
|
|
|
|
"A period spanning two meter epochs must be written as degraded"
|
|
|
|
|
|
)
|
|
|
|
|
|
# meter_id should be m0 (t0's meter), not m1.
|
|
|
|
|
|
assert row.meter_id == old_meter.id, (
|
|
|
|
|
|
f"Cross-meter degraded row should attribute to old_meter (id={old_meter.id}), "
|
|
|
|
|
|
f"got meter_id={row.meter_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
# No real cost should be produced.
|
|
|
|
|
|
assert row.import_cost == 0.0
|
|
|
|
|
|
assert row.net_cost == 0.0
|
|
|
|
|
|
# Suppress unused variable warning.
|
|
|
|
|
|
_ = new_meter
|
|
|
|
|
|
|
|
|
|
|
|
# ③ No active meter coverage: degraded with meter_id=None
|
|
|
|
|
|
def test_no_meter_coverage_is_degraded_with_null_meter_id(
|
|
|
|
|
|
self, energy_db: Session
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""When no meter epoch covers t0, the period is written as degraded(meter_id=None)."""
|
|
|
|
|
|
# No meter inserted at all.
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True, "No-meter period must be written as degraded"
|
|
|
|
|
|
assert row.meter_id is None, (
|
|
|
|
|
|
"No-meter degraded row must have meter_id=None, "
|
|
|
|
|
|
f"got meter_id={row.meter_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert row.import_cost == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# ④ Negative delta: degraded (D6 guard)
|
|
|
|
|
|
def test_negative_delta_is_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A period with any negative register delta → degraded (D6 guard).
|
|
|
|
|
|
|
|
|
|
|
|
Negative deltas occur after a meter reset or DSMR rollover and must
|
|
|
|
|
|
never produce a negative cost row.
|
|
|
|
|
|
"""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
_make_active_meter(energy_db)
|
|
|
|
|
|
# End reading has LOWER d1 than start → negative delta for d1.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1="20000.500", d2="10000.000",
|
|
|
|
|
|
r1="5000.000", r2="3000.000", source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1="20000.000", d2="10001.200",
|
|
|
|
|
|
r1="5000.000", r2="3000.100", source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True, (
|
|
|
|
|
|
"A period with a negative delta must be written as degraded (no negative costs)"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert row.import_cost == 0.0
|
|
|
|
|
|
assert row.net_cost == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# ⑤ Super-large delta: degraded (D6 guard)
|
|
|
|
|
|
def test_super_large_delta_is_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A period with a delta > _MAX_DELTA_KWH → degraded (D6 guard).
|
|
|
|
|
|
|
|
|
|
|
|
Implausibly large deltas indicate an anomaly (wrong scale, DSMR
|
|
|
|
|
|
reporting bug) and must never produce a grossly inflated cost row.
|
|
|
|
|
|
"""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
_make_active_meter(energy_db)
|
|
|
|
|
|
# d1 delta = 200 kWh >> _MAX_DELTA_KWH (100 kWh).
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
|
|
|
|
|
|
r1="5000.000", r2="3000.000", source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1="10200.000", d2="10000.000",
|
|
|
|
|
|
r1="5000.000", r2="3000.000", source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True, (
|
|
|
|
|
|
f"A delta > {_MAX_DELTA_KWH} kWh must be written as degraded (D6 guard)"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert row.import_cost == 0.0
|
|
|
|
|
|
assert row.net_cost == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# ⑤b Delta exactly at limit is NOT degraded (guard fires at strictly >)
|
|
|
|
|
|
def test_delta_exactly_at_max_limit_not_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A delta exactly equal to _MAX_DELTA_KWH is NOT degraded.
|
|
|
|
|
|
|
|
|
|
|
|
The guard condition is strictly > _MAX_DELTA_KWH, so a delta of exactly
|
|
|
|
|
|
100 kWh passes through and is computed normally. This is intentional:
|
|
|
|
|
|
the threshold is set far above any plausible residential consumption
|
|
|
|
|
|
(400 kW average over 15 min) to avoid false positives.
|
|
|
|
|
|
"""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
_make_active_meter(energy_db)
|
|
|
|
|
|
# d1 delta = _MAX_DELTA_KWH exactly (100 kWh) — must NOT degrade.
|
|
|
|
|
|
max_delta = float(_MAX_DELTA_KWH)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
|
|
|
|
|
|
r1="5000.000", r2="3000.000", source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=str(10000.0 + max_delta),
|
|
|
|
|
|
d2="10000.000", r1="5000.000", r2="3000.000", source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is False, (
|
|
|
|
|
|
f"A delta exactly at _MAX_DELTA_KWH ({max_delta} kWh) must NOT trigger "
|
|
|
|
|
|
"the D6 guard (guard condition is strictly >)"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert row.import_cost > 0
|
|
|
|
|
|
|
|
|
|
|
|
# ⑤c Delta strictly above limit IS degraded
|
|
|
|
|
|
def test_delta_strictly_above_max_limit_is_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A delta strictly greater than _MAX_DELTA_KWH → degraded (D6 guard fires)."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
_make_active_meter(energy_db)
|
|
|
|
|
|
# d1 delta = _MAX_DELTA_KWH + 0.001 (strictly over threshold) → degraded.
|
|
|
|
|
|
max_delta_plus = float(_MAX_DELTA_KWH) + 0.001
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
|
|
|
|
|
|
r1="5000.000", r2="3000.000", source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=str(10000.0 + max_delta_plus),
|
|
|
|
|
|
d2="10000.000", r1="5000.000", r2="3000.000", source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True, (
|
|
|
|
|
|
f"A delta of {max_delta_plus} kWh (> _MAX_DELTA_KWH) must trigger D6 guard"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ⑤c Delta just below limit is NOT degraded
|
|
|
|
|
|
def test_delta_just_below_max_limit_not_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A delta just below _MAX_DELTA_KWH must NOT trigger the D6 guard."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
_make_active_meter(energy_db)
|
|
|
|
|
|
# d1 delta = 99.999 kWh — just under 100, should compute normally.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
|
|
|
|
|
|
r1="5000.000", r2="3000.000", source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1="10099.999", d2="10000.000",
|
|
|
|
|
|
r1="5000.000", r2="3000.000", source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is False, (
|
|
|
|
|
|
"A delta of 99.999 kWh must NOT trigger the D6 guard (guard fires at > 100 kWh)"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert row.import_cost > 0
|
|
|
|
|
|
|
|
|
|
|
|
# ⑥ recompute re-judges meter attribution
|
|
|
|
|
|
def test_recompute_re_judges_meter_attribution(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""recompute_range must re-attribute meter_id to the current meter_at judgment.
|
|
|
|
|
|
|
|
|
|
|
|
Scenario:
|
|
|
|
|
|
1. Compute period [10:00, 10:15) with meter M1 active → row has meter_id=M1.id.
|
|
|
|
|
|
2. Retroactively close M1 at 10:00 and open M2 from 09:00 (so M2 covers both
|
|
|
|
|
|
boundaries, by updating the meter row directly — simulating update_meter).
|
|
|
|
|
|
3. Call recompute_range → row's meter_id must now be M2.id.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Step 1: initial compute with M1.
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
m1 = _make_active_meter(energy_db)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
compute_period(energy_db, _T0)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
row_before = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_before.meter_id == m1.id, "Pre-condition: meter_id should be m1.id"
|
|
|
|
|
|
|
|
|
|
|
|
# Step 2: retroactively replace M1 with M2.
|
|
|
|
|
|
# Close M1 (make it a zero-width closed epoch before any test reading).
|
|
|
|
|
|
# Open M2 covering the whole day.
|
|
|
|
|
|
m1.ended_at = datetime(2026, 6, 22, 0, 0, 0, tzinfo=_UTC) # before test day
|
|
|
|
|
|
m2 = _make_meter(
|
|
|
|
|
|
energy_db,
|
|
|
|
|
|
started_at=datetime(2026, 6, 22, 0, 0, 0, tzinfo=_UTC),
|
|
|
|
|
|
ended_at=None,
|
|
|
|
|
|
label="Replacement Meter",
|
|
|
|
|
|
)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# Step 3: recompute the range — should re-attribute to m2.
|
|
|
|
|
|
count = recompute_range(energy_db, _T0, _T1)
|
|
|
|
|
|
assert count == 1
|
|
|
|
|
|
|
|
|
|
|
|
energy_db.expire_all()
|
|
|
|
|
|
row_after = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row_after.meter_id == m2.id, (
|
|
|
|
|
|
f"After recompute, meter_id should be m2.id={m2.id}, "
|
|
|
|
|
|
f"got {row_after.meter_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert row_after.degraded is False
|
|
|
|
|
|
|
|
|
|
|
|
# ⑦ No-contract skip preserved inside single-meter path
|
|
|
|
|
|
def test_no_contract_is_skip_not_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Single-meter path with no active contract → skip (no row written).
|
|
|
|
|
|
|
|
|
|
|
|
This ensures that 'meter OK, contract missing' still produces a skip
|
|
|
|
|
|
(not a degraded row), preserving the existing skip semantics.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Active meter but NO contract.
|
|
|
|
|
|
_make_active_meter(energy_db)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
|
|
|
|
|
|
r1=_END_R1, r2=_END_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is False, (
|
|
|
|
|
|
"Single-meter, no-contract period must be skipped (return False, no row)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rows = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalars().all()
|
|
|
|
|
|
assert len(rows) == 0, (
|
|
|
|
|
|
"No EnergyCostPeriod row must be written when skipping due to missing contract"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Degraded within-meter rows get the correct meter_id
|
|
|
|
|
|
def test_degraded_within_meter_has_meter_id(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""Degraded rows due to missing readings within a known meter get meter_id set."""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
meter = _make_active_meter(energy_db)
|
|
|
|
|
|
# No readings at all → will degrade due to missing readings.
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is True
|
|
|
|
|
|
assert row.meter_id == meter.id, (
|
|
|
|
|
|
f"Within-meter degraded row must carry meter_id={meter.id}, "
|
|
|
|
|
|
f"got {row.meter_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Delta sanity guard with all-zero deltas (zero is fine, not negative)
|
|
|
|
|
|
def test_zero_delta_not_degraded(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""A period with all-zero deltas (no energy used) must NOT be degraded.
|
|
|
|
|
|
|
|
|
|
|
|
Zero is valid — perfectly matching start/end readings means no energy
|
|
|
|
|
|
was consumed or produced in that window.
|
|
|
|
|
|
"""
|
|
|
|
|
|
contract = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
|
|
|
|
|
|
_make_active_meter(energy_db)
|
|
|
|
|
|
# Identical start and end readings → all deltas = 0.
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=1)
|
|
|
|
|
|
_make_reading(energy_db, recorded_at=_T1, d1=_START_D1, d2=_START_D2,
|
|
|
|
|
|
r1=_START_R1, r2=_START_R2, source_id=2)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
result = compute_period(energy_db, _T0)
|
|
|
|
|
|
assert result is True
|
|
|
|
|
|
|
|
|
|
|
|
row = energy_db.execute(
|
|
|
|
|
|
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
|
|
|
|
|
|
assert row.import_cost == 0.0
|
|
|
|
|
|
assert row.net_cost == 0.0
|
2026-06-26 11:44:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# FUE-T08. summarize — settlement offset (local 01:05)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSummarizeSettlementOffset:
|
|
|
|
|
|
"""FUE-T08: daily fixed-fee/heffingskorting settled after local 01:05.
|
|
|
|
|
|
|
|
|
|
|
|
Reference date: June 26, 2026 AMS (CEST = UTC+2).
|
|
|
|
|
|
- AMS midnight June 26 = June 25 22:00 UTC
|
|
|
|
|
|
- Settlement threshold = June 25 22:00 + 01:05 = June 25 23:05 UTC
|
|
|
|
|
|
= June 26 01:05 AMS
|
|
|
|
|
|
- "before offset" now = June 26 00:30 AMS = June 25 22:30 UTC
|
|
|
|
|
|
- "after offset" now = June 26 01:10 AMS = June 25 23:10 UTC
|
|
|
|
|
|
|
|
|
|
|
|
All tests monkeypatch both local_tz (Europe/Amsterdam) and
|
|
|
|
|
|
``app.services.energy_cost.local_now`` to be fully deterministic.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
# UTC instants used as "now":
|
|
|
|
|
|
# June 25 22:30 UTC = June 26 00:30 AMS (before settlement threshold 23:05 UTC)
|
|
|
|
|
|
_NOW_BEFORE = datetime(2026, 6, 25, 22, 30, 0, tzinfo=UTC)
|
|
|
|
|
|
# June 25 23:10 UTC = June 26 01:10 AMS (after settlement threshold 23:05 UTC)
|
|
|
|
|
|
_NOW_AFTER = datetime(2026, 6, 25, 23, 10, 0, tzinfo=UTC)
|
|
|
|
|
|
|
|
|
|
|
|
def _setup_single_version_contract(self, session: Session) -> None:
|
|
|
|
|
|
"""Insert a manual contract effective Jan 1 2026 (covers all test dates)."""
|
|
|
|
|
|
c = _make_contract(session, kind="manual", active=True)
|
|
|
|
|
|
_make_version(session, c, _MANUAL_VALUES, effective_from=_ams_midnight(2026, 1, 1))
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
def _run(
|
|
|
|
|
|
self,
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
start_utc: datetime,
|
|
|
|
|
|
end_utc: datetime,
|
|
|
|
|
|
*,
|
|
|
|
|
|
now_utc: datetime,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Run summarize with AMS timezone and pinned local_now.
|
|
|
|
|
|
|
|
|
|
|
|
*now_utc* is converted to AMS before being used as the ``local_now``
|
|
|
|
|
|
return value, so ``.date()`` yields the correct AMS local date.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
import app.services.energy_cost as _ec
|
|
|
|
|
|
|
|
|
|
|
|
now_ams = now_utc.astimezone(_ams())
|
|
|
|
|
|
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
|
|
|
|
|
with patch.object(_ec, "local_now", return_value=now_ams):
|
|
|
|
|
|
return summarize(session, start_utc, end_utc)
|
|
|
|
|
|
|
|
|
|
|
|
# --- AC1: before 01:05 → today not settled → fixed_costs/credits == 0 ---
|
|
|
|
|
|
|
|
|
|
|
|
def test_today_window_before_offset_yields_zero(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""AC1: At local 00:30 (before 01:05), today's window → credits == 0 and fixed_costs == 0.
|
|
|
|
|
|
|
|
|
|
|
|
Window: [June 26 AMS midnight, June 27 AMS midnight).
|
|
|
|
|
|
now = June 26 00:30 AMS (before 01:05) → settled_cap = June 25.
|
|
|
|
|
|
first_counted = June 26 > last_counted = June 25 → 0 days.
|
|
|
|
|
|
"""
|
|
|
|
|
|
self._setup_single_version_contract(energy_db)
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
|
|
|
|
|
end = _ams_midnight(2026, 6, 27) # June 26 22:00 UTC
|
|
|
|
|
|
|
|
|
|
|
|
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
|
|
|
|
|
|
|
|
|
|
|
assert result["fixed_costs"] == 0.0, (
|
|
|
|
|
|
"AC1: before settlement offset, today's fixed_costs must be 0; "
|
|
|
|
|
|
f"got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert result["credits"] == 0.0, (
|
|
|
|
|
|
"AC1: before settlement offset, today's credits must be 0; "
|
|
|
|
|
|
f"got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# --- AC2: >= 01:05 → today settled → 1 day fixed/credits ---
|
|
|
|
|
|
|
|
|
|
|
|
def test_today_window_after_offset_yields_one_day(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""AC2: At local 01:10 (>= 01:05), today's window → 1 day of fixed/credits.
|
|
|
|
|
|
|
|
|
|
|
|
Window: [June 26 AMS midnight, June 27 AMS midnight).
|
|
|
|
|
|
now = June 26 01:10 AMS (after 01:05) → settled_cap = June 26.
|
|
|
|
|
|
first_counted = June 26 = last_counted → 1 day.
|
|
|
|
|
|
"""
|
|
|
|
|
|
self._setup_single_version_contract(energy_db)
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 26)
|
|
|
|
|
|
end = _ams_midnight(2026, 6, 27)
|
|
|
|
|
|
|
|
|
|
|
|
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
|
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed) < 1e-9, (
|
|
|
|
|
|
"AC2: after settlement offset, today's fixed_costs must equal 1 day; "
|
|
|
|
|
|
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit) < 1e-9, (
|
|
|
|
|
|
"AC2: after settlement offset, today's credits must equal 1 day; "
|
|
|
|
|
|
f"expected {daily_credit:.6f}, got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# --- AC3a: cumulative window, before offset → today (June 26) not counted ---
|
|
|
|
|
|
|
|
|
|
|
|
def test_cumulative_before_offset_excludes_today(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""AC3: Cumulative window at 00:30 (before offset) excludes today from count.
|
|
|
|
|
|
|
|
|
|
|
|
Window: [June 24 AMS midnight, June 26 00:30 AMS).
|
|
|
|
|
|
first_counted = June 24.
|
|
|
|
|
|
last_counted (from window) = June 26 (lmu(June 26) = June 25 22:00 < 22:30).
|
|
|
|
|
|
settled_cap = June 25 (before offset) → min(June 26, June 25) = June 25.
|
|
|
|
|
|
Counted: June 24 + June 25 = 2 days (today June 26 excluded).
|
|
|
|
|
|
"""
|
|
|
|
|
|
self._setup_single_version_contract(energy_db)
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
|
|
|
|
|
end = self._NOW_BEFORE # June 25 22:30 UTC = June 26 00:30 AMS
|
|
|
|
|
|
|
|
|
|
|
|
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
|
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
|
|
|
|
|
|
"AC3 before offset: today (June 26) must not be counted; expected 2 days; "
|
|
|
|
|
|
f"got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
|
|
|
|
|
|
"AC3 before offset: credits must be 2 days; "
|
|
|
|
|
|
f"got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# --- AC3b: cumulative window, after offset → today (June 26) counted ---
|
|
|
|
|
|
|
|
|
|
|
|
def test_cumulative_after_offset_includes_today(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""AC3: Cumulative window at 01:10 (after offset) includes today.
|
|
|
|
|
|
|
|
|
|
|
|
Window: [June 24 AMS midnight, June 26 01:10 AMS).
|
|
|
|
|
|
first_counted = June 24.
|
|
|
|
|
|
last_counted (from window) = June 26 (lmu(June 26) < 23:10 UTC).
|
|
|
|
|
|
settled_cap = June 26 (after offset) → last_counted = June 26.
|
|
|
|
|
|
Counted: June 24 + June 25 + June 26 = 3 days.
|
|
|
|
|
|
"""
|
|
|
|
|
|
self._setup_single_version_contract(energy_db)
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
|
|
|
|
|
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
|
|
|
|
|
|
|
|
|
|
|
|
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
|
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 3) < 1e-9, (
|
|
|
|
|
|
"AC3 after offset: today (June 26) must be counted; expected 3 days; "
|
|
|
|
|
|
f"got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 3) < 1e-9, (
|
|
|
|
|
|
"AC3 after offset: credits must be 3 days; "
|
|
|
|
|
|
f"got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# --- AC4: past days always fully counted regardless of offset ---
|
|
|
|
|
|
|
|
|
|
|
|
def test_past_days_always_counted_regardless_of_offset(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""AC4: Past days (all before today) are fully counted even before 01:05.
|
|
|
|
|
|
|
|
|
|
|
|
Window: [June 20 AMS midnight, June 26 AMS midnight) — all before today.
|
|
|
|
|
|
now = June 26 00:30 AMS (before offset) → settled_cap = June 25.
|
|
|
|
|
|
last_counted (from window) = June 25 (lmu(June 26) = end_utc, not <).
|
|
|
|
|
|
min(June 25, June 25) = June 25 → 6 days (June 20-25), all past.
|
|
|
|
|
|
"""
|
|
|
|
|
|
self._setup_single_version_contract(energy_db)
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 20) # June 19 22:00 UTC
|
|
|
|
|
|
end = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
|
|
|
|
|
|
|
|
|
|
|
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
|
|
|
|
|
|
|
|
|
|
|
|
daily_fixed = (9.87 + 9.87) / 30
|
|
|
|
|
|
daily_credit = 600.0 / 365
|
|
|
|
|
|
# June 20-25 = 6 days, all past — unaffected by settlement offset.
|
|
|
|
|
|
assert abs(result["fixed_costs"] - daily_fixed * 6) < 1e-9, (
|
|
|
|
|
|
"AC4: past days must be fully counted regardless of settlement offset; "
|
|
|
|
|
|
f"expected 6 days = {daily_fixed * 6:.6f}, got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - daily_credit * 6) < 1e-9, (
|
|
|
|
|
|
"AC4: past credits must be 6 days; "
|
|
|
|
|
|
f"got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# --- AC5: cross-version segments still correct with settlement offset ---
|
|
|
|
|
|
|
|
|
|
|
|
def test_cross_version_with_settlement_offset(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""AC5: Cross-version day split is correct when settlement offset is active.
|
|
|
|
|
|
|
|
|
|
|
|
V1 covers June 24; V2 covers June 25+.
|
|
|
|
|
|
Window: [June 24 AMS midnight, June 26 01:10 AMS).
|
|
|
|
|
|
After offset (01:10) → settled_cap = June 26 → 3 days: V1=1, V2=2.
|
|
|
|
|
|
|
|
|
|
|
|
V1: network_fee=6, management_fee=6 → daily_fixed = 12/30
|
|
|
|
|
|
heffingskorting=300 → daily_credit = 300/365
|
|
|
|
|
|
V2: network_fee=12, management_fee=12 → daily_fixed = 24/30
|
|
|
|
|
|
heffingskorting=600 → daily_credit = 600/365
|
|
|
|
|
|
|
|
|
|
|
|
Expected:
|
|
|
|
|
|
fixed_costs = 1×(12/30) + 2×(24/30) = 0.4 + 1.6 = 2.0
|
|
|
|
|
|
credits = 1×(300/365) + 2×(600/365) = 1500/365
|
|
|
|
|
|
"""
|
|
|
|
|
|
_VALUES_V1 = {
|
|
|
|
|
|
"energy": {"buy": {"normal": 0.10, "dal": 0.10},
|
|
|
|
|
|
"sell": {"normal": 0.05, "dal": 0.05},
|
|
|
|
|
|
"energy_tax": 0.0, "ode": 0.0},
|
|
|
|
|
|
"standing": {"network_fee": 6.0, "management_fee": 6.0},
|
|
|
|
|
|
"credits": {"heffingskorting": 300.0},
|
|
|
|
|
|
}
|
|
|
|
|
|
_VALUES_V2 = {
|
|
|
|
|
|
"energy": {"buy": {"normal": 0.20, "dal": 0.20},
|
|
|
|
|
|
"sell": {"normal": 0.08, "dal": 0.08},
|
|
|
|
|
|
"energy_tax": 0.0, "ode": 0.0},
|
|
|
|
|
|
"standing": {"network_fee": 12.0, "management_fee": 12.0},
|
|
|
|
|
|
"credits": {"heffingskorting": 600.0},
|
|
|
|
|
|
}
|
|
|
|
|
|
v1_from = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
|
|
|
|
|
v2_from = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
|
|
|
|
|
|
|
|
|
|
|
|
c = _make_contract(energy_db, kind="manual", active=True)
|
|
|
|
|
|
_make_version(energy_db, c, _VALUES_V1, effective_from=v1_from, effective_to=v2_from)
|
|
|
|
|
|
_make_version(energy_db, c, _VALUES_V2, effective_from=v2_from)
|
|
|
|
|
|
energy_db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
|
|
|
|
|
|
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
|
|
|
|
|
|
|
|
|
|
|
|
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
|
|
|
|
|
|
|
|
|
|
|
expected_fixed = 1 * 12 / 30 + 2 * 24 / 30 # V1: 1 day, V2: 2 days
|
|
|
|
|
|
expected_credits = 1 * 300 / 365 + 2 * 600 / 365
|
|
|
|
|
|
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
|
|
|
|
|
|
f"AC5: cross-version fixed_costs wrong; expected {expected_fixed}, "
|
|
|
|
|
|
f"got {result['fixed_costs']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert abs(result["credits"] - expected_credits) < 1e-9, (
|
|
|
|
|
|
f"AC5: cross-version credits wrong; expected {expected_credits}, "
|
|
|
|
|
|
f"got {result['credits']}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# --- AC6: return dict key-set unchanged ---
|
|
|
|
|
|
|
|
|
|
|
|
def test_return_dict_keys_unchanged(self, energy_db: Session) -> None:
|
|
|
|
|
|
"""AC6: summarize() return dict keys are unchanged by the settlement offset."""
|
|
|
|
|
|
self._setup_single_version_contract(energy_db)
|
|
|
|
|
|
start = _ams_midnight(2026, 6, 26)
|
|
|
|
|
|
end = _ams_midnight(2026, 6, 27)
|
|
|
|
|
|
|
|
|
|
|
|
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
|
|
|
|
|
|
|
|
|
|
|
|
expected_keys = {
|
|
|
|
|
|
"currency", "metered_import", "metered_export", "metered_net",
|
|
|
|
|
|
"fixed_costs", "credits", "total_payable", "period_count",
|
|
|
|
|
|
"degraded_count", "days",
|
|
|
|
|
|
}
|
|
|
|
|
|
assert set(result.keys()) == expected_keys, (
|
|
|
|
|
|
f"AC6: summarize() key set changed; expected {expected_keys}, "
|
|
|
|
|
|
f"got {set(result.keys())}"
|
|
|
|
|
|
)
|