diff --git a/app/models/energy.py b/app/models/energy.py index 0dfaae2..25ab153 100644 --- a/app/models/energy.py +++ b/app/models/energy.py @@ -13,7 +13,17 @@ from __future__ import annotations import uuid as _uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, event, text +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Integer, + String, + UniqueConstraint, + event, + text, +) from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym from sqlalchemy.types import JSON @@ -53,9 +63,7 @@ class Meter(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # Stable internal identity — used as HA Discovery unique_id anchor. - uuid: Mapped[str] = mapped_column( - String(36), unique=True, nullable=False, default=_uuid4_str - ) + uuid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, default=_uuid4_str) # Human-readable label for this physical meter (e.g. address, serial, tariff zone). label: Mapped[str] = mapped_column(String(255), nullable=False) @@ -207,15 +215,11 @@ class EnergyContractVersion(Base): ) # Start of this version's validity window (inclusive, UTC). - effective_from: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) + effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) # End of this version's validity window (exclusive, UTC). NULL means open-ended # (i.e. this is the most recent / current version). - effective_to: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) + effective_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Pricing values as a JSON object conforming to the profile structure for # ``contract.kind`` (validated by the application layer against the YAML profile). @@ -320,8 +324,8 @@ class EnergyCostPeriod(Base): ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True ) - # Nullable while M8 adopts historical DSMR rows. Future normal periods - # will point at the binding that supplied both cumulative endpoints. + # Nullable for historical and degraded rows. Every new normal period + # points at the one binding that supplied both cumulative endpoints. source_binding_id: Mapped[int | None] = mapped_column( ForeignKey("meter_source_binding.id", ondelete="RESTRICT"), nullable=True ) diff --git a/app/schemas/energy.py b/app/schemas/energy.py index eb2b6e4..c8775db 100644 --- a/app/schemas/energy.py +++ b/app/schemas/energy.py @@ -95,13 +95,18 @@ class CostPeriodSchema(BaseModel): export_revenue: float = Field(description="Revenue from electricity fed to grid (EUR).") net_cost: float = Field(description="import_cost − export_revenue (EUR).") currency: str = Field(description="ISO 4217 currency code.") - degraded: bool = Field( - description="True when the period was computed with incomplete data." - ) + degraded: bool = Field(description="True when the period was computed with incomplete data.") contract_version_id: int | None = Field( default=None, description="FK to the contract version used for this billing period (null when degraded).", ) + source_binding_id: int | None = Field( + default=None, + description=( + "FK to the source binding that supplied both cumulative endpoints " + "(null for legacy or degraded periods)." + ), + ) model_config = {"from_attributes": True} diff --git a/app/services/energy_cost.py b/app/services/energy_cost.py index a3de7a4..6e02173 100644 --- a/app/services/energy_cost.py +++ b/app/services/energy_cost.py @@ -37,7 +37,7 @@ Design notes - **Register keys**: DSMR payload uses JSON strings like ``"20915.154"`` for cumulative kWh registers. ``register_at`` converts them to Decimal. - **Degraded vs skip semantics**: - - *No meter coverage* (``meter_at`` returns None for t0): write a + - *No unique meter coverage* (no sole electricity meter at t0): write a ``degraded=True`` row with ``meter_id=None``. - *Cross-meter boundary* (m0.id != m1.id for t0/t1): write a ``degraded=True`` row with ``meter_id=m0.id``; losing this one period at the swap boundary is @@ -67,8 +67,8 @@ Meter-aware compute_period ordering rationale (M7-T03) The order of checks inside ``compute_period`` is: 1. **Immutability guard** (existing non-degraded row, overwrite=False) → return False. -2. **Meter determination** (m0 = meter_at(t0), m1 = meter_at(t1)): - - No meter (m0 is None) → write degraded, meter_id=None. +2. **Meter determination** (m0/m1 each resolve to one electricity Meter): + - No unique meter (m0 is None) → write degraded, meter_id=None. - Cross-meter boundary (m0.id != m1.id) → write degraded, meter_id=m0.id. 3. **Active contract version check** → skip (no write) if absent. 4. **Boundary register readings** within m0's window → write degraded if missing. @@ -98,8 +98,8 @@ from app.integrations.pricing.strategies import ( get_strategy, ) from app.models.energy import DsmrReading, EnergyCostPeriod, Meter +from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel from app.services.contracts import active_contract_version_at, active_contract_versions -from app.services.meters import meter_at from app.services.timezone import local_date, local_now logger = logging.getLogger(__name__) @@ -134,10 +134,10 @@ _MAX_DELTA_KWH = Decimal("100") _SETTLEMENT_OFFSET = timedelta(hours=1, minutes=5) # DSMR payload register keys (cumulative kWh, JSON string values). -_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1) -_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2) -_KEY_R1 = "electricity_returned_1" # returned low-tariff -_KEY_R2 = "electricity_returned_2" # returned high-tariff +_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1) +_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2) +_KEY_R1 = "electricity_returned_1" # returned low-tariff +_KEY_R2 = "electricity_returned_2" # returned high-tariff # --------------------------------------------------------------------------- @@ -174,6 +174,23 @@ def _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None: ).scalar_one_or_none() +def _unique_electricity_meter_at(session: Session, boundary: datetime) -> Meter | None: + """Return the sole electricity meter covering *boundary*, if one exists. + + Billing must treat overlapping meter epochs as a structural ambiguity rather + than relying on ``meter_at``'s newest-started tie breaker. A cumulative + delta is safe only when exactly one electricity meter covers each endpoint. + """ + candidates = session.execute( + select(Meter).where( + Meter.commodity == "electricity", + Meter.started_at <= boundary, + (Meter.ended_at.is_(None)) | (Meter.ended_at > boundary), + ) + ).scalars().all() + return candidates[0] if len(candidates) == 1 else None + + # --------------------------------------------------------------------------- # register_at — boundary reading lookup (meter-aware) # --------------------------------------------------------------------------- @@ -183,6 +200,8 @@ def register_at( session: Session, boundary: datetime, meter: Meter, + *, + meter_source_id: int | None = None, ) -> dict[str, Decimal] | None: """Return the four cumulative kWh register values at *boundary*, within *meter*'s window. @@ -226,7 +245,7 @@ def register_at( """ # Build the meter-window constraints: [started_at, ended_at). meter_lower = meter.started_at # DsmrReading.recorded_at >= meter.started_at - meter_upper = meter.ended_at # DsmrReading.recorded_at < meter.ended_at (if set) + meter_upper = meter.ended_at # DsmrReading.recorded_at < meter.ended_at (if set) stmt = ( select(DsmrReading) @@ -240,6 +259,8 @@ def register_at( # Apply the upper bound only when the meter is closed (ended_at is not None). if meter_upper is not None: stmt = stmt.where(DsmrReading.recorded_at < meter_upper) + if meter_source_id is not None: + stmt = stmt.where(DsmrReading.meter_source_id == meter_source_id) row: DsmrReading | None = session.execute(stmt).scalar_one_or_none() @@ -273,6 +294,33 @@ def register_at( } +def _binding_at( + session: Session, boundary: datetime, meter: Meter +) -> tuple[MeterSourceBinding, int] | None: + """Resolve the sole DSMR binding for *meter* at one period boundary. + + Costing must not infer a cumulative domain from whichever reading happens + to be latest. A binding anchors both the physical meter epoch and its + source stream. Any missing or overlapping binding is therefore + deliberately unresolvable. + """ + candidates = session.execute( + select(MeterSourceBinding, MeterSourceChannel.source_id) + .join(MeterSourceChannel, MeterSourceChannel.id == MeterSourceBinding.channel_id) + .join(MeterSource, MeterSource.id == MeterSourceChannel.source_id) + .where( + MeterSourceBinding.meter_id == meter.id, + MeterSourceBinding.started_at <= boundary, + (MeterSourceBinding.ended_at.is_(None)) | (MeterSourceBinding.ended_at > boundary), + MeterSource.kind == "dsmr_mqtt", + ) + ).all() + if len(candidates) != 1: + return None + binding, source_id = candidates[0] + return binding, source_id + + # --------------------------------------------------------------------------- # compute_period — single 15-minute period # --------------------------------------------------------------------------- @@ -302,9 +350,9 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) - Side-effects ------------ - Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``. - - If no meter covers t0 (``meter_at`` returns None for t0): inserts/updates - a degraded row with ``meter_id=None``. - - If the period spans a meter boundary (``meter_at(t0).id != meter_at(t1).id``): + - If no unique meter covers t0: inserts/updates a degraded row with + ``meter_id=None``. + - If the period spans a meter boundary (m0.id != m1.id): inserts/updates a degraded row with ``meter_id=m0.id`` (D5 decision). - If readings are missing at either boundary within the meter window: inserts/updates a degraded row with ``meter_id=m0.id``. @@ -332,7 +380,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) - # is corrected and a recompute_range is triggered. # # Ordering rationale: - # 1. No meter (m0 is None) → degraded(meter_id=None): no epoch for t0. + # 1. No unique meter (m0 is None) → degraded(meter_id=None): no unambiguous epoch for t0. # 2. Cross-meter boundary (m0.id != m1.id) → degraded(meter_id=m0.id): D5. # 3. (Single meter, proceed) → contract check → readings → delta guard → price. # @@ -341,13 +389,13 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) - # first, a missing-contract skip would silently discard the cross-table # evidence; once a contract is added and recompute runs, the engine would # incorrectly use cross-table reads. - m0 = meter_at(session, t0) - m1 = meter_at(session, t1) + m0 = _unique_electricity_meter_at(session, t0) + m1 = _unique_electricity_meter_at(session, t1) if m0 is None: - # No meter epoch covers t0 — degraded with no meter attribution. + # No unambiguous meter epoch covers t0 — degraded with no attribution. logger.debug( - "compute_period(%s): no active meter at t0 — writing degraded (meter_id=None).", + "compute_period(%s): no unique active meter at t0 — writing degraded (meter_id=None).", t0.isoformat(), ) _upsert_degraded(session, t0, now, existing, meter_id=None) @@ -366,6 +414,16 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) - _upsert_degraded(session, t0, now, existing, meter_id=m0.id) return True + # Both endpoints must resolve to the same binding and source before a + # cumulative subtraction is permitted. This is checked before contract + # lookup so structural inconsistencies remain visible as degraded rows. + bound0 = _binding_at(session, t0, m0) + bound1 = _binding_at(session, t1, m1) + if bound0 is None or bound1 is None or bound0[0].id != bound1[0].id or bound0[1] != bound1[1]: + _upsert_degraded(session, t0, now, existing, meter_id=m0.id) + return True + binding, meter_source_id = bound0 + # --- Active contract version at t0 --- # If there is no active contract covering t0, skip the period entirely. # We do not write a degraded row — there is no meaningful state to recover @@ -378,8 +436,8 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) - return False # --- Boundary readings within m0's meter window --- - start_regs = register_at(session, t0, m0) - end_regs = register_at(session, t1, m0) + start_regs = register_at(session, t0, m0, meter_source_id=meter_source_id) + end_regs = register_at(session, t1, m0, meter_source_id=meter_source_id) if start_regs is None or end_regs is None: # Missing readings within the meter window → degraded with m0 attribution. @@ -421,9 +479,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) - except TibberPriceNotFoundError: # Missing Tibber price → skip the period; it will be retried once the # price arrives (e.g. after the next Tibber refresh job runs). - logger.debug( - "compute_period(%s): no Tibber price found — skipping.", t0.isoformat() - ) + logger.debug("compute_period(%s): no Tibber price found — skipping.", t0.isoformat()) return False # --- Upsert the billing record --- @@ -445,6 +501,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) - existing.pricing = pricing existing.contract_version_id = version.id existing.meter_id = m0.id + existing.source_binding_id = binding.id existing.degraded = False existing.computed_at = now else: @@ -461,6 +518,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) - pricing=pricing, contract_version_id=version.id, meter_id=m0.id, + source_binding_id=binding.id, degraded=False, computed_at=now, ) @@ -520,6 +578,7 @@ def _upsert_degraded( existing.pricing = {} existing.contract_version_id = None existing.meter_id = meter_id + existing.source_binding_id = None existing.degraded = True existing.computed_at = now else: @@ -536,6 +595,7 @@ def _upsert_degraded( pricing={}, contract_version_id=None, meter_id=meter_id, + source_binding_id=None, degraded=True, computed_at=now, ) @@ -756,12 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any end_utc = _as_utc(end) # --- Fetch all EnergyCostPeriod rows in [start, end) --- - rows = session.execute( - select(EnergyCostPeriod).where( - EnergyCostPeriod.period_start >= start_utc, - EnergyCostPeriod.period_start < end_utc, + rows = ( + session.execute( + select(EnergyCostPeriod).where( + EnergyCostPeriod.period_start >= start_utc, + EnergyCostPeriod.period_start < end_utc, + ) ) - ).scalars().all() + .scalars() + .all() + ) good_rows = [r for r in rows if not r.degraded] degraded_rows = [r for r in rows if r.degraded] @@ -863,7 +927,9 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any version_segments: list[tuple[_date, _date | None, dict]] = [] for v in versions: v_start_local = local_date(_as_utc(v.effective_from)) - v_end_local = local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None + v_end_local = ( + local_date(_as_utc(v.effective_to)) if v.effective_to is not None else None + ) version_segments.append((v_start_local, v_end_local, v.values or {})) for v_start, v_end_excl, v_values in version_segments: diff --git a/docs/design/m8-warmtelink-energy.md b/docs/design/m8-warmtelink-energy.md index 3c95da7..de0610e 100644 --- a/docs/design/m8-warmtelink-energy.md +++ b/docs/design/m8-warmtelink-energy.md @@ -451,7 +451,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 ### M8-T05 — 电费计算绑定 Source Binding [structural] -- **Status**: `todo` +- **Status**: `done` - **Depends**: M8-T04 - **Context**: DSMR 已多 source 后,电力 period 必须只在同一 Meter/binding 累计域内计算。 @@ -461,6 +461,10 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - `modify app/schemas/energy.py` - `modify tests/test_energy_cost.py` - `modify tests/test_api_energy.py` +- `modify tests/test_energy_expose.py` +- `modify openapi/openapi.json` +- `modify openapi/openapi.yaml` +- `modify frontend/src/api/schema.d.ts` **Steps** 1. 为 period 两个边界按时间解析唯一 electricity Meter、binding 和 DSMR source;查询 reading 时加入 @@ -471,6 +475,10 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 不删除旧字段。 4. 用现有 golden tests 锁住单 source 正常 period 的 quantity、cost、rounding、fixed fee 与 summary; 增加 source switch、binding boundary、missing/ambiguous binding 测试。 +5. 因响应 schema 增加 nullable binding identity,重导 OpenAPI,并在 `frontend/` 运行 + `npm run codegen`;两个生成物必须随本卡提交,禁止手改 `schema.d.ts`。 +6. 固定现有 expose fixed-fee/credit 回归中的 `local_now()` 到明确已越过 01:05 结算点的时刻;测试 + 不得依赖执行当天恰好处于 UTC 00:00~01:05 之外,也不得为消除红灯改变生产结算语义。 **Out of scope / 不要碰** - 不实现 thermal cost,不修改合同 scope,不改变正常电价公式。 @@ -480,11 +488,14 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接 - [ ] 正常新周期总能审计到唯一 binding,跨域周期明确 degraded。 - [ ] 既有单 DSMR source 的所有非降级数字逐项不变。 - [ ] recompute 幂等,不能把 source A 起点和 source B 终点相减。 -- [ ] `pytest`、`ruff check .` 全绿。 +- [ ] fixed-fee/credit golden tests 在 01:05 前后任意实际运行时刻均确定性通过,生产结算点不变。 +- [ ] `pytest`、`ruff check .`、OpenAPI/codegen 同步闸门全绿且生成物已提交。 **Reviewer checklist** - 重点构造 Meter 相同但 source 切换、source 相同但 Meter 换表两种边界。 - 检查 Decimal/rounding 和本地日 fixed-fee 逻辑是否被无意改变。 +- 检查 expose golden tests 是否显式固定业务时钟,而不是等待 wall clock 或放宽 01:05 断言。 +- 独立重导 OpenAPI 与 codegen,确认 schema 生成物同步且不是手改。 ### M8-T06 — Source / Channel / Binding HTTP 契约 [structural] diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index f974cf1..bc044cf 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1477,6 +1477,11 @@ export interface components { * @description FK to the contract version used for this billing period (null when degraded). */ contract_version_id?: number | null; + /** + * Source Binding Id + * @description FK to the source binding that supplied both cumulative endpoints (null for legacy or degraded periods). + */ + source_binding_id?: number | null; }; /** * CostsResponse diff --git a/openapi/openapi.json b/openapi/openapi.json index f530a62..790f178 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -3086,6 +3086,18 @@ ], "title": "Contract Version Id", "description": "FK to the contract version used for this billing period (null when degraded)." + }, + "source_binding_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Source Binding Id", + "description": "FK to the source binding that supplied both cumulative endpoints (null for legacy or degraded periods)." } }, "type": "object", diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 44073fe..9ff53d9 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2442,6 +2442,13 @@ components: title: Contract Version Id description: FK to the contract version used for this billing period (null when degraded). + source_binding_id: + anyOf: + - type: integer + - type: 'null' + title: Source Binding Id + description: FK to the source binding that supplied both cumulative endpoints + (null for legacy or degraded periods). type: object required: - period_start diff --git a/tests/test_api_energy.py b/tests/test_api_energy.py index 2f63ac1..c87a302 100644 --- a/tests/test_api_energy.py +++ b/tests/test_api_energy.py @@ -430,9 +430,7 @@ def test_prices_tibber_limit_caps_results(energy_client): start = (datetime.now(UTC) - timedelta(hours=3)).isoformat() end = (datetime.now(UTC) + timedelta(hours=2)).isoformat() - resp = client.get( - "/api/energy/prices", params={"start": start, "end": end, "limit": 2} - ) + resp = client.get("/api/energy/prices", params={"start": start, "end": end, "limit": 2}) assert resp.status_code == 200 body = resp.json() assert len(body["points"]) <= 2 @@ -508,9 +506,16 @@ def test_costs_schema_fields_present(energy_client): item = resp.json()["items"][0] for field in ( "period_start", - "d1_kwh", "d2_kwh", "r1_kwh", "r2_kwh", - "import_cost", "export_revenue", "net_cost", - "currency", "degraded", + "d1_kwh", + "d2_kwh", + "r1_kwh", + "r2_kwh", + "import_cost", + "export_revenue", + "net_cost", + "currency", + "degraded", + "source_binding_id", ): assert field in item, f"Missing field: {field}" @@ -556,9 +561,7 @@ def test_summary_returns_correct_structure(energy_client): start = (datetime.now(UTC) - timedelta(hours=3)).isoformat() end = datetime.now(UTC).isoformat() - resp = client.get( - "/api/energy/costs/summary", params={"start": start, "end": end} - ) + resp = client.get("/api/energy/costs/summary", params={"start": start, "end": end}) assert resp.status_code == 200 body = resp.json() for field in ( @@ -903,5 +906,3 @@ def test_tibber_test_token_not_in_response(energy_client): assert resp.status_code == 200 assert secret_token not in resp.text - - diff --git a/tests/test_energy_cost.py b/tests/test_energy_cost.py index d924b89..2a62f99 100644 --- a/tests/test_energy_cost.py +++ b/tests/test_energy_cost.py @@ -44,6 +44,7 @@ from app.models.energy import ( Meter, TibberPrice, ) +from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel from app.services.energy_cost import ( _MAX_DELTA_KWH, compute_closed_periods, @@ -163,11 +164,13 @@ def _make_reading( r1: str = "5000.000", r2: str = "3000.000", source_id: int | None = None, + meter_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, + meter_source_id=meter_source_id, payload={ "electricity_delivered_1": d1, "electricity_delivered_2": d2, @@ -181,6 +184,35 @@ def _make_reading( return r +def _make_dsmr_source_channel( + session: Session, label: str +) -> tuple[MeterSource, MeterSourceChannel]: + now = datetime.now(_UTC) + source = MeterSource( + name=label, + kind="dsmr_mqtt", + enabled=True, + config={}, + status="unknown", + created_at=now, + updated_at=now, + ) + session.add(source) + session.flush() + channel = MeterSourceChannel( + source_id=source.id, + channel_key="electricity", + label=f"{label} electricity", + suggested_commodity="electricity", + unit="kWh", + created_at=now, + updated_at=now, + ) + session.add(channel) + session.flush() + return source, channel + + def _make_meter( session: Session, *, @@ -204,9 +236,35 @@ def _make_meter( ) session.add(m) session.flush() + if commodity == "electricity": + _bind_default_dsmr_source(session, m) return m +def _bind_default_dsmr_source(session: Session, meter: Meter) -> MeterSourceBinding: + """Give legacy single-source fixtures an explicit M8 source binding.""" + source = session.execute( + select(MeterSource).where(MeterSource.kind == "dsmr_mqtt").order_by(MeterSource.id) + ).scalar_one() + channel = session.execute( + select(MeterSourceChannel) + .where(MeterSourceChannel.source_id == source.id) + .order_by(MeterSourceChannel.id) + ).scalar_one() + now = datetime.now(_UTC) + binding = MeterSourceBinding( + meter_id=meter.id, + channel_id=channel.id, + started_at=meter.started_at, + ended_at=meter.ended_at, + created_at=now, + updated_at=now, + ) + session.add(binding) + session.flush() + return binding + + def _make_active_meter(session: Session, *, started_at: datetime | None = None) -> Meter: """Insert and flush an active electricity meter covering the full test day. @@ -304,8 +362,24 @@ class TestRegisterAt: 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) + _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) @@ -315,7 +389,15 @@ class TestRegisterAt: 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) + _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) @@ -355,8 +437,15 @@ class TestRegisterAt: 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) + _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) @@ -369,21 +458,33 @@ class TestRegisterAt: """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) + _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" - ) + 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) + _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) @@ -394,15 +495,21 @@ class TestRegisterAt: """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) + _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)" + "register_at must exclude a reading exactly at meter.ended_at (half-open upper bound)" ) @@ -444,17 +551,27 @@ 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, + 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) + _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) + _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 @@ -549,9 +666,15 @@ class TestComputePeriodIdempotency: 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) + _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) @@ -589,9 +712,11 @@ class TestComputePeriodIdempotency: 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() + rows = ( + energy_db.execute(select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)) + .scalars() + .all() + ) assert len(rows) == 1 @@ -624,14 +749,24 @@ _TIBBER_VALUES = { class TestComputePeriodTibber: - def _setup(self, session: Session, total: float = 0.25) -> tuple[EnergyContractVersion, TibberPrice]: + 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) + _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 @@ -694,19 +829,29 @@ class TestComputePeriodMissingTibberPrice: 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) + _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() + 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: @@ -714,10 +859,18 @@ class TestComputePeriodMissingTibberPrice: 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) + _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() @@ -725,9 +878,11 @@ class TestComputePeriodMissingTibberPrice: result = compute_period(energy_db, _T0) assert result is False - rows = energy_db.execute( - select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0) - ).scalars().all() + rows = ( + energy_db.execute(select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)) + .scalars() + .all() + ) assert len(rows) == 0 @@ -743,8 +898,9 @@ class TestComputePeriodMissingReadings: _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) + _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) @@ -775,8 +931,15 @@ class TestComputePeriodMissingReadings: _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) + _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) @@ -821,10 +984,18 @@ class TestComputePeriodMissingReadings: # 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) + _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) @@ -876,12 +1047,16 @@ class TestCrossVersionSelection: def _setup(self, session: Session) -> tuple[EnergyContractVersion, EnergyContractVersion]: contract = _make_contract(session, kind="manual", active=True) v1 = _make_version( - session, contract, self._VALUES_V1, + session, + contract, + self._VALUES_V1, effective_from=_ts(0, 0), effective_to=_ts(8, 0), ) v2 = _make_version( - session, contract, self._VALUES_V2, + session, + contract, + self._VALUES_V2, effective_from=_ts(8, 0), effective_to=None, ) @@ -894,10 +1069,24 @@ class TestCrossVersionSelection: 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) + _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) @@ -915,10 +1104,24 @@ class TestCrossVersionSelection: 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) + _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) @@ -964,19 +1167,30 @@ class TestSummarize: _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) + _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) + _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) @@ -1209,6 +1423,7 @@ def _ams(): global _AMS if _AMS is None: from zoneinfo import ZoneInfo + _AMS = ZoneInfo("Europe/Amsterdam") return _AMS @@ -1216,16 +1431,21 @@ def _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) + 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) @@ -1242,7 +1462,9 @@ class TestSummarizePrincipleC: # 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: + 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) @@ -1409,14 +1631,22 @@ class TestSummarizePrincipleC: 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}, + "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}, + "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}, } @@ -1426,8 +1656,7 @@ class TestSummarizePrincipleC: 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_V1, effective_from=v1_from, effective_to=v2_from) _make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from) energy_db.commit() @@ -1435,6 +1664,7 @@ class TestSummarizePrincipleC: 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()): @@ -1460,14 +1690,22 @@ class TestSummarizePrincipleC: 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}, + "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}, + "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}, } @@ -1475,8 +1713,7 @@ class TestSummarizePrincipleC: 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_V1, effective_from=v1_from, effective_to=v2_from) _make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from) energy_db.commit() @@ -1486,6 +1723,7 @@ class TestSummarizePrincipleC: 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): @@ -1560,7 +1798,7 @@ class TestSummarizePrincipleC: 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 + 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()): @@ -1593,8 +1831,8 @@ class TestSummarizePrincipleC: 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 + 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) @@ -1654,13 +1892,29 @@ class TestComputeClosedPeriods: 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)) + _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) + _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. @@ -1691,7 +1945,9 @@ class TestComputeClosedPeriods: 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)) + _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. @@ -1705,10 +1961,24 @@ class TestComputeClosedPeriods: 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) + _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. @@ -1732,10 +2002,18 @@ class TestRecomputeRange: 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) + _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. @@ -1743,9 +2021,15 @@ class TestRecomputeRange: 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) + _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) @@ -1764,16 +2048,28 @@ class TestRecomputeRange: _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) + _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) + _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) @@ -1784,18 +2080,28 @@ class TestRecomputeRange: 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) + _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() + 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( @@ -1819,12 +2125,22 @@ class TestRecomputeRange: _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, + 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, + recorded_at=_T1, + d1=_END_D1, + d2=_END_D2, + r1=_END_R1, + r2=_END_R2, + source_id=2, ) energy_db.commit() @@ -1884,7 +2200,7 @@ class TestRegisterAtFreshness: 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) + 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() @@ -1898,11 +2214,17 @@ class TestRegisterAtFreshness: 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) + 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) + _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) @@ -1914,11 +2236,17 @@ class TestRegisterAtFreshness: 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 + 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) + _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) @@ -2016,8 +2344,10 @@ class TestRecomputeRangeNoFuturePeriods: # 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 + 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; " @@ -2040,7 +2370,9 @@ class TestRecomputeRangeNoFuturePeriods: 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, + energy_db, + contract, + _MANUAL_VALUES, effective_from=datetime(2020, 1, 1, tzinfo=UTC), ) # Meter covering from far past. @@ -2060,8 +2392,10 @@ class TestRecomputeRangeNoFuturePeriods: # 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 + 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). " @@ -2082,10 +2416,24 @@ class TestNormalPeriodsUnaffected: # 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) + _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) @@ -2120,17 +2468,23 @@ class TestMeterAwareComputePeriod: """ # ① Same-meter period: delta correct + meter_id attributed - def test_same_meter_period_computes_correctly_with_meter_id( - self, energy_db: Session - ) -> None: + 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) + _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) @@ -2147,9 +2501,7 @@ class TestMeterAwareComputePeriod: 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}" - ) + 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: @@ -2165,10 +2517,18 @@ class TestMeterAwareComputePeriod: 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) + _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) @@ -2192,17 +2552,23 @@ class TestMeterAwareComputePeriod: _ = 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: + 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) + _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) @@ -2213,8 +2579,7 @@ class TestMeterAwareComputePeriod: ).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}" + f"No-meter degraded row must have meter_id=None, got meter_id={row.meter_id}" ) assert row.import_cost == 0.0 @@ -2229,10 +2594,24 @@ class TestMeterAwareComputePeriod: _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) + _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) @@ -2258,10 +2637,24 @@ class TestMeterAwareComputePeriod: _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) + _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) @@ -2290,10 +2683,24 @@ class TestMeterAwareComputePeriod: _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) + _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) @@ -2316,10 +2723,24 @@ class TestMeterAwareComputePeriod: _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) + _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) @@ -2339,10 +2760,24 @@ class TestMeterAwareComputePeriod: _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) + _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) @@ -2370,10 +2805,18 @@ class TestMeterAwareComputePeriod: 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) + _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) @@ -2405,8 +2848,7 @@ class TestMeterAwareComputePeriod: 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}" + f"After recompute, meter_id should be m2.id={m2.id}, got {row_after.meter_id}" ) assert row_after.degraded is False @@ -2419,10 +2861,18 @@ class TestMeterAwareComputePeriod: """ # 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) + _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) @@ -2430,9 +2880,11 @@ class TestMeterAwareComputePeriod: "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() + 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" ) @@ -2454,8 +2906,7 @@ class TestMeterAwareComputePeriod: ).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}" + f"Within-meter degraded row must carry meter_id={meter.id}, got {row.meter_id}" ) # Delta sanity guard with all-zero deltas (zero is fine, not negative) @@ -2469,10 +2920,24 @@ class TestMeterAwareComputePeriod: _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) + _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) @@ -2486,6 +2951,144 @@ class TestMeterAwareComputePeriod: assert row.net_cost == 0.0 +class TestSourceBindingCosting: + """D9: a billing delta has exactly one meter, binding, and DSMR source.""" + + def _contract_and_meter(self, session: Session) -> tuple[Meter, MeterSourceBinding]: + contract = _make_contract(session, kind="manual", active=True) + _make_version(session, contract, _MANUAL_VALUES, effective_from=_ts(0, 0)) + meter = _make_active_meter(session) + binding = session.execute( + select(MeterSourceBinding).where(MeterSourceBinding.meter_id == meter.id) + ).scalar_one() + return meter, binding + + def test_normal_period_records_binding_identity(self, energy_db: Session) -> None: + meter, binding = self._contract_and_meter(energy_db) + source_id = binding.channel.source_id + _make_reading( + energy_db, recorded_at=_T0, d1="1", d2="1", r1="1", r2="1", meter_source_id=source_id + ) + _make_reading( + energy_db, recorded_at=_T1, d1="2", d2="1", r1="1", r2="1", meter_source_id=source_id + ) + assert compute_period(energy_db, _T0) is True + row = energy_db.execute( + select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0) + ).scalar_one() + assert row.degraded is False + assert row.meter_id == meter.id + assert row.source_binding_id == binding.id + + def test_source_switch_never_subtracts_across_sources(self, energy_db: Session) -> None: + meter, first = self._contract_and_meter(energy_db) + first.ended_at = _T1 + source, channel = _make_dsmr_source_channel(energy_db, "second DSMR") + now = datetime.now(_UTC) + energy_db.add( + MeterSourceBinding( + meter_id=meter.id, + channel_id=channel.id, + started_at=_T1, + ended_at=None, + created_at=now, + updated_at=now, + ) + ) + _make_reading( + energy_db, + recorded_at=_T0, + d1="10", + d2="1", + r1="1", + r2="1", + meter_source_id=first.channel.source_id, + ) + _make_reading( + energy_db, recorded_at=_T1, d1="999", d2="1", r1="1", r2="1", meter_source_id=source.id + ) + assert compute_period(energy_db, _T0) is True + row = energy_db.execute( + select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0) + ).scalar_one() + assert row.degraded is True + assert row.source_binding_id is None + + def test_missing_binding_degrades(self, energy_db: Session) -> None: + meter, first = self._contract_and_meter(energy_db) + energy_db.delete(first) + assert compute_period(energy_db, _T0) is True + row = energy_db.execute( + select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0) + ).scalar_one() + assert row.degraded is True + assert row.source_binding_id is None + + def test_ambiguous_binding_degrades(self, energy_db: Session) -> None: + meter, first = self._contract_and_meter(energy_db) + _, channel = _make_dsmr_source_channel(energy_db, "overlapping DSMR") + now = datetime.now(_UTC) + energy_db.add( + MeterSourceBinding( + meter_id=meter.id, + channel_id=channel.id, + started_at=_ts(0), + ended_at=None, + created_at=now, + updated_at=now, + ) + ) + assert compute_period(energy_db, _T0) is True + row = energy_db.execute( + select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0) + ).scalar_one() + assert row.degraded is True + assert row.source_binding_id is None + + def test_overlapping_meters_degrade_without_selecting_one_epoch( + self, energy_db: Session + ) -> None: + """A temporary SQLite DB must reject overlapping electricity epochs. + + ``meter_at`` deliberately selects the most recently started epoch for + callers that need one candidate. Costing instead needs an unambiguous + cumulative domain, so two meters covering both boundaries are degraded + even if one candidate has complete bindings and readings. + """ + _meter, binding = self._contract_and_meter(energy_db) + _make_meter( + energy_db, + label="Overlapping electricity meter", + started_at=_ts(9), + ) + _make_reading( + energy_db, + recorded_at=_T0, + d1="1", + d2="1", + r1="1", + r2="1", + meter_source_id=binding.channel.source_id, + ) + _make_reading( + energy_db, + recorded_at=_T1, + d1="2", + d2="1", + r1="1", + r2="1", + meter_source_id=binding.channel.source_id, + ) + + assert compute_period(energy_db, _T0) is True + row = energy_db.execute( + select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0) + ).scalar_one() + assert row.degraded is True + assert row.meter_id is None + assert row.source_binding_id is None + + # --------------------------------------------------------------------------- # FUE-T08. summarize — settlement offset (local 01:05) # --------------------------------------------------------------------------- @@ -2548,8 +3151,8 @@ class TestSummarizeSettlementOffset: 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 + 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) @@ -2558,8 +3161,7 @@ class TestSummarizeSettlementOffset: f"got {result['fixed_costs']}" ) assert result["credits"] == 0.0, ( - "AC1: before settlement offset, today's credits must be 0; " - f"got {result['credits']}" + f"AC1: before settlement offset, today's credits must be 0; got {result['credits']}" ) # --- AC2: >= 01:05 → today settled → 1 day fixed/credits --- @@ -2600,8 +3202,8 @@ class TestSummarizeSettlementOffset: 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 + 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) @@ -2612,8 +3214,7 @@ class TestSummarizeSettlementOffset: 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']}" + f"AC3 before offset: credits must be 2 days; got {result['credits']}" ) # --- AC3b: cumulative window, after offset → today (June 26) counted --- @@ -2628,8 +3229,8 @@ class TestSummarizeSettlementOffset: 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 + 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) @@ -2640,8 +3241,7 @@ class TestSummarizeSettlementOffset: 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']}" + f"AC3 after offset: credits must be 3 days; got {result['credits']}" ) # --- AC4: past days always fully counted regardless of offset --- @@ -2655,8 +3255,8 @@ class TestSummarizeSettlementOffset: 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 + 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) @@ -2668,8 +3268,7 @@ class TestSummarizeSettlementOffset: 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']}" + f"AC4: past credits must be 6 days; got {result['credits']}" ) # --- AC5: cross-version segments still correct with settlement offset --- @@ -2691,33 +3290,39 @@ class TestSummarizeSettlementOffset: 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}, + "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}, + "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 + 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 + 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_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}, " @@ -2739,12 +3344,19 @@ class TestSummarizeSettlementOffset: result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER) expected_keys = { - "currency", "metered_import", "metered_export", "metered_net", - "metered_import_kwh", "metered_export_kwh", - "fixed_costs", "credits", "total_payable", "period_count", - "degraded_count", "days", + "currency", + "metered_import", + "metered_export", + "metered_net", + "metered_import_kwh", + "metered_export_kwh", + "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())}" + f"AC6: summarize() key set changed; expected {expected_keys}, got {set(result.keys())}" ) diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index b346d15..68b2069 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -1097,6 +1097,9 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None: from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) + # Keep the dynamic test date, but settle it at a deterministic business time + # beyond the local 01:05 fixed-fee/credit cutoff. + settled_local_now = now_utc.replace(hour=12, minute=0, second=0, microsecond=0) # D2 anchor = meter.started_at = 10 UTC days ago at midnight meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10) effective_from = meter_started_at # contract also starts at the same time @@ -1123,7 +1126,10 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None: with Session(energy_db) as session: # Pin to UTC so local days = UTC days (deterministic on any CI host). - with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch("app.services.energy_cost.local_now", return_value=settled_local_now), + ): catalog = build_catalog(session) import_entry = next( e for e in catalog if e.entity.key == "energy.import_cost_total" @@ -1163,6 +1169,9 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None: from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) + # Keep the dynamic test date, but settle it at a deterministic business time + # beyond the local 01:05 fixed-fee/credit cutoff. + settled_local_now = now_utc.replace(hour=12, minute=0, second=0, microsecond=0) meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4) effective_from = meter_started_at @@ -1186,7 +1195,10 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None: session.commit() with Session(energy_db) as session: - with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch("app.services.energy_cost.local_now", return_value=settled_local_now), + ): catalog = build_catalog(session) export_entry = next( e for e in catalog if e.entity.key == "energy.export_revenue_total" @@ -1945,6 +1957,9 @@ def test_cumulative_anchor_is_meter_started_at(energy_db) -> None: from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) + # Keep the dynamic test date, but settle it at a deterministic business time + # beyond the local 01:05 fixed-fee/credit cutoff. + settled_local_now = now_utc.replace(hour=12, minute=0, second=0, microsecond=0) midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) # Contract starts 180 days ago (far before the meter) @@ -1966,7 +1981,10 @@ def test_cumulative_anchor_is_meter_started_at(energy_db) -> None: session.commit() with Session(energy_db) as session: - with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch("app.services.energy_cost.local_now", return_value=settled_local_now), + ): catalog = build_catalog(session) import_entry = next( e for e in catalog if e.entity.key == "energy.import_cost_total" @@ -2089,6 +2107,9 @@ def test_cumulative_resets_after_meter_swap(energy_db) -> None: from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) + # Keep the dynamic test date, but settle it at a deterministic business time + # beyond the local 01:05 fixed-fee/credit cutoff. + settled_local_now = now_utc.replace(hour=12, minute=0, second=0, microsecond=0) midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) old_meter_start = midnight_today - timedelta(days=30) @@ -2145,7 +2166,10 @@ def test_cumulative_resets_after_meter_swap(energy_db) -> None: session.commit() with Session(energy_db) as session: - with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch("app.services.energy_cost.local_now", return_value=settled_local_now), + ): catalog = build_catalog(session) import_entry = next( e for e in catalog if e.entity.key == "energy.import_cost_total" @@ -2194,6 +2218,9 @@ def test_daily_getters_unaffected_by_d2_meter_anchor(energy_db) -> None: from app.services import timezone as _tz_mod now_utc = datetime.now(timezone.utc) + # Keep the dynamic test date, but settle it at a deterministic business time + # beyond the local 01:05 fixed-fee/credit cutoff. + settled_local_now = now_utc.replace(hour=12, minute=0, second=0, microsecond=0) # Period 1h ago — in today's UTC window t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) if t0.date() < now_utc.date(): @@ -2226,7 +2253,10 @@ def test_daily_getters_unaffected_by_d2_meter_anchor(energy_db) -> None: session.commit() with Session(energy_db) as session: - with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch("app.services.energy_cost.local_now", return_value=settled_local_now), + ): catalog = build_catalog(session) import_today_entry = next( e for e in catalog if e.entity.key == "energy.import_cost_today"