M8-T05: bind electricity costs to source bindings

This commit is contained in:
2026-08-23 21:22:05 +02:00
parent 2e125dbd53
commit 1ea2f659e0
10 changed files with 1057 additions and 304 deletions
+16 -12
View File
@@ -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
)
+8 -3
View File
@@ -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}
+94 -28
View File
@@ -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:
+13 -2
View File
@@ -451,7 +451,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T05 — 电费计算绑定 Source Binding [structural]
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M8-T04
- **Context**: DSMR 已多 source 后,电力 period 必须只在同一 Meter/binding 累计域内计算。
@@ -461,6 +461,10 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- `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 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
不删除旧字段。
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 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- [ ] 正常新周期总能审计到唯一 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]
+5
View File
@@ -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
+12
View File
@@ -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",
+7
View File
@@ -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
+12 -11
View File
@@ -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
+855 -243
View File
File diff suppressed because it is too large Load Diff
+35 -5
View File
@@ -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"