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
|
import uuid as _uuid
|
||||||
from datetime import datetime
|
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.orm import Mapped, mapped_column, relationship, synonym
|
||||||
from sqlalchemy.types import JSON
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
@@ -53,9 +63,7 @@ class Meter(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
# Stable internal identity — used as HA Discovery unique_id anchor.
|
# Stable internal identity — used as HA Discovery unique_id anchor.
|
||||||
uuid: Mapped[str] = mapped_column(
|
uuid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, default=_uuid4_str)
|
||||||
String(36), unique=True, nullable=False, default=_uuid4_str
|
|
||||||
)
|
|
||||||
|
|
||||||
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
|
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
|
||||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
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).
|
# Start of this version's validity window (inclusive, UTC).
|
||||||
effective_from: Mapped[datetime] = mapped_column(
|
effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
DateTime(timezone=True), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# End of this version's validity window (exclusive, UTC). NULL means open-ended
|
# End of this version's validity window (exclusive, UTC). NULL means open-ended
|
||||||
# (i.e. this is the most recent / current version).
|
# (i.e. this is the most recent / current version).
|
||||||
effective_to: Mapped[datetime | None] = mapped_column(
|
effective_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pricing values as a JSON object conforming to the profile structure for
|
# Pricing values as a JSON object conforming to the profile structure for
|
||||||
# ``contract.kind`` (validated by the application layer against the YAML profile).
|
# ``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
|
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nullable while M8 adopts historical DSMR rows. Future normal periods
|
# Nullable for historical and degraded rows. Every new normal period
|
||||||
# will point at the binding that supplied both cumulative endpoints.
|
# points at the one binding that supplied both cumulative endpoints.
|
||||||
source_binding_id: Mapped[int | None] = mapped_column(
|
source_binding_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("meter_source_binding.id", ondelete="RESTRICT"), nullable=True
|
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).")
|
export_revenue: float = Field(description="Revenue from electricity fed to grid (EUR).")
|
||||||
net_cost: float = Field(description="import_cost − export_revenue (EUR).")
|
net_cost: float = Field(description="import_cost − export_revenue (EUR).")
|
||||||
currency: str = Field(description="ISO 4217 currency code.")
|
currency: str = Field(description="ISO 4217 currency code.")
|
||||||
degraded: bool = Field(
|
degraded: bool = Field(description="True when the period was computed with incomplete data.")
|
||||||
description="True when the period was computed with incomplete data."
|
|
||||||
)
|
|
||||||
contract_version_id: int | None = Field(
|
contract_version_id: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="FK to the contract version used for this billing period (null when degraded).",
|
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}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
+86
-20
@@ -37,7 +37,7 @@ Design notes
|
|||||||
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
|
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
|
||||||
for cumulative kWh registers. ``register_at`` converts them to Decimal.
|
for cumulative kWh registers. ``register_at`` converts them to Decimal.
|
||||||
- **Degraded vs skip semantics**:
|
- **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``.
|
``degraded=True`` row with ``meter_id=None``.
|
||||||
- *Cross-meter boundary* (m0.id != m1.id for t0/t1): write a ``degraded=True``
|
- *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
|
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:
|
The order of checks inside ``compute_period`` is:
|
||||||
|
|
||||||
1. **Immutability guard** (existing non-degraded row, overwrite=False) → return False.
|
1. **Immutability guard** (existing non-degraded row, overwrite=False) → return False.
|
||||||
2. **Meter determination** (m0 = meter_at(t0), m1 = meter_at(t1)):
|
2. **Meter determination** (m0/m1 each resolve to one electricity Meter):
|
||||||
- No meter (m0 is None) → write degraded, meter_id=None.
|
- No unique meter (m0 is None) → write degraded, meter_id=None.
|
||||||
- Cross-meter boundary (m0.id != m1.id) → write degraded, meter_id=m0.id.
|
- Cross-meter boundary (m0.id != m1.id) → write degraded, meter_id=m0.id.
|
||||||
3. **Active contract version check** → skip (no write) if absent.
|
3. **Active contract version check** → skip (no write) if absent.
|
||||||
4. **Boundary register readings** within m0's window → write degraded if missing.
|
4. **Boundary register readings** within m0's window → write degraded if missing.
|
||||||
@@ -98,8 +98,8 @@ from app.integrations.pricing.strategies import (
|
|||||||
get_strategy,
|
get_strategy,
|
||||||
)
|
)
|
||||||
from app.models.energy import DsmrReading, EnergyCostPeriod, Meter
|
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.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
|
from app.services.timezone import local_date, local_now
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -174,6 +174,23 @@ def _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None:
|
|||||||
).scalar_one_or_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)
|
# register_at — boundary reading lookup (meter-aware)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -183,6 +200,8 @@ def register_at(
|
|||||||
session: Session,
|
session: Session,
|
||||||
boundary: datetime,
|
boundary: datetime,
|
||||||
meter: Meter,
|
meter: Meter,
|
||||||
|
*,
|
||||||
|
meter_source_id: int | None = None,
|
||||||
) -> dict[str, Decimal] | None:
|
) -> dict[str, Decimal] | None:
|
||||||
"""Return the four cumulative kWh register values at *boundary*, within *meter*'s window.
|
"""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).
|
# Apply the upper bound only when the meter is closed (ended_at is not None).
|
||||||
if meter_upper is not None:
|
if meter_upper is not None:
|
||||||
stmt = stmt.where(DsmrReading.recorded_at < meter_upper)
|
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()
|
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
|
# compute_period — single 15-minute period
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -302,9 +350,9 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
Side-effects
|
Side-effects
|
||||||
------------
|
------------
|
||||||
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
|
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
|
||||||
- If no meter covers t0 (``meter_at`` returns None for t0): inserts/updates
|
- If no unique meter covers t0: inserts/updates a degraded row with
|
||||||
a degraded row with ``meter_id=None``.
|
``meter_id=None``.
|
||||||
- If the period spans a meter boundary (``meter_at(t0).id != meter_at(t1).id``):
|
- If the period spans a meter boundary (m0.id != m1.id):
|
||||||
inserts/updates a degraded row with ``meter_id=m0.id`` (D5 decision).
|
inserts/updates a degraded row with ``meter_id=m0.id`` (D5 decision).
|
||||||
- If readings are missing at either boundary within the meter window:
|
- If readings are missing at either boundary within the meter window:
|
||||||
inserts/updates a degraded row with ``meter_id=m0.id``.
|
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.
|
# is corrected and a recompute_range is triggered.
|
||||||
#
|
#
|
||||||
# Ordering rationale:
|
# 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.
|
# 2. Cross-meter boundary (m0.id != m1.id) → degraded(meter_id=m0.id): D5.
|
||||||
# 3. (Single meter, proceed) → contract check → readings → delta guard → price.
|
# 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
|
# first, a missing-contract skip would silently discard the cross-table
|
||||||
# evidence; once a contract is added and recompute runs, the engine would
|
# evidence; once a contract is added and recompute runs, the engine would
|
||||||
# incorrectly use cross-table reads.
|
# incorrectly use cross-table reads.
|
||||||
m0 = meter_at(session, t0)
|
m0 = _unique_electricity_meter_at(session, t0)
|
||||||
m1 = meter_at(session, t1)
|
m1 = _unique_electricity_meter_at(session, t1)
|
||||||
|
|
||||||
if m0 is None:
|
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(
|
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(),
|
t0.isoformat(),
|
||||||
)
|
)
|
||||||
_upsert_degraded(session, t0, now, existing, meter_id=None)
|
_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)
|
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
|
||||||
return True
|
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 ---
|
# --- Active contract version at t0 ---
|
||||||
# If there is no active contract covering t0, skip the period entirely.
|
# 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
|
# 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
|
return False
|
||||||
|
|
||||||
# --- Boundary readings within m0's meter window ---
|
# --- Boundary readings within m0's meter window ---
|
||||||
start_regs = register_at(session, t0, m0)
|
start_regs = register_at(session, t0, m0, meter_source_id=meter_source_id)
|
||||||
end_regs = register_at(session, t1, m0)
|
end_regs = register_at(session, t1, m0, meter_source_id=meter_source_id)
|
||||||
|
|
||||||
if start_regs is None or end_regs is None:
|
if start_regs is None or end_regs is None:
|
||||||
# Missing readings within the meter window → degraded with m0 attribution.
|
# 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:
|
except TibberPriceNotFoundError:
|
||||||
# Missing Tibber price → skip the period; it will be retried once the
|
# Missing Tibber price → skip the period; it will be retried once the
|
||||||
# price arrives (e.g. after the next Tibber refresh job runs).
|
# price arrives (e.g. after the next Tibber refresh job runs).
|
||||||
logger.debug(
|
logger.debug("compute_period(%s): no Tibber price found — skipping.", t0.isoformat())
|
||||||
"compute_period(%s): no Tibber price found — skipping.", t0.isoformat()
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# --- Upsert the billing record ---
|
# --- Upsert the billing record ---
|
||||||
@@ -445,6 +501,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
existing.pricing = pricing
|
existing.pricing = pricing
|
||||||
existing.contract_version_id = version.id
|
existing.contract_version_id = version.id
|
||||||
existing.meter_id = m0.id
|
existing.meter_id = m0.id
|
||||||
|
existing.source_binding_id = binding.id
|
||||||
existing.degraded = False
|
existing.degraded = False
|
||||||
existing.computed_at = now
|
existing.computed_at = now
|
||||||
else:
|
else:
|
||||||
@@ -461,6 +518,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
pricing=pricing,
|
pricing=pricing,
|
||||||
contract_version_id=version.id,
|
contract_version_id=version.id,
|
||||||
meter_id=m0.id,
|
meter_id=m0.id,
|
||||||
|
source_binding_id=binding.id,
|
||||||
degraded=False,
|
degraded=False,
|
||||||
computed_at=now,
|
computed_at=now,
|
||||||
)
|
)
|
||||||
@@ -520,6 +578,7 @@ def _upsert_degraded(
|
|||||||
existing.pricing = {}
|
existing.pricing = {}
|
||||||
existing.contract_version_id = None
|
existing.contract_version_id = None
|
||||||
existing.meter_id = meter_id
|
existing.meter_id = meter_id
|
||||||
|
existing.source_binding_id = None
|
||||||
existing.degraded = True
|
existing.degraded = True
|
||||||
existing.computed_at = now
|
existing.computed_at = now
|
||||||
else:
|
else:
|
||||||
@@ -536,6 +595,7 @@ def _upsert_degraded(
|
|||||||
pricing={},
|
pricing={},
|
||||||
contract_version_id=None,
|
contract_version_id=None,
|
||||||
meter_id=meter_id,
|
meter_id=meter_id,
|
||||||
|
source_binding_id=None,
|
||||||
degraded=True,
|
degraded=True,
|
||||||
computed_at=now,
|
computed_at=now,
|
||||||
)
|
)
|
||||||
@@ -756,12 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
end_utc = _as_utc(end)
|
end_utc = _as_utc(end)
|
||||||
|
|
||||||
# --- Fetch all EnergyCostPeriod rows in [start, end) ---
|
# --- Fetch all EnergyCostPeriod rows in [start, end) ---
|
||||||
rows = session.execute(
|
rows = (
|
||||||
|
session.execute(
|
||||||
select(EnergyCostPeriod).where(
|
select(EnergyCostPeriod).where(
|
||||||
EnergyCostPeriod.period_start >= start_utc,
|
EnergyCostPeriod.period_start >= start_utc,
|
||||||
EnergyCostPeriod.period_start < end_utc,
|
EnergyCostPeriod.period_start < end_utc,
|
||||||
)
|
)
|
||||||
).scalars().all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
good_rows = [r for r in rows if not r.degraded]
|
good_rows = [r for r in rows if not r.degraded]
|
||||||
degraded_rows = [r for r in rows if 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]] = []
|
version_segments: list[tuple[_date, _date | None, dict]] = []
|
||||||
for v in versions:
|
for v in versions:
|
||||||
v_start_local = local_date(_as_utc(v.effective_from))
|
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 {}))
|
version_segments.append((v_start_local, v_end_local, v.values or {}))
|
||||||
|
|
||||||
for v_start, v_end_excl, v_values in version_segments:
|
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]
|
### M8-T05 — 电费计算绑定 Source Binding [structural]
|
||||||
|
|
||||||
- **Status**: `todo`
|
- **Status**: `done`
|
||||||
- **Depends**: M8-T04
|
- **Depends**: M8-T04
|
||||||
- **Context**: DSMR 已多 source 后,电力 period 必须只在同一 Meter/binding 累计域内计算。
|
- **Context**: DSMR 已多 source 后,电力 period 必须只在同一 Meter/binding 累计域内计算。
|
||||||
|
|
||||||
@@ -461,6 +461,10 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
- `modify app/schemas/energy.py`
|
- `modify app/schemas/energy.py`
|
||||||
- `modify tests/test_energy_cost.py`
|
- `modify tests/test_energy_cost.py`
|
||||||
- `modify tests/test_api_energy.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**
|
**Steps**
|
||||||
1. 为 period 两个边界按时间解析唯一 electricity Meter、binding 和 DSMR source;查询 reading 时加入
|
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;
|
4. 用现有 golden tests 锁住单 source 正常 period 的 quantity、cost、rounding、fixed fee 与 summary;
|
||||||
增加 source switch、binding boundary、missing/ambiguous binding 测试。
|
增加 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 / 不要碰**
|
**Out of scope / 不要碰**
|
||||||
- 不实现 thermal cost,不修改合同 scope,不改变正常电价公式。
|
- 不实现 thermal cost,不修改合同 scope,不改变正常电价公式。
|
||||||
@@ -480,11 +488,14 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
- [ ] 正常新周期总能审计到唯一 binding,跨域周期明确 degraded。
|
- [ ] 正常新周期总能审计到唯一 binding,跨域周期明确 degraded。
|
||||||
- [ ] 既有单 DSMR source 的所有非降级数字逐项不变。
|
- [ ] 既有单 DSMR source 的所有非降级数字逐项不变。
|
||||||
- [ ] recompute 幂等,不能把 source A 起点和 source B 终点相减。
|
- [ ] recompute 幂等,不能把 source A 起点和 source B 终点相减。
|
||||||
- [ ] `pytest`、`ruff check .` 全绿。
|
- [ ] fixed-fee/credit golden tests 在 01:05 前后任意实际运行时刻均确定性通过,生产结算点不变。
|
||||||
|
- [ ] `pytest`、`ruff check .`、OpenAPI/codegen 同步闸门全绿且生成物已提交。
|
||||||
|
|
||||||
**Reviewer checklist**
|
**Reviewer checklist**
|
||||||
- 重点构造 Meter 相同但 source 切换、source 相同但 Meter 换表两种边界。
|
- 重点构造 Meter 相同但 source 切换、source 相同但 Meter 换表两种边界。
|
||||||
- 检查 Decimal/rounding 和本地日 fixed-fee 逻辑是否被无意改变。
|
- 检查 Decimal/rounding 和本地日 fixed-fee 逻辑是否被无意改变。
|
||||||
|
- 检查 expose golden tests 是否显式固定业务时钟,而不是等待 wall clock 或放宽 01:05 断言。
|
||||||
|
- 独立重导 OpenAPI 与 codegen,确认 schema 生成物同步且不是手改。
|
||||||
|
|
||||||
### M8-T06 — Source / Channel / Binding HTTP 契约 [structural]
|
### 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).
|
* @description FK to the contract version used for this billing period (null when degraded).
|
||||||
*/
|
*/
|
||||||
contract_version_id?: number | null;
|
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
|
* CostsResponse
|
||||||
|
|||||||
@@ -3086,6 +3086,18 @@
|
|||||||
],
|
],
|
||||||
"title": "Contract Version Id",
|
"title": "Contract Version Id",
|
||||||
"description": "FK to the contract version used for this billing period (null when degraded)."
|
"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",
|
"type": "object",
|
||||||
|
|||||||
@@ -2442,6 +2442,13 @@ components:
|
|||||||
title: Contract Version Id
|
title: Contract Version Id
|
||||||
description: FK to the contract version used for this billing period (null
|
description: FK to the contract version used for this billing period (null
|
||||||
when degraded).
|
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
|
type: object
|
||||||
required:
|
required:
|
||||||
- period_start
|
- 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()
|
start = (datetime.now(UTC) - timedelta(hours=3)).isoformat()
|
||||||
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
|
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
|
||||||
resp = client.get(
|
resp = client.get("/api/energy/prices", params={"start": start, "end": end, "limit": 2})
|
||||||
"/api/energy/prices", params={"start": start, "end": end, "limit": 2}
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert len(body["points"]) <= 2
|
assert len(body["points"]) <= 2
|
||||||
@@ -508,9 +506,16 @@ def test_costs_schema_fields_present(energy_client):
|
|||||||
item = resp.json()["items"][0]
|
item = resp.json()["items"][0]
|
||||||
for field in (
|
for field in (
|
||||||
"period_start",
|
"period_start",
|
||||||
"d1_kwh", "d2_kwh", "r1_kwh", "r2_kwh",
|
"d1_kwh",
|
||||||
"import_cost", "export_revenue", "net_cost",
|
"d2_kwh",
|
||||||
"currency", "degraded",
|
"r1_kwh",
|
||||||
|
"r2_kwh",
|
||||||
|
"import_cost",
|
||||||
|
"export_revenue",
|
||||||
|
"net_cost",
|
||||||
|
"currency",
|
||||||
|
"degraded",
|
||||||
|
"source_binding_id",
|
||||||
):
|
):
|
||||||
assert field in item, f"Missing field: {field}"
|
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()
|
start = (datetime.now(UTC) - timedelta(hours=3)).isoformat()
|
||||||
end = datetime.now(UTC).isoformat()
|
end = datetime.now(UTC).isoformat()
|
||||||
resp = client.get(
|
resp = client.get("/api/energy/costs/summary", params={"start": start, "end": end})
|
||||||
"/api/energy/costs/summary", params={"start": start, "end": end}
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
for field in (
|
for field in (
|
||||||
@@ -903,5 +906,3 @@ def test_tibber_test_token_not_in_response(energy_client):
|
|||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert secret_token not in resp.text
|
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
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
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
|
# 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)
|
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
|
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:
|
with Session(energy_db) as session:
|
||||||
# Pin to UTC so local days = UTC days (deterministic on any CI host).
|
# 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)
|
catalog = build_catalog(session)
|
||||||
import_entry = next(
|
import_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
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
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
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)
|
meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4)
|
||||||
effective_from = meter_started_at
|
effective_from = meter_started_at
|
||||||
|
|
||||||
@@ -1186,7 +1195,10 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
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)
|
catalog = build_catalog(session)
|
||||||
export_entry = next(
|
export_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.export_revenue_total"
|
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
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
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)
|
midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
# Contract starts 180 days ago (far before the meter)
|
# 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()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
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)
|
catalog = build_catalog(session)
|
||||||
import_entry = next(
|
import_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
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
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
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)
|
midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
old_meter_start = midnight_today - timedelta(days=30)
|
old_meter_start = midnight_today - timedelta(days=30)
|
||||||
@@ -2145,7 +2166,10 @@ def test_cumulative_resets_after_meter_swap(energy_db) -> None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
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)
|
catalog = build_catalog(session)
|
||||||
import_entry = next(
|
import_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.import_cost_total"
|
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
|
from app.services import timezone as _tz_mod
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
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
|
# Period 1h ago — in today's UTC window
|
||||||
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||||
if t0.date() < now_utc.date():
|
if t0.date() < now_utc.date():
|
||||||
@@ -2226,7 +2253,10 @@ def test_daily_getters_unaffected_by_d2_meter_anchor(energy_db) -> None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
with Session(energy_db) as session:
|
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)
|
catalog = build_catalog(session)
|
||||||
import_today_entry = next(
|
import_today_entry = next(
|
||||||
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
||||||
|
|||||||
Reference in New Issue
Block a user