"""Tests for M6-T07 + FU10: billing engine (app/services/energy_cost.py). 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). 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. 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, Meter, TibberPrice, ) from app.services.energy_cost import ( _MAX_DELTA_KWH, compute_closed_periods, compute_period, floor_to_quarter, register_at, recompute_range, summarize, ) from app.services import timezone as tz_module # --------------------------------------------------------------------------- # 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 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) 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: """register_at now requires a Meter argument; all tests use an active meter.""" def test_returns_none_when_no_readings(self, energy_db: Session) -> None: meter = _make_active_meter(energy_db) energy_db.commit() result = register_at(energy_db, _ts(10, 0), meter) assert result is None def test_returns_most_recent_at_or_before_boundary(self, energy_db: Session) -> None: meter = _make_active_meter(energy_db) # 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() result = register_at(energy_db, _ts(10, 0), meter) 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: meter = _make_active_meter(energy_db) _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_missing_register_key_returns_none(self, energy_db: Session) -> None: meter = _make_active_meter(energy_db) 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() result = register_at(energy_db, _ts(10, 0), meter) assert result is None def test_null_register_value_returns_none(self, energy_db: Session) -> None: meter = _make_active_meter(energy_db) 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() result = register_at(energy_db, _ts(10, 0), meter) assert result is None def test_values_are_decimal(self, energy_db: Session) -> None: meter = _make_active_meter(energy_db) _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() result = register_at(energy_db, _ts(10, 0), meter) assert result is not None assert isinstance(result["d1"], Decimal) assert result["d1"] == Decimal("20915.154") assert result["r2"] == Decimal("890.123") 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)" ) # --------------------------------------------------------------------------- # 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: """Create active manual contract + active meter + two boundary readings; return the version.""" contract = _make_contract(session, kind="manual", active=True) version = _make_version( session, contract, _MANUAL_VALUES, effective_from=_ts(0, 0), # covers t0=10:00 ) # Active meter covering the full test day (started before T0). _make_active_meter(session) # 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)) _make_active_meter(session) _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)) _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) # 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)) _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) # 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: """No DsmrReading at or before t0 within the meter window → degraded row written.""" 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) # 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: """No DsmrReading at or before t1 within the meter window → degraded row written. 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)) _make_active_meter(energy_db) # 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)) _make_active_meter(energy_db) # 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)) _make_active_meter(energy_db) # 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, ) # Active meter covering the full test day. _make_active_meter(session) 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: """Insert contract + meter + readings for two consecutive 15-min periods and compute them.""" contract = _make_contract(session, kind="manual", active=True) _make_version(session, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) _make_active_meter(session) # 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 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. 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 """ from zoneinfo import ZoneInfo from unittest.mock import patch self._setup_two_periods(energy_db) # Pin the local timezone so the test is deterministic on any CI host. with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")): result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) 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']}" ) 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. """ from zoneinfo import ZoneInfo from unittest.mock import patch self._setup_two_periods(energy_db) with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")): result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) 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']}" ) 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)) _make_active_meter(energy_db) # 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: """Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day UTC interval. 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. """ from zoneinfo import ZoneInfo from unittest.mock import patch 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) energy_db.commit() # Summarize over exactly 1 UTC day (no periods in DB — only standing/credits). # Pin timezone to Europe/Amsterdam for deterministic local-date mapping. start = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC) end = datetime(2026, 6, 24, 0, 0, 0, tzinfo=_UTC) with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")): result = summarize(energy_db, start, end) # 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 assert abs(result["total_payable"] - expected_total) < 1e-9 # --------------------------------------------------------------------------- # 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: """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``. """ # Effective_from: June 1 CEST local midnight = May 31 22:00 UTC. # Reference "today" pinned in individual tests = June 25 CEST. 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() 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. """ from unittest.mock import patch import app.services.energy_cost as _ec with patch.object(tz_module, "local_tz", return_value=_ams()): if pinned_now is not None: with patch.object(_ec, "local_now", return_value=pinned_now): return summarize(session, start_utc, end_utc) 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). 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. 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) # 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) 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). Pins ``local_now`` to June 25 noon AMS so the test is deterministic. Window: June 25 → June 28 local (4 dates, but June 26/27 are future). Today = June 25 → settled_cap = June 25 → only 1 day elapsed (June 25). 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) # 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) 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 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()) with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(_ec, "local_now", return_value=pinned_now): result = summarize(energy_db, start, end) # 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() # 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. start = _ams_midnight(2026, 6, 25) end = _ams_midnight(2026, 7, 1) from unittest.mock import patch import app.services.energy_cost as _ec pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams()) with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(_ec, "local_now", return_value=pinned_now): result = summarize(energy_db, start, end) # 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 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)). Pins local_now to June 25 noon AMS → today=June 25, settled_cap=June 25. 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) # 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) 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']}" ) # --------------------------------------------------------------------------- # 8. compute_closed_periods # --------------------------------------------------------------------------- class TestComputeClosedPeriods: def test_skips_future_periods(self, energy_db: Session) -> None: """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) contract = _make_contract(energy_db, kind="manual", active=True) _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) energy_db.commit() # The period [now, now+15min) is still open — should not be computed. # All closed past periods: meter OK, but no contract version → skip, no write. 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)) # Meter covering from before past_t0. _make_meter(energy_db, started_at=past_t0 - timedelta(hours=1), ended_at=None) _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)) # Meter covering from before past_t0. _make_meter(energy_db, started_at=past_t0 - timedelta(hours=1), ended_at=None) # 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)) _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() # 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)) _make_active_meter(energy_db) # 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)) _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() 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)) _make_active_meter(energy_db) 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 == {} # --------------------------------------------------------------------------- # 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. All tests now pass a meter that covers the reading and boundary times. """ 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) meter = _make_active_meter(energy_db) _make_reading(energy_db, recorded_at=reading_time, source_id=1) energy_db.commit() result = register_at(energy_db, boundary, meter) 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) meter = _make_active_meter(energy_db) _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() result = register_at(energy_db, boundary, meter) 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 meter = _make_active_meter(energy_db) _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() result = register_at(energy_db, boundary, meter) # 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 meter = _make_active_meter(energy_db) _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 result = register_at(energy_db, far_future_boundary, meter) 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)) _make_active_meter(energy_db) # 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. """ now = datetime.now(UTC) contract = _make_contract(energy_db, kind="manual", active=True) _make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) # Meter covering from before now. _make_meter(energy_db, started_at=now - timedelta(hours=1), ended_at=None) # 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. """ now = datetime.now(UTC) 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), ) # Meter covering from far past. _make_meter( energy_db, started_at=datetime(2020, 1, 1, tzinfo=UTC), ended_at=None, ) 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)) _make_active_meter(energy_db) # 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 # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # 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())}" )