M8-T05: bind electricity costs to source bindings
This commit is contained in:
+16
-12
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+86
-20
@@ -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__)
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Vendored
+5
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
+828
-216
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user