7 Commits
26 changed files with 6162 additions and 192 deletions
@@ -0,0 +1,238 @@
"""add meter table and energy_cost_period.meter_id
Introduces the ``meter`` table (one row per physical meter installation epoch)
and a nullable FK column ``energy_cost_period.meter_id`` that attributes each
billing period to a specific physical meter.
**Backfill logic (§3.7 of the M7 design doc)**:
If the database already contains any ``dsmr_reading`` or
``energy_cost_period`` rows, one initial ``meter`` row is created:
label = "Initial meter"
commodity = "electricity"
started_at = earliest dsmr_reading.recorded_at
(or, if none, earliest energy_cost_period.period_start,
or, if still none, the migration timestamp)
ended_at = NULL (still active)
reason = "initial"
All existing ``energy_cost_period`` rows are then back-filled with that
initial meter's id.
**Idempotency**: the backfill is guarded with a check for any existing
``meter`` row whose ``reason = 'initial'`` and ``commodity = 'electricity'``
and ``ended_at IS NULL``, so repeating the upgrade does not create duplicate
meters or overwrite already-filled meter_id values.
**Audit (on-non-degraded periods only)**: after backfilling, the number of
non-degraded ``energy_cost_period`` rows with ``meter_id IS NULL`` must be
zero; if it is not, the migration raises a ``RuntimeError`` and rolls back.
**Data safety**: this migration is additive only — no existing rows are
deleted or overwritten; it only creates a new table, adds a nullable column,
and back-fills that column.
Revision ID: 20260625_13_meter_table
Revises: 20260624_12_dsmr_decouple_telegram_id
Create Date: 2026-06-25 00:00:00.000000
"""
from datetime import datetime, timezone
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "20260625_13_meter_table"
down_revision: Union[str, None] = "20260624_12_dsmr_decouple_telegram_id"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ------------------------------------------------------------------ #
# 1. Create the meter table. #
# ------------------------------------------------------------------ #
op.create_table(
"meter",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("commodity", sa.String(length=32), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reason", sa.String(length=64), nullable=False),
sa.Column("note", sa.String(length=1024), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# ------------------------------------------------------------------ #
# 2. Add meter_id column to energy_cost_period (nullable FK). #
# ------------------------------------------------------------------ #
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
batch_op.add_column(
sa.Column("meter_id", sa.Integer(), nullable=True)
)
batch_op.create_foreign_key(
"fk_energy_cost_period_meter_id",
"meter",
["meter_id"],
["id"],
ondelete="RESTRICT",
)
# ------------------------------------------------------------------ #
# 3. Backfill initial meter (idempotent). #
# ------------------------------------------------------------------ #
conn = op.get_bind()
# Check if there is already an initial meter (idempotency guard).
existing_initial = conn.execute(
sa.text(
"SELECT id FROM meter "
"WHERE reason = 'initial' AND commodity = 'electricity' AND ended_at IS NULL "
"LIMIT 1"
)
).fetchone()
if existing_initial is not None:
# Already backfilled — nothing to do.
return
# Determine whether there is any historical data to create a meter for.
has_readings = conn.execute(
sa.text("SELECT 1 FROM dsmr_reading LIMIT 1")
).fetchone()
has_periods = conn.execute(
sa.text("SELECT 1 FROM energy_cost_period LIMIT 1")
).fetchone()
if not has_readings and not has_periods:
# Empty database: no historical data, so no initial meter is needed.
# meter_id will remain NULL on any future rows until T02 service layer
# starts populating it.
return
# Determine started_at: earliest dsmr_reading.recorded_at, falling back to
# earliest energy_cost_period.period_start, and finally to now().
earliest_reading_row = conn.execute(
sa.text("SELECT MIN(recorded_at) AS ts FROM dsmr_reading")
).fetchone()
earliest_period_row = conn.execute(
sa.text("SELECT MIN(period_start) AS ts FROM energy_cost_period")
).fetchone()
started_at_value: datetime | None = None
if earliest_reading_row and earliest_reading_row[0] is not None:
# SQLite returns ISO strings for datetime columns; parse to datetime.
raw = earliest_reading_row[0]
started_at_value = _parse_sqlite_datetime(raw)
if started_at_value is None and earliest_period_row and earliest_period_row[0] is not None:
raw = earliest_period_row[0]
started_at_value = _parse_sqlite_datetime(raw)
if started_at_value is None:
started_at_value = datetime.now(tz=timezone.utc)
now_utc = datetime.now(tz=timezone.utc)
# Insert the initial meter row.
conn.execute(
sa.text(
"INSERT INTO meter (label, commodity, started_at, ended_at, reason, note, created_at) "
"VALUES (:label, :commodity, :started_at, NULL, :reason, NULL, :created_at)"
),
{
"label": "Initial meter",
"commodity": "electricity",
"started_at": _iso(started_at_value),
"reason": "initial",
"created_at": _iso(now_utc),
},
)
# Retrieve the newly created meter id.
meter_row = conn.execute(
sa.text(
"SELECT id FROM meter "
"WHERE reason = 'initial' AND commodity = 'electricity' AND ended_at IS NULL "
"LIMIT 1"
)
).fetchone()
assert meter_row is not None, "Initial meter row not found after insert"
meter_id: int = meter_row[0]
# Back-fill all existing energy_cost_period rows that have meter_id IS NULL.
conn.execute(
sa.text(
"UPDATE energy_cost_period SET meter_id = :mid WHERE meter_id IS NULL"
),
{"mid": meter_id},
)
# ------------------------------------------------------------------ #
# 4. Audit: verify all non-degraded periods have a meter_id. #
# ------------------------------------------------------------------ #
unmatched_row = conn.execute(
sa.text(
"SELECT COUNT(*) FROM energy_cost_period "
"WHERE meter_id IS NULL AND degraded = 0"
)
).fetchone()
unmatched_count: int = unmatched_row[0] if unmatched_row else 0
if unmatched_count != 0:
raise RuntimeError(
f"Meter backfill audit failed: {unmatched_count} non-degraded "
"energy_cost_period row(s) still have meter_id IS NULL after backfill. "
"Migration aborted to protect data integrity."
)
def downgrade() -> None:
# Remove the FK column from energy_cost_period first (references meter).
with op.batch_alter_table("energy_cost_period", schema=None) as batch_op:
batch_op.drop_constraint("fk_energy_cost_period_meter_id", type_="foreignkey")
batch_op.drop_column("meter_id")
# Drop the meter table.
op.drop_table("meter")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_sqlite_datetime(value: str | datetime) -> datetime:
"""Parse a SQLite datetime value into an aware UTC datetime.
SQLite stores datetimes as ISO 8601 strings. SQLAlchemy may return them
as plain strings or as naive datetimes (no tzinfo) depending on the driver
and column declaration. This helper normalises both forms to an aware UTC
``datetime``.
"""
if isinstance(value, datetime):
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
# String form — strip trailing Z or +00:00 variants, then attach UTC.
s = str(value).strip()
for suffix in ("+00:00", "Z", " UTC"):
if s.endswith(suffix):
s = s[: -len(suffix)]
# SQLite uses space as the T separator in some formats.
s = s.replace(" ", "T")
try:
dt = datetime.fromisoformat(s)
except ValueError:
# Fallback: strip subseconds if present to handle unusual formats.
dt = datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
return dt.replace(tzinfo=timezone.utc)
def _iso(dt: datetime) -> str:
"""Serialise a datetime to an ISO 8601 string for SQLite storage."""
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt.strftime("%Y-%m-%dT%H:%M:%S")
+302
View File
@@ -0,0 +1,302 @@
"""Meter CRUD, swap declaration, and retroactive recompute API (M7-T05).
All endpoints are under /api/energy/meters, require an authenticated session,
and write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
header.
Route semantics
---------------
GET /api/energy/meters — list all meter epochs (ascending started_at)
POST /api/energy/meters — declare a meter swap / initial meter epoch
PATCH /api/energy/meters/{id} — edit label / note, or correct started_at (retroactive)
Retroactive recompute
---------------------
Whenever a write operation changes a meter's ``started_at`` (new declaration
or PATCH correction), the affected billing window is re-judged via
``recompute_range``:
- **POST** (new meter, possibly retroactive):
window = [new_meter.started_at, now)
Rationale: the new meter's ``started_at`` closes the previous meter at that
point; all periods from that boundary forward may have a different meter
attribution. Using ``now`` as the upper bound is safe because
``recompute_range`` only processes closed quarters and the operation is
idempotent.
- **PATCH started_at** (retroactive correction):
window = [min(old_started_at, new_started_at), now)
Rationale: shifting the boundary in either direction affects all periods
between the old and new boundary (and potentially beyond if re-attribution
cascades). Using the minimum of the two timestamps guarantees the entire
affected range is covered; using ``now`` as the upper bound is safe and
idempotent.
``started_at`` localisation (Principle A, FU10 convention)
----------------------------------------------------------
If the client sends a timezone-naive ``started_at`` value, it is interpreted as
the **server's local wall-clock time** and converted to UTC before storage.
Timezone-aware values are converted to UTC as-is. This is identical to the
``_localize_effective_from`` convention used in ``energy_contracts.py``.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.models.energy import Meter
from app.schemas.meter import (
MeterDeclareRequest,
MeterListResponse,
MeterPatchRequest,
MeterResponse,
)
from app.services import timezone as _tz_mod
from app.services.auth import AuthenticatedSession
from app.services.energy_cost import recompute_range
from app.services.meters import (
MeterIntervalError,
MeterOverlapError,
declare_meter,
list_meters,
update_meter,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
"""Return the meter with the given id or raise 404."""
meter: Optional[Meter] = db.get(Meter, meter_id)
if meter is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Meter {meter_id!r} not found.",
)
return meter
def _localize_started_at(dt: datetime) -> datetime:
"""Resolve *dt* to an aware UTC datetime for storage.
Follows the same Principle-A convention as ``_localize_effective_from``
in ``energy_contracts.py`` (FU10):
- Timezone-aware → convert to UTC as-is.
- Timezone-naive → interpret as server local wall-clock time, localize
with ``local_tz()``, then convert to UTC.
A front-end sending ``"2026-06-25T00:00:00"`` (no Z) has it interpreted
as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
not as UTC midnight.
"""
if dt.tzinfo is not None:
return dt.astimezone(UTC)
tz = _tz_mod.local_tz()
local_dt = dt.replace(tzinfo=tz)
return local_dt.astimezone(UTC)
def _trigger_recompute(db: Session, start: datetime, label: str) -> int:
"""Trigger recompute_range from *start* to now (UTC).
This is the standard "retroactive window" call: everything from the
affected boundary up to the current moment needs re-attribution.
Using ``now`` as the upper bound is safe because ``recompute_range``
only touches closed quarter-hour periods and the operation is idempotent.
"""
end = datetime.now(UTC)
if start >= end:
# started_at is in the future — nothing to recompute.
logger.info("%s: started_at (%s) is in the future, skipping recompute.", label, start)
return 0
n = recompute_range(db, start, end)
logger.info(
"%s: recomputed %d period(s) in window [%s, %s).",
label,
n,
start.isoformat(),
end.isoformat(),
)
return n
# ---------------------------------------------------------------------------
# GET /api/energy/meters
# ---------------------------------------------------------------------------
@router.get("/meters", response_model=MeterListResponse)
def list_energy_meters(
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
) -> MeterListResponse:
"""List all meter epochs in ascending ``started_at`` order.
Returns the full historical sequence of meter installations across all
commodities. The active meter (``ended_at=null``) appears last because it
has the latest ``started_at``.
"""
meters = list_meters(db)
items = [MeterResponse.model_validate(m) for m in meters]
return MeterListResponse(items=items, total=len(items))
# ---------------------------------------------------------------------------
# POST /api/energy/meters
# ---------------------------------------------------------------------------
@router.post(
"/meters",
response_model=MeterResponse,
status_code=status.HTTP_201_CREATED,
)
def declare_energy_meter(
body: MeterDeclareRequest,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> MeterResponse:
"""Declare a new meter epoch (swap, home move, or initial declaration).
Closes the current active meter for the given commodity at ``started_at``
and opens a new active meter. If no active meter exists, the new meter is
simply created without closing anything.
**Validation**: ``started_at`` must be **≥** the current active meter's
own ``started_at`` (no chronological backdate below the active epoch's
start). Equal timestamps are allowed (replaces the current meter at the
same logical moment). Violation → 422.
**Retroactive recompute**: if ``started_at`` is in the past, billing
records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The response includes the count of
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
— the recompute is transparent; callers should re-fetch costs if needed).
"""
started_at_utc = _localize_started_at(body.started_at)
try:
new_meter = declare_meter(
db,
label=body.label,
started_at=started_at_utc,
reason=body.reason.value,
commodity=body.commodity,
note=body.note,
)
except MeterOverlapError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
db.flush() # assign PK before recompute (recompute uses session, needs meter in DB)
# Retroactive recompute: re-judge attribution from the new boundary onward.
now = datetime.now(UTC)
if started_at_utc < now:
_trigger_recompute(db, started_at_utc, "POST /api/energy/meters")
db.commit()
db.refresh(new_meter)
logger.info(
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
body.commodity,
new_meter.id,
new_meter.label,
started_at_utc.isoformat(),
)
return MeterResponse.model_validate(new_meter)
# ---------------------------------------------------------------------------
# PATCH /api/energy/meters/{id}
# ---------------------------------------------------------------------------
@router.patch("/meters/{meter_id}", response_model=MeterResponse)
def patch_energy_meter(
meter_id: int,
body: MeterPatchRequest,
db: Session = Depends(get_db),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> MeterResponse:
"""Partially update a meter epoch: rename, edit note, or correct started_at.
- ``label``: updates the human-readable label.
- ``note``: updates the free-form note.
- ``started_at``: **retroactive correction** — shifts this meter's start
boundary. The service layer maintains timeline continuity by also
updating the preceding meter's ``ended_at``. Validation:
* Must be strictly after the previous meter's own ``started_at``.
* Must be strictly before this meter's ``ended_at`` (if closed).
Violation → 422.
**Retroactive recompute when ``started_at`` changes**: billing records in
the window ``[min(old, new), now)`` are re-judged to reflect the corrected
meter attribution.
Not found → 404.
"""
meter = _get_meter_or_404(db, meter_id)
# Capture old started_at before mutation (needed for recompute window).
old_started_at: Optional[datetime] = meter.started_at
# Localise started_at if provided.
new_started_at_utc: Optional[datetime] = None
if body.started_at is not None:
new_started_at_utc = _localize_started_at(body.started_at)
try:
update_meter(
db,
meter,
label=body.label,
note=body.note,
started_at=new_started_at_utc,
)
except MeterIntervalError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
# Retroactive recompute if started_at was changed.
if new_started_at_utc is not None and old_started_at is not None:
# Normalise old_started_at to UTC-aware for comparison.
if old_started_at.tzinfo is None:
old_started_at = old_started_at.replace(tzinfo=UTC)
# Window = [min(old, new), now) — covers all periods whose attribution
# may have changed due to the boundary shift in either direction.
window_start = min(old_started_at, new_started_at_utc)
_trigger_recompute(db, window_start, f"PATCH /api/energy/meters/{meter_id}")
db.commit()
db.refresh(meter)
logger.info(
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
meter_id,
meter.label,
meter.started_at,
)
return MeterResponse.model_validate(meter)
+54 -52
View File
@@ -549,53 +549,57 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_import_cost_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative import cost plus prorated standing charges.
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``,
where ``anchor_utc`` is the **max** of:
- the earliest version's effective_from (contract billing start), and
- the earliest non-degraded EnergyCostPeriod.period_start (recording start).
D2 (M7): anchor = current active electricity meter's ``started_at``.
After a meter swap the cumulative resets to zero for the new meter —
old-meter periods have ``period_start < new_meter.started_at`` and fall
outside the [anchor, now) window, so they are naturally excluded.
Cross-meter boundary periods are already degraded and also excluded.
This prevents counting fixed costs for the period before any data was recorded
(e.g. contract starts Jan 1 but data recording only starts Jun 17 — we do not
want to include ~€270 of standing charges for a period with no meter data).
No active electricity meter → returns None (cannot anchor; safer than
returning a stale or wrong value). The check is done via an inline
query (``ended_at IS NULL``) rather than ``meter_at(now)`` to be robust
against the edge case where the active meter's ``started_at`` is in the
future (``meter_at(now)`` would return None in that scenario).
No non-degraded periods at all → returns None (has_data guard).
No active contract → ``summarize`` still runs but fixed_costs = 0;
the return value is the pure metered sum within the current meter window.
This is consistent with D2: the anchor is the meter, not the contract.
value = summarize(anchor → now).metered_import + summarize(anchor → now).fixed_costs
Returns None when no non-degraded period exists.
No arithmetic is done here — all fixed-cost/credit accounting lives in summarize().
"""
def _getter(sess: "Session") -> Any:
from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions
from app.services.contracts import _as_utc as _cu
from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP
from sqlalchemy import func as _func
from sqlalchemy import func as _func, select as _select
# Quick check: any non-degraded period at all? (avoids full summarize overhead
# when there are zero periods, which should return None)
# Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
_ECP.degraded.is_(False)
).scalar()
if has_data is None:
return None
versions = active_contract_versions(sess)
if not versions:
# No active contract — return pure SUM(import_cost) without standing charges.
return float(has_data)
# D2: anchor = active electricity meter's started_at.
# Inline query (ended_at IS NULL) is more robust than meter_at(now)
# because it avoids the edge case where started_at is in the future.
active_meter = sess.execute(
_select(_Meter)
.where(
_Meter.commodity == "electricity",
_Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
# anchor = max(contract billing start, earliest recording start).
# This prevents accumulating fixed costs for days with no meter data.
contract_anchor_utc = _cu(versions[0].effective_from)
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter(
_ECP.degraded.is_(False)
).scalar()
if earliest_period_start is not None:
recording_anchor_utc = _cu(earliest_period_start)
anchor_utc = max(contract_anchor_utc, recording_anchor_utc)
else:
anchor_utc = contract_anchor_utc
if active_meter is None:
# No active electricity meter — cannot anchor; return None.
return None
from app.services.contracts import _as_utc as _cu
anchor_utc = _cu(active_meter.started_at)
now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, now_utc)
@@ -608,20 +612,18 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
"""Return a getter for the cumulative export revenue plus prorated tax credit.
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``.
Anchor is the same max(contract_start, recording_start) logic as import getter.
D2 (M7): anchor = current active electricity meter's ``started_at``.
Same reasoning as the import cost getter — see its docstring.
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
Returns None when no non-degraded period exists in [anchor, now].
Returns None when no non-degraded period exists or no active electricity meter.
"""
def _getter(sess: "Session") -> Any:
from datetime import UTC, datetime as _dt
from app.services.contracts import active_contract_versions
from app.services.contracts import _as_utc as _cu
from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
from app.services.energy_cost import summarize as _summarize
from app.models.energy import EnergyCostPeriod as _ECP
from sqlalchemy import func as _func
from sqlalchemy import func as _func, select as _select
# Quick check: any non-degraded period at all?
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
@@ -630,22 +632,22 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
if has_data is None:
return None
versions = active_contract_versions(sess)
if not versions:
# No active contract — return pure SUM(export_revenue) without credits.
return float(has_data)
# D2: anchor = active electricity meter's started_at.
active_meter = sess.execute(
_select(_Meter)
.where(
_Meter.commodity == "electricity",
_Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
# anchor = max(contract billing start, earliest recording start).
contract_anchor_utc = _cu(versions[0].effective_from)
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter(
_ECP.degraded.is_(False)
).scalar()
if earliest_period_start is not None:
recording_anchor_utc = _cu(earliest_period_start)
anchor_utc = max(contract_anchor_utc, recording_anchor_utc)
else:
anchor_utc = contract_anchor_utc
if active_meter is None:
# No active electricity meter — cannot anchor; return None.
return None
from app.services.contracts import _as_utc as _cu
anchor_utc = _cu(active_meter.started_at)
now_utc = _dt.now(UTC)
result = _summarize(sess, anchor_utc, now_utc)
+2
View File
@@ -16,6 +16,7 @@ from app.api.routes.api.data import router as api_data_router
from app.api.routes.api.energy import router as api_energy_router
from app.api.routes.api.energy_contracts import router as api_energy_contracts_router
from app.api.routes.api.expose import router as api_expose_router
from app.api.routes.api.meters import router as api_meters_router
from app.api.routes.api.modbus import router as api_modbus_router
from app.api.routes.api.session import router as api_session_router
from app.api.routes import status
@@ -280,6 +281,7 @@ def create_app() -> FastAPI:
app.include_router(api_data_router)
app.include_router(api_energy_router)
app.include_router(api_energy_contracts_router)
app.include_router(api_meters_router)
app.include_router(api_expose_router)
app.include_router(api_modbus_router)
app.include_router(api_session_router)
+69 -1
View File
@@ -1,6 +1,7 @@
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
Five tables:
Six tables:
- meter: physical electricity meter lifecycle epoch.
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
- energy_contract: contract head (manual or tibber, one active at a time).
- energy_contract_version: versioned pricing values; append-only for auditability.
@@ -19,6 +20,62 @@ from sqlalchemy.types import JSON
from app.db import Base
class Meter(Base):
"""One physical electricity meter's installation epoch.
A ``meter`` record represents the period ``[started_at, ended_at)`` during
which a particular physical meter was installed and active. Replacing a meter
(swap, home move, etc.) is modelled by closing the current record
(``ended_at = swap_timestamp``) and opening a new one
(``started_at = swap_timestamp``).
**Invariant**: for each ``commodity`` there is at most one active meter
(``ended_at IS NULL``) at any point in time. The service layer enforces
this — no DB-level constraint is added to keep the migration simple and to
allow the application to return a meaningful error message.
``commodity`` defaults to ``"electricity"``; the field is a free-form string
(no CHECK constraint) so future commodities (``gas``, ``heating``) can be
added without a schema change.
``reason`` captures why this epoch started — one of ``initial``,
``meter_swap``, ``home_move``, or ``other`` — stored as a plain string so
the application layer controls the allowed set.
"""
__tablename__ = "meter"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
label: Mapped[str] = mapped_column(String(255), nullable=False)
# Energy commodity this meter measures. Defaults to "electricity".
commodity: Mapped[str] = mapped_column(String(32), nullable=False, default="electricity")
# UTC timestamp when this meter epoch starts (inclusive). May be in the past
# (retroactive declaration); effective billing start = max(started_at, data start).
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# UTC timestamp when this meter epoch ends (exclusive). NULL = currently active.
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Why this epoch was created. Application-layer validation enforces the
# allowed set; no CHECK constraint to keep migrations simple.
reason: Mapped[str] = mapped_column(String(64), nullable=False)
# Free-form note (e.g. location, physical meter id, reason details).
note: Mapped[str | None] = mapped_column(String(1024), nullable=True)
# UTC timestamp of when this row was created.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Relationship to cost periods attributed to this meter epoch (not loaded eagerly).
cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
back_populates="meter", cascade="save-update, merge"
)
class DsmrReading(Base):
"""One down-sampled DSMR telegram stored as a full JSON blob.
@@ -217,6 +274,14 @@ class EnergyCostPeriod(Base):
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
)
# FK to the meter epoch this period belongs to. RESTRICT prevents deletion of
# a meter that still has attributed cost periods. Nullable for backwards
# compatibility (pre-M7 rows) and degraded periods where the meter was not
# determinable.
meter_id: Mapped[int | None] = mapped_column(
ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
)
# True when the period was computed with incomplete data (missing readings or
# missing price); serves as a flag for later recomputation.
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -229,6 +294,9 @@ class EnergyCostPeriod(Base):
back_populates="cost_periods"
)
# Relationship back to the meter epoch.
meter: Mapped["Meter | None"] = relationship(back_populates="cost_periods")
# Index on recorded_at for efficient time-range queries on DSMR readings.
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
+131
View File
@@ -0,0 +1,131 @@
"""Pydantic schemas for the Meter CRUD + swap declaration API (M7-T05).
Schema hierarchy
----------------
MeterResponse — single meter row (id/label/commodity/started_at/ended_at/reason/note/created_at)
MeterListResponse — ordered list of MeterResponse items
MeterDeclareRequest — POST /api/energy/meters body (declare a swap or initial meter)
MeterPatchRequest — PATCH /api/energy/meters/{id} body (all fields optional)
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class MeterReason(str, Enum):
"""Allowed values for the meter epoch creation reason."""
initial = "initial"
meter_swap = "meter_swap"
home_move = "home_move"
other = "other"
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class MeterResponse(BaseModel):
"""Response schema for a single Meter epoch row.
``ended_at`` is ``null`` for the currently active meter.
"""
id: int
label: str
commodity: str
started_at: datetime
ended_at: datetime | None
reason: str
note: str | None
created_at: datetime
model_config = {"from_attributes": True}
class MeterListResponse(BaseModel):
"""Response schema for GET /api/energy/meters.
Meters are returned in ascending ``started_at`` order so the caller sees
the historical installation sequence.
"""
items: list[MeterResponse]
total: int
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
_VALID_REASONS = ", ".join(r.value for r in MeterReason)
class MeterDeclareRequest(BaseModel):
"""Request body for POST /api/energy/meters.
Declares a new meter epoch (swap, home move, or initial declaration). The
service layer closes the current active meter for the given commodity at
``started_at`` and opens a new one.
``started_at`` follows the Principle-A localisation convention: a
timezone-naive value is interpreted as the **server's local wall-clock time**
(e.g. CEST midnight → stored as UTC the night before); a timezone-aware
value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
every meter declaration must carry an explicit start timestamp.
``commodity`` defaults to ``"electricity"``; the field is available for
future use with ``gas`` or ``heating``.
"""
label: str = Field(..., min_length=1, max_length=255)
started_at: datetime = Field(
...,
description=(
"UTC (or server-local naive) datetime from which this meter epoch starts. "
"May be in the past (retroactive declaration)."
),
)
reason: MeterReason = Field(
...,
description=f"Why this epoch was created. One of: {_VALID_REASONS}.",
)
note: str | None = Field(default=None, max_length=1024)
commodity: str = Field(
default="electricity",
min_length=1,
max_length=32,
description="Energy commodity this meter measures. Defaults to 'electricity'.",
)
class MeterPatchRequest(BaseModel):
"""Request body for PATCH /api/energy/meters/{id}.
All fields are optional. Only non-``None`` values are applied.
Updating ``started_at`` is a **retroactive correction**: the service layer
maintains timeline continuity (adjusting the preceding meter's ``ended_at``)
and the API layer triggers ``recompute_range`` over the affected window so
that billing attribution is re-judged.
"""
label: str | None = Field(default=None, min_length=1, max_length=255)
note: str | None = Field(default=None, max_length=1024)
started_at: datetime | None = Field(
default=None,
description=(
"Retroactive correction of the meter epoch start timestamp. "
"Triggers billing recompute over the affected window."
),
)
+202 -24
View File
@@ -1,7 +1,7 @@
"""Billing engine for DSMR 15-minute energy metering periods.
This module implements the two-layer billing model described in §3.4 of the
M6 design document:
M6 design document, extended in M7-T03 to be meter-aware:
**Layer 1 — per-period metering cost (immutable, price-snapshot)**
``compute_period(session, t0)`` computes the import cost, export revenue, and
@@ -31,17 +31,49 @@ 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
``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
acceptable (D5 decision).
- *Missing readings* (``register_at`` returns None for start or end
boundary): write a ``degraded=True`` row with costs at 0 so the period is
tracked and can be retried by ``compute_closed_periods``.
boundary within the meter window): write a ``degraded=True`` row with
``meter_id=m0.id`` so the period is tracked and can be retried by
``compute_closed_periods``.
- *Negative or excessively large delta* (delta sanity guard D6): write a
``degraded=True`` row with ``meter_id=m0.id``; prevents negative costs and
grossly inflated costs from meter resets, DSMR rollover, or data spikes.
- *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do
not write a row); the period will be retried once prices arrive.
- *Missing active contract version*: skip (no contract to compute against).
- **Meter-aware register lookup**: ``register_at`` now accepts a ``meter``
parameter and restricts the DSMR reading query to readings within
``[meter.started_at, meter.ended_at)`` (half-open), preventing old-meter
readings from leaking into a new-meter epoch.
- **Lookback window in ``compute_closed_periods``**: to avoid scanning all
historical DSMR data on every tick, the function looks back at most 7 days
from the current time. This covers typical short outages (no data / no
contract) while staying bounded. Periods older than 7 days must be
recovered via an explicit ``recompute_range`` call.
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.
- 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.
5. **Delta sanity guard** → write degraded if any delta < 0 or > _MAX_DELTA_KWH.
6. **Price strategy** → skip (no write) if Tibber price missing.
7. **Upsert billing record** with meter_id=m0.id.
Why meter before contract? The meter is a *structural* prerequisite: without a
known meter epoch we cannot trust the delta at all, so we commit a degraded row
immediately. The contract skip, by contrast, is transient (the period can be
re-computed once a contract is configured), so it produces no row.
"""
from __future__ import annotations
@@ -59,8 +91,9 @@ from app.integrations.pricing.strategies import (
TibberPriceNotFoundError,
get_strategy,
)
from app.models.energy import DsmrReading, EnergyCostPeriod
from app.models.energy import DsmrReading, EnergyCostPeriod, Meter
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__)
@@ -81,6 +114,15 @@ _LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
# producing a spurious zero-delta "successful" row.
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# Maximum plausible kWh delta for a single 15-minute period (D6 sanity guard).
# A typical Dutch household uses well under 5 kWh per quarter hour even under
# heavy load. 100 kWh per 15 minutes corresponds to ~400 kW — far beyond any
# residential consumption — but is lenient enough to never fire on legitimate
# data. Any delta at or above this threshold indicates a meter reset, DSMR
# rollover, sign error, or other data anomaly, and the period is marked
# degraded to prevent negative costs or grossly inflated charges.
_MAX_DELTA_KWH = Decimal("100")
# 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)
@@ -123,15 +165,29 @@ def _existing_period(session: Session, t0: datetime) -> EnergyCostPeriod | None:
# ---------------------------------------------------------------------------
# register_at — boundary reading lookup
# register_at — boundary reading lookup (meter-aware)
# ---------------------------------------------------------------------------
def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | None:
"""Return the four cumulative kWh register values at *boundary*.
def register_at(
session: Session,
boundary: datetime,
meter: Meter,
) -> dict[str, Decimal] | None:
"""Return the four cumulative kWh register values at *boundary*, within *meter*'s window.
Queries the most recent ``DsmrReading`` with ``recorded_at ≤ boundary``
and extracts the four energy registers from ``payload``:
Queries the most recent ``DsmrReading`` with:
``recorded_at ≤ boundary``
AND ``recorded_at ≥ meter.started_at``
AND (``meter.ended_at IS NULL`` OR ``recorded_at < meter.ended_at``)
The meter window constraint (half-open ``[started_at, ended_at)``) ensures
that readings from a previous meter epoch are never used to anchor a new
meter's computation. Without this guard, the final reading of the old meter
would be visible at the start of the new meter's epoch and produce a
cross-meter delta, defeating the isolation guarantee.
Extracts the four energy registers from ``payload``:
d1 — electricity_delivered_1 (delivered low-tariff / dal)
d2 — electricity_delivered_2 (delivered high-tariff / normal)
@@ -142,18 +198,40 @@ def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | No
-------
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
when:
- No ``DsmrReading`` row exists with ``recorded_at ≤ boundary``.
- No ``DsmrReading`` row exists with ``recorded_at ≤ boundary`` within
*meter*'s epoch window.
- The most recent such reading is older than ``_READING_MAX_STALENESS``
relative to *boundary* (freshness guard).
- Any of the four register keys is absent from the payload.
- Any of the four register values is ``None`` (null in JSON).
SQLite naive datetime note
--------------------------
``recorded_at`` is stored as a naive UTC datetime in SQLite. Comparisons
against *boundary* (always tz-aware UTC) use ``_as_utc()`` for the
freshness check. The SQL ``WHERE`` clause comparisons work correctly
because SQLAlchemy's SQLite dialect strips tzinfo when binding parameters
(leaving the wall-clock UTC value unchanged), consistent with the storage
format.
"""
row: DsmrReading | None = (
session.execute(
# 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)
stmt = (
select(DsmrReading)
.where(DsmrReading.recorded_at <= boundary)
.where(
DsmrReading.recorded_at <= boundary,
DsmrReading.recorded_at >= meter_lower,
)
.order_by(DsmrReading.recorded_at.desc())
.limit(1)
).scalar_one_or_none()
)
# 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)
row: DsmrReading | None = session.execute(stmt).scalar_one_or_none()
if row is None:
return None
@@ -214,8 +292,14 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
Side-effects
------------
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
- If readings are missing at either boundary: inserts/updates a degraded
row (costs=0, degraded=True).
- 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``):
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``.
- If any delta is negative or exceeds ``_MAX_DELTA_KWH`` (D6 sanity guard):
inserts/updates a degraded row with ``meter_id=m0.id``.
- If the active contract version is missing: **skips** (returns False, no write).
- If the Tibber price is missing (TibberPriceNotFoundError): **skips**
(returns False, no write).
@@ -229,7 +313,50 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
if existing is not None and not existing.degraded and not overwrite:
return False
# --- Active contract version at t0 (checked before readings) ---
# --- Meter determination (structural prerequisite, checked before contract) ---
#
# A missing or cross-boundary meter is a structural problem: we cannot trust
# the delta at all, so we write a degraded row immediately. This is different
# from the contract skip (transient, no write): the degraded row ensures the
# period appears in the history and can be revisited once the meter timeline
# is corrected and a recompute_range is triggered.
#
# Ordering rationale:
# 1. No meter (m0 is None) → degraded(meter_id=None): no 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.
#
# We place meter before contract so that "cross-table period" is always
# marked degraded regardless of contract state. If we checked contract
# 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)
if m0 is None:
# No meter epoch covers t0 — degraded with no meter attribution.
logger.debug(
"compute_period(%s): no active meter at t0 — writing degraded (meter_id=None).",
t0.isoformat(),
)
_upsert_degraded(session, t0, now, existing, meter_id=None)
return True
if m1 is None or m0.id != m1.id:
# Period spans a meter boundary or t1 has no meter. Degrade with m0's id
# (t0's meter attribution): the period's start belongs to m0's epoch.
logger.debug(
"compute_period(%s): period crosses meter boundary "
"(m0.id=%s, m1.id=%s) — writing degraded.",
t0.isoformat(),
m0.id,
m1.id if m1 is not None else None,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- 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
# without a contract (we would not know which strategy to apply once data
@@ -240,14 +367,13 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
return False
# --- Boundary readings ---
start_regs = register_at(session, t0)
end_regs = register_at(session, t1)
# --- Boundary readings within m0's meter window ---
start_regs = register_at(session, t0, m0)
end_regs = register_at(session, t1, m0)
if start_regs is None or end_regs is None:
# Missing readings → write/update a degraded placeholder so the period
# is visible and can be retried by compute_closed_periods once data arrives.
_upsert_degraded(session, t0, now, existing)
# Missing readings within the meter window → degraded with m0 attribution.
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True # a record was written (degraded)
# --- Compute deltas (end start) ---
@@ -258,6 +384,26 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
r2=end_regs["r2"] - start_regs["r2"],
)
# --- Delta sanity guard (D6) ---
# Any negative delta indicates a meter reset, DSMR rollover, or data error.
# Any delta exceeding _MAX_DELTA_KWH (100 kWh per 15 min = 400 kW average)
# is implausible for residential use and indicates an anomaly.
# Both cases produce a degraded row so no negative or grossly inflated cost
# is ever written to the billing record.
all_deltas = (deltas.d1, deltas.d2, deltas.r1, deltas.r2)
if any(d < Decimal("0") for d in all_deltas) or any(d > _MAX_DELTA_KWH for d in all_deltas):
logger.debug(
"compute_period(%s): delta sanity guard triggered "
"(d1=%s, d2=%s, r1=%s, r2=%s) — writing degraded.",
t0.isoformat(),
deltas.d1,
deltas.d2,
deltas.r1,
deltas.r2,
)
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
return True
# --- Price strategy ---
strategy = get_strategy(version.contract.kind)
try:
@@ -288,6 +434,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
existing.currency = version.contract.currency
existing.pricing = pricing
existing.contract_version_id = version.id
existing.meter_id = m0.id
existing.degraded = False
existing.computed_at = now
else:
@@ -303,6 +450,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
currency=version.contract.currency,
pricing=pricing,
contract_version_id=version.id,
meter_id=m0.id,
degraded=False,
computed_at=now,
)
@@ -316,9 +464,25 @@ def _upsert_degraded(
t0: datetime,
now: datetime,
existing: EnergyCostPeriod | None,
*,
meter_id: int | None,
) -> None:
"""Insert or update a degraded placeholder for period *t0*.
Parameters
----------
session:
Active SQLAlchemy session.
t0:
UTC start of the 15-minute period.
now:
Current UTC timestamp for the ``computed_at`` field.
existing:
The existing ``EnergyCostPeriod`` row for this period, or ``None``.
meter_id:
The meter ID to attribute this degraded period to, or ``None`` when
no meter epoch covers the period (no-meter degraded case).
When *existing* is not None (row was previously written — either degraded
or successful), the row is explicitly reset to the standard degraded state.
This is required for the ``recompute_range`` (overwrite=True) path: if the
@@ -326,6 +490,11 @@ def _upsert_degraded(
since disappeared, the stale non-zero costs must be cleared so the row
accurately reflects the current "missing readings" state rather than
masquerading as a valid result.
The ``meter_id`` is always updated to reflect the current meter attribution
judgment (the result of ``meter_at`` at the time of recompute). This
ensures that a retroactive ``started_at`` change + ``recompute_range`` will
re-attribute historical degraded periods to the correct meter epoch.
"""
if existing is not None:
# Explicitly reset to degraded state — identical field values to the
@@ -340,6 +509,7 @@ def _upsert_degraded(
existing.net_cost = 0.0
existing.pricing = {}
existing.contract_version_id = None
existing.meter_id = meter_id
existing.degraded = True
existing.computed_at = now
else:
@@ -355,6 +525,7 @@ def _upsert_degraded(
currency="EUR", # placeholder; real currency known after contract lookup
pricing={},
contract_version_id=None,
meter_id=meter_id,
degraded=True,
computed_at=now,
)
@@ -381,6 +552,7 @@ def compute_closed_periods(session: Session) -> int:
3. For each boundary, calls ``compute_period(overwrite=False)``, which:
- Skips periods that already have a *successful* (non-degraded) record.
- Retries periods that have a *degraded* record.
- Writes degraded rows for periods with no meter or cross-meter boundaries.
- Skips periods for which no active contract version exists or the
Tibber price is unavailable (without writing a degraded row).
@@ -429,11 +601,17 @@ def recompute_range(session: Session, start: datetime, end: datetime) -> int:
This is the *explicit opt-in* path for recovering from:
- Periods where readings or prices arrived late.
- Price corrections (new contract version retroactively applied).
- Retroactive meter changes (``update_meter`` with new ``started_at``) —
re-running this function will re-judge meter attribution and re-compute
costs using the corrected epoch boundaries.
- Any other reason to override the immutability guard.
The function iterates over every UTC quarter-hour boundary in
``[floor(start), end)`` and calls ``compute_period(overwrite=True)``.
Existing rows (including successful ones) are overwritten.
Existing rows (including successful ones) are overwritten; their
``meter_id`` fields will reflect the *current* ``meter_at`` judgment for
each period's start timestamp, naturally re-attributing periods when meter
``started_at`` values have been retroactively corrected.
Parameters
----------
+480
View File
@@ -0,0 +1,480 @@
"""Service layer for Meter epoch CRUD, swap/close, and time-range lookup.
All functions accept an explicit SQLAlchemy Session; callers are responsible
for committing or rolling back the transaction.
Design decisions
----------------
- ``meter_at``: half-open interval ``[started_at, ended_at)`` lookup;
returns the meter whose epoch covers *ts* for the given commodity.
SQLite naive datetime is normalised via ``_as_utc()`` before comparison.
- ``declare_meter``: validates that the new ``started_at`` is **not earlier
than** the current active meter's ``started_at`` (rejects back-dating below
the active meter's own start). Equal timestamps are allowed because the
typical "swap now" use-case sets ``started_at`` to the current moment, which
coincides with the active meter's ``started_at`` only in degenerate test
scenarios — but blocking equal values would make that workflow impossible.
The old active meter is closed (``ended_at = started_at``) and a new active
meter is opened in the same operation, guaranteeing continuity: the old
meter's ``ended_at`` equals the new meter's ``started_at`` (contiguous,
no gap, no overlap).
- ``update_meter``: when ``started_at`` is modified, the service keeps the
timeline contiguous by also updating the **previous** meter's ``ended_at``
(the one whose ``ended_at`` matched the old ``started_at``) to the new
``started_at``. Validation ensures the new ``started_at``:
* is strictly after the previous meter's own ``started_at``
(cannot push the boundary before the previous meter even started);
* is strictly before the current meter's ``ended_at``, if set
(cannot push the boundary past where the current meter was already
closed).
- Mutual exclusion (at most one active meter per commodity) is enforced by the
service layer; no DB-level unique partial index is added to keep migrations
simple and to allow the application to return a meaningful error message.
SQLite timezone note
--------------------
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
read-back they come out as **timezone-naive** datetimes. Wherever this code
compares timestamps from the DB against timezone-aware values, it calls
``_as_utc()`` to make both sides comparable without tripping on
"offset-naive vs offset-aware" TypeErrors.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.energy import Meter
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _as_utc(dt: datetime) -> datetime:
"""Return *dt* as a timezone-aware UTC datetime.
SQLite's ``DateTime(timezone=True)`` column type stores datetimes as naive
UTC strings and gives them back as naive datetimes on read. This helper
re-attaches the UTC timezone info when it is missing, making cross-origin
comparisons safe.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class MeterError(ValueError):
"""Base class for meter service validation errors."""
class MeterOverlapError(MeterError):
"""Raised when a new meter's ``started_at`` would create an overlap or backdate.
Specifically: the new meter's ``started_at`` must be greater than or equal
to the current active meter's ``started_at``. Allowing a value strictly
earlier than the active meter's start would mean the new epoch begins before
the current one, which is chronologically inconsistent.
"""
class MeterIntervalError(MeterError):
"""Raised when an ``update_meter`` call would produce an inconsistent interval.
Examples of inconsistent intervals:
- New ``started_at`` ≥ this meter's ``ended_at`` (epoch would be empty/inverted).
- New ``started_at`` ≤ the previous meter's own ``started_at`` (previous meter
would become empty/inverted after its ``ended_at`` is updated).
"""
# ---------------------------------------------------------------------------
# Internal query helpers
# ---------------------------------------------------------------------------
def _active_meter(session: Session, commodity: str) -> Optional[Meter]:
"""Return the current active (``ended_at IS NULL``) meter for *commodity*, or None."""
return session.execute(
select(Meter)
.where(
Meter.commodity == commodity,
Meter.ended_at.is_(None),
)
.limit(1)
).scalar_one_or_none()
def _meter_before(session: Session, meter: Meter) -> Optional[Meter]:
"""Return the meter whose ``ended_at`` equals *meter*'s ``started_at``.
This is the meter that was closed when *meter* was opened; its ``ended_at``
needs to stay equal to *meter*'s ``started_at`` to maintain timeline
continuity. Returns None if *meter* is the first epoch for its commodity.
The lookup compares naive/aware datetimes via string to avoid SQLite timezone
quirks: both are formatted as ISO 8601 UTC strings for the WHERE clause.
We rely on the fact that the service layer always stores the same timestamp
object as both ``prev.ended_at`` and ``new.started_at``, so their string
representations are identical.
"""
# Normalise the target to a tz-aware UTC datetime for comparison.
# We scan in Python (rather than SQL) to avoid SQLite naive-vs-aware
# mismatch issues when comparing DateTime columns against tz-aware values.
target = _as_utc(meter.started_at)
# Fetch all closed meters of the same commodity and find the one whose
# ended_at equals this meter's started_at (the standard contiguous handoff).
candidates = session.execute(
select(Meter)
.where(
Meter.commodity == meter.commodity,
Meter.ended_at.is_not(None),
)
).scalars().all()
for candidate in candidates:
if candidate.id == meter.id:
continue
candidate_ended = _as_utc(candidate.ended_at)
if candidate_ended == target:
return candidate
return None
# ---------------------------------------------------------------------------
# Core service functions
# ---------------------------------------------------------------------------
def meter_at(
session: Session,
ts: datetime,
commodity: str = "electricity",
) -> Optional[Meter]:
"""Return the meter epoch that covers *ts* for *commodity*.
A meter covers *ts* when:
``started_at ≤ ts`` AND (``ended_at IS NULL`` OR ``ts < ended_at``)
This is the standard half-open interval ``[started_at, ended_at)`` lookup,
consistent with ``active_contract_version_at`` in ``contracts.py``.
Returns ``None`` when no meter covers *ts* (e.g. before the first epoch
was declared, or after a gap).
Parameters
----------
session:
Active SQLAlchemy session (read-only usage).
ts:
UTC datetime to look up.
commodity:
Energy commodity (default ``"electricity"``).
Returns
-------
Meter | None
Implementation note — why the upper bound is pushed into SQL
------------------------------------------------------------
When ``declare_meter`` is called with ``started_at == active.started_at``
(the "equal-timestamp swap" allowed by §3.5), the old meter is closed as a
zero-width epoch ``[T0, T0)`` and the new active meter also has
``started_at == T0``. Two rows share the same ``started_at``; SQLite's
row-ordering for ``ORDER BY started_at DESC LIMIT 1`` is then determined by
rowid (i.e. insertion order), which returns the older zero-width row first.
If the upper bound were checked in Python *after* fetching that one row, the
condition ``ts < ended_at`` would be False for **any** ``ts >= T0`` (because
``ended_at == T0``), causing the function to return ``None`` and making the
new active meter permanently invisible.
Pushing both bounds into the SQL ``WHERE`` clause eliminates the ambiguity:
the zero-width row is excluded by ``ts < ended_at`` before ``LIMIT 1`` is
applied, so only the genuinely covering row survives.
SQLite naive-vs-aware datetime note: SQLAlchemy's SQLite dialect strips the
``tzinfo`` from aware datetimes when binding parameters (it does *not*
convert to UTC first). Since all datetimes in this codebase are UTC (either
naive-UTC from the DB or aware-UTC from ``datetime.now(UTC)``), stripping
the tzinfo leaves the wall-clock value unchanged and comparisons remain
correct. This is consistent with how ``contracts.py`` handles the same
situation (see OBS 2 in the M7-T02 review notes).
"""
# Push both the lower and upper bounds into the SQL WHERE clause so that
# zero-width epochs (ended_at == started_at) are excluded *before* LIMIT 1
# is applied. This prevents an equal-timestamp swap from making the new
# active meter invisible (see implementation note above).
stmt = (
select(Meter)
.where(
Meter.commodity == commodity,
Meter.started_at <= ts,
(Meter.ended_at.is_(None)) | (Meter.ended_at > ts),
)
.order_by(Meter.started_at.desc())
.limit(1)
)
return session.execute(stmt).scalar_one_or_none()
def declare_meter(
session: Session,
*,
label: str,
started_at: datetime,
reason: str,
commodity: str = "electricity",
note: Optional[str] = None,
) -> Meter:
"""Declare a meter swap or initial meter epoch.
Closes the current active meter for *commodity* (if one exists) by setting
its ``ended_at`` to *started_at*, then opens a new active meter. The
resulting timeline is **contiguous**: old meter's ``ended_at`` equals new
meter's ``started_at``.
If there is no current active meter (first-ever declaration for this
commodity), the new meter is simply opened without closing anything.
Validation
----------
- If a current active meter exists, *started_at* must be **≥** that meter's
own ``started_at``. A value strictly earlier would place the new epoch
entirely before the current active meter, which is a chronological
contradiction ("back-dating before the active epoch's start"). Raises
``MeterOverlapError`` when this constraint is violated.
Note: equal timestamps (``started_at == active.started_at``) are allowed
because that scenario effectively replaces the current meter at the same
logical moment (e.g. correcting a mis-entry), which is a valid use-case.
The old meter is then closed with ``ended_at == started_at`` (a zero-width
epoch), which is intentional and auditable.
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
label:
Human-readable label for the new meter.
started_at:
UTC datetime at which this meter epoch starts. May be in the past.
reason:
Why this epoch was created (``"initial"``, ``"meter_swap"``,
``"home_move"``, or ``"other"``).
commodity:
Energy commodity (default ``"electricity"``).
note:
Optional free-form note.
Returns
-------
Meter
The newly created, not-yet-committed active meter.
Raises
------
MeterOverlapError
If *started_at* is strictly earlier than the current active meter's
``started_at`` (chronological backdate below the active epoch's start).
"""
active = _active_meter(session, commodity)
if active is not None:
# Reject back-dating: new started_at must be ≥ current active's started_at.
if _as_utc(started_at) < _as_utc(active.started_at):
raise MeterOverlapError(
f"New meter started_at ({started_at.isoformat()}) must be ≥ the current "
f"active {commodity!r} meter's started_at "
f"({active.started_at.isoformat()}). "
"Declare a started_at on or after the active meter's start to avoid "
"a chronologically inconsistent epoch ordering."
)
# Close the current active meter at the swap point (contiguous handoff).
active.ended_at = started_at
logger.info(
"Closed active %r meter id=%d (ended_at=%s)",
commodity,
active.id,
started_at.isoformat(),
)
now = datetime.now(UTC)
new_meter = Meter(
label=label,
commodity=commodity,
started_at=started_at,
ended_at=None,
reason=reason,
note=note,
created_at=now,
)
session.add(new_meter)
logger.info(
"Declared new %r meter %r (started_at=%s, reason=%s)",
commodity,
label,
started_at.isoformat(),
reason,
)
return new_meter
def list_meters(
session: Session,
commodity: Optional[str] = None,
) -> list[Meter]:
"""List all meter epochs, ordered by started_at ascending.
Active meters (``ended_at IS NULL``) sort naturally to the end of the
timeline since they have the latest ``started_at``. Within a single
commodity, the ascending ``started_at`` order reflects the historical
sequence of installed meters.
Parameters
----------
session:
Active SQLAlchemy session (read-only usage).
commodity:
If provided, filter to this commodity only. If ``None``, return all
meters across all commodities.
Returns
-------
list[Meter]
Meters ordered by (started_at ASC).
"""
stmt = select(Meter).order_by(Meter.started_at.asc())
if commodity is not None:
stmt = stmt.where(Meter.commodity == commodity)
return list(session.execute(stmt).scalars().all())
def update_meter(
session: Session,
meter: Meter,
*,
label: Optional[str] = None,
note: Optional[str] = None,
started_at: Optional[datetime] = None,
) -> Meter:
"""Update a meter's mutable fields (label, note, started_at).
Passing ``None`` for a field leaves it unchanged. At least one keyword
argument must be non-``None``; calling with all-``None`` is a no-op but
is not an error.
Updating ``started_at`` (retroactive correction)
------------------------------------------------
When ``started_at`` is provided the service maintains **timeline
continuity** across the adjacent meter boundaries:
1. **Previous meter's ``ended_at``** — if the meter immediately before
this one has ``ended_at == meter.started_at`` (the standard contiguous
handoff), its ``ended_at`` is updated to the new ``started_at`` so the
boundary between the two epochs stays seamless.
2. **Validation** — the new ``started_at`` is checked for consistency:
a. It must be **strictly after** the previous meter's own ``started_at``
(otherwise the previous meter's epoch would collapse to zero or
invert).
b. It must be **strictly before** this meter's ``ended_at`` (if set),
so this meter's epoch remains non-empty.
Note: triggering a billing recompute (``recompute_range``) after a
retroactive ``started_at`` change is **out of scope** for this service
layer; that is the API layer's responsibility (M7-T05).
Parameters
----------
session:
Active SQLAlchemy session. Caller must commit after this returns.
meter:
The ``Meter`` ORM instance to update (already loaded from the session).
label:
New human-readable label; ``None`` = keep existing.
note:
New free-form note; ``None`` = keep existing.
started_at:
New start timestamp for this meter epoch; ``None`` = keep existing.
Returns
-------
Meter
The updated ``Meter`` instance (not yet committed).
Raises
------
MeterIntervalError
If the new ``started_at`` would produce an invalid (empty or inverted)
epoch for this meter or the immediately preceding one.
"""
if label is not None:
meter.label = label
logger.info("Updated meter id=%d label=%r", meter.id, label)
if note is not None:
meter.note = note
logger.info("Updated meter id=%d note=%r", meter.id, note)
if started_at is not None:
old_started_at = meter.started_at
# --- Validate upper bound: new started_at must be < this meter's ended_at (if set).
if meter.ended_at is not None:
if _as_utc(started_at) >= _as_utc(meter.ended_at):
raise MeterIntervalError(
f"New started_at ({started_at.isoformat()}) must be strictly before "
f"this meter's ended_at ({meter.ended_at.isoformat()}). "
"The meter epoch would become empty or inverted."
)
# --- Find the immediately preceding meter (its ended_at == meter's old started_at).
prev = _meter_before(session, meter)
# --- Validate lower bound: new started_at must be strictly after prev's started_at.
if prev is not None:
if _as_utc(started_at) <= _as_utc(prev.started_at):
raise MeterIntervalError(
f"New started_at ({started_at.isoformat()}) must be strictly after "
f"the previous meter's started_at ({prev.started_at.isoformat()}). "
"Moving the boundary that far back would collapse the previous meter's epoch."
)
# Maintain continuity: update the previous meter's ended_at to match the new start.
prev.ended_at = started_at
logger.info(
"Updated previous meter id=%d ended_at=%s (boundary shift from %s)",
prev.id,
started_at.isoformat(),
old_started_at.isoformat(),
)
meter.started_at = started_at
logger.info(
"Updated meter id=%d started_at=%s (was %s)",
meter.id,
started_at.isoformat(),
old_started_at.isoformat(),
)
return meter
+1
View File
@@ -8,6 +8,7 @@
- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP**先做**
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
- [`m6-tibber-dynamic-energy.md`](./m6-tibber-dynamic-energy.md) — 通用电价层 + DSMR 实时电表接入 + 实时买卖电费计算 + HA Energy 反哺
- [`m7-meter-epochs-archival.md`](./m7-meter-epochs-archival.md) — 电表生命周期 / 换表归档(Meter epochs
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
+9 -7
View File
@@ -123,7 +123,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T01 — `meter` 表 + 模型 + `energy_cost_period.meter_id` + 迁移回填 `[schema]`
- **Status**: `todo`
- **Status**: `done`
- **Depends**: none
- **Context**: 引入 Meter 实体的数据地基;为现有数据回填初始表。
@@ -149,7 +149,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T02 — Meter 服务层(swap/close/edit + `meter_at` + 互斥/校验)
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M7-T01
- **Context**: 换表/继承的业务逻辑与按时刻查表。
@@ -174,7 +174,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T03 — 计费引擎 meter-aware + delta 护栏 + `period.meter_id`
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M7-T02
- **Context**: 让计费永不跨表算 delta,并兜底异常 delta。
@@ -201,7 +201,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T04 — 累计 per-meter 归零(expose 锚点)
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M7-T03
- **Context**: 换表后 MQTT 累计量从零起算。
@@ -226,7 +226,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T05 — Meter API + 追溯 recompute + OpenAPI
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M7-T02
- **Context**: 暴露电表 CRUD 与换表声明;追溯后重算。
@@ -254,7 +254,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T06 — 前端电表管理 UI
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M7-T05
- **Context**: 让用户在 Energy 页声明换表、查看电表时间线。
@@ -280,7 +280,7 @@ M7-T01 (meter 表+模型+列+迁移回填)
### M7-T07 — 文档 / OpenAPI / roadmap 收尾
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M7-T01..T06
- **Context**: 把 M7 落档、更新 roadmap 与索引。
@@ -313,6 +313,8 @@ M7-T01 (meter 表+模型+列+迁移回填)
## 10. 里程碑完成定义(DoD)
**已完成**M7-T01..T07 全部 done
- `meter` 数据地基 + 回填上线;计费引擎永不跨表算 delta,跨表/无表/异常 delta 一律降级。
- 累计量按当前表归零;追溯换表可重算。
- Meter CRUD API + 前端管理 UI 可用;OpenAPI/schema 已同步。
+114
View File
@@ -0,0 +1,114 @@
# Meter Epochs(电表生命周期 / 换表归档)
本文档说明 **Meter epoch** 概念、换表声明流程、计费隔离行为、累计量归零、追溯重算,以及与 HA 累计 blip 的已知行为。
## 为什么需要 Meter epoch
计费引擎采用**寄存器差值(delta)模型**:每 15 分钟成本 =(周期末读数 − 周期初读数)× 单价。荷兰 2G 智能电表退网后,电网公司会把表换成 4G 表(同址换表);搬家继承新表也是同类场景。
问题在于:**新表的寄存器基数与旧表无关**,若跨表算 delta,会产出负成本或巨额假成本。Meter epoch 让引擎知道"某时刻起属于哪一块物理表",从而永不跨表算 delta。
## 核心概念
一条 `meter` 记录 = **一块物理电表的一段安装期** `[started_at, ended_at)`
- `started_at`:表安装/继承/搬家的起点(可在过去,追溯声明)。
- `ended_at`null = 当前 active;非 null = 已退役表的结束时刻。
- `label`:人读标签,便于识别(如 "旧 2G 表 @ Dorpsstraat 1")。
- `reason``initial`(初始建档)/ `meter_swap`(换表)/ `home_move`(搬家)/ `other`
- `commodity`:默认 `electricity`;为未来 gas/heating 预留,每 commodity 至多一个 active 表。
**换表** = 关闭旧 active 表(`ended_at = T`+ 新建 `started_at = T` 的表。系统**无法自动识别换表**(DSMR 帧无电表序列号),靠用户**显式声明**才能隔离。
## 计费隔离行为
每个 15 分钟计费周期,引擎先查该周期两端(t0/t1)分别属于哪块表:
| 情况 | 结果 |
| --- | --- |
| 同一块表(正常)| 在表窗口内取读数、算 delta、正常计费 |
| 无表覆盖(未声明)| **降级**(成本置 0`degraded=true` |
| 跨表边界(t0 ≠ t1 的表)| **降级**(丢这一个周期,可接受;换表常伴随长时间无数据) |
| delta < 0 或 > 100 kWh/15min | **降级**(兜底异常,如 DSMR 回绕/毛刺,与换表无关也防住) |
**忘了声明换表怎么办**:搬家必有的数据空档(staleness)+ delta 护栏保证不会产出垃圾成本,只是新数据暂混在旧表 epoch、累计未归零。事后补声明 + 追溯重算即可纠正(见下方"追溯换表")。
## 换表声明流程
### 通过前端(Energy 页 → Meters tab
1. 在 Energy 页切到 **Meters** tab,查看当前电表时间线。
2. 点击"声明换表 / 继承新表",填写:
- **Label**:新表的人读标签(如 "4G 新表 2026"
- **安装日期**:新表的 `started_at`(可选择历史日期追溯)
- **Reason**`meter_swap` / `home_move` / `other`
3. 保存后,旧表自动关闭(`ended_at = 填写日期`),新表成为 active。
4. 如填写的是过去日期,系统自动触发受影响窗口的重算(`recompute_range`),跨表周期变为降级,新表内周期重新归属。
### 通过 API
```http
POST /api/energy/meters
Content-Type: application/json
{
"label": "4G 2026",
"started_at": "2026-07-01T00:00:00",
"reason": "meter_swap",
"note": ""
}
```
`started_at` 必须 `≥` 当前 active 表的 `started_at`(拒绝倒挂)。
查询所有表(时间线):
```http
GET /api/energy/meters
```
编辑 label / 备注 / 修正日期:
```http
PATCH /api/energy/meters/{id}
```
## 追溯换表(started_at 在过去)
若换表日期在过去但当时忘了声明:
1. 声明新表,填写过去的安装日期。
2. 系统自动对 `[started_at, now]` 范围内的周期触发 `recompute_range`
- 跨表边界周期 → 降级。
- 归属新表窗口内的周期 → `meter_id` 更新为新表。
3. MQTT 累计量锚点切换至新表起点,下一次 expose 推送时累计量从新表起点重算。
## 累计量归零(per-meter
MQTT 上报的累计成本实体(`import_cost_total` / `export_revenue_total`)锚点 = **当前 active electricity 表的 `started_at`**。换表后:
- 累计量从新表安装时刻起重新累加,不跨表维护历史偏移。
- 日归零实体(`*_today`)不受影响(其窗口必落在当前表内)。
### 已知行为:HA 长期统计的一次性负 blip
累计实体 `state_class: total`,换表归零时其值会**下台阶**。Home Assistant 长期统计(energy dashboard / statistics)可能在换表那一刻记录一次性负 blip。
这是**已知、可接受的行为**:HA 自身存旧值做 down-sample,短期 blip 不影响日常电费读数。已与用户确认先这样观察。如需消除 blip,可在未来通过 `last_reset` 信号通知 HA(见设计文档 §9 后续杠杆,本里程碑不做)。
## 回填(迁移时的初始表)
首次升级到含 M7 迁移的版本时:
- 若库内已有任何 `dsmr_reading` / `energy_cost_period`,迁移自动创建一条 `reason="initial"` 的初始表(`started_at = 最早 dsmr_reading.recorded_at`),并回填现有 `energy_cost_period.meter_id`
- 若是全新空库,初始表不创建(无历史数据)。
- 回填幂等:重复跑迁移不会创建多条初始表;回填后对账(非降级周期 `meter_id IS NULL` 数必须为 0)。
## 非目标(本里程碑不做)
- Gas / 区域供暖的计费(`commodity != "electricity"` 的 strategy)。
- "家庭(home)"分组实体;多合同时间线积分。
- 自动识别换表(DSMR 帧无电表序列号,无法自动识别,靠用户显式声明)。
- `last_reset` 信号(消除 HA 长期统计 blip)。
> 详细设计与任务卡:[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)
+44 -1
View File
@@ -2,7 +2,7 @@
本文档记录 `home-automation``v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md)M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md)M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)、M7 [`m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
## 当前基线(v1.0.3
@@ -39,6 +39,7 @@
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5** |
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
| **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI |
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
@@ -214,6 +215,48 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
---
## M7 — 电表生命周期 / 换表归档(✅ 已完成)
### 目标
让计费系统正确处理**电表更换**这一必然事件:荷兰 2G 智能电表退网后,电网公司会把表换成 4G 表(同址换表);搬家继承新表也是同类。引入显式的 **Meter(电表)epoch** 概念,标记"某时刻起属于哪一块物理表",使引擎**永不跨表算 delta**,并让累计量按表归零、历史可追溯可查。
### 关键能力
- **Meter epoch 数据模型**:一条 `meter` 记录 = 一块物理电表的一段安装期 `[started_at, ended_at)`。换表 = 关闭旧 active 表(`ended_at=T`+ 新建 `started_at=T` 的表。每 `commodity` 至多一个 active 表;`commodity` 字段预留 `gas`/`heating`
- **计费永不跨表**`compute_period` 先查 t0/t1 两端的 `meter_at`;无表覆盖或跨表边界 → **降级**(成本置 0),绝不产负成本或巨额假成本。
- **delta 护栏**:任一寄存器 delta `< 0``> _MAX_DELTA_KWH`100 kWh/15min,远超住宅用量)→ 降级。兜底表重置 / DSMR 回绕 / 数据毛刺,与换表无关的异常也一并防住。
- **累计按当前表归零(D2**`expose.py` 累计 `import_cost_total`/`export_revenue_total` 锚点 = 当前 active electricity meter 的 `started_at`;换表后累计从零起新序列,不维护跨表偏移。
- **追溯换表可重算**`started_at` 可在过去;PATCH 修正日期后,触发受影响窗口 `recompute_range` 重判跨表周期归属。
- **Meter CRUD API + 前端管理 UI**`GET/POST/PATCH /api/energy/meters`+ 追溯 recompute);Energy 页新增 Meters tab,展示电表时间线,支持"换表/继承"表单与编辑。
### 新增表与列(单库 app 链,migration `20260625_13_meter_table`
| 表/列 | 关键设计 |
| --- | --- |
| `meter` | `id``label``commodity`(默认 `electricity`)、`started_at``ended_at`null=active)、`reason``initial`/`meter_swap`/`home_move`/`other`)、`note``created_at` |
| `energy_cost_period.meter_id` | nullable FK → `meter.id`;记录每周期归属,便于审计/按表查询/归档 |
迁移含回填:有历史数据时自动创建一条 `reason="initial"` 的初始表,并回填现有 `energy_cost_period.meter_id`;幂等 + 对账(非降级周期回填后 `meter_id IS NULL` 数必须为 0)。
### 已锁定决策摘要
- **D1**Meter 不绑定 home`label` 编址 + `commodity` 区分品类。
- **D2**:换表后累计量归零(锚当前表起点),不维护跨表偏移。
- **D3**:合同与电表是独立时间线,合同不引用电表;同址换表时合同自动沿用。
- **D4**`started_at` 可在过去;有效计费起点 = `max(started_at, 数据起点)`
- **D5**:跨表边界周期判 degraded(换表常伴随长时间无数据,可接受)。
- **D6**:负/异常大 delta → degraded,通用兜底。
- **D7**gas/heating 计费不在本里程碑;`commodity` 字段为未来扩展预留。
**已知行为(可接受)**:累计 `_total``state_class: total`,换表归零时 HA 长期统计可能在换表那一刻记一次性负 blip。已与用户确认先这样、观察后按需处理(`last_reset` 信号见设计文档 §9 留痕,本里程碑不做)。
> 详细设计与任务卡:[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)
>
> 模块概念说明:[`docs/meter-epochs.md`](./meter-epochs.md)
---
## M3 — 开放与移动端(远期试水)
### 目标
+278
View File
@@ -559,6 +559,84 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/energy/meters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Energy Meters
* @description List all meter epochs in ascending ``started_at`` order.
*
* Returns the full historical sequence of meter installations across all
* commodities. The active meter (``ended_at=null``) appears last because it
* has the latest ``started_at``.
*/
get: operations["list_energy_meters_api_energy_meters_get"];
put?: never;
/**
* Declare Energy Meter
* @description Declare a new meter epoch (swap, home move, or initial declaration).
*
* Closes the current active meter for the given commodity at ``started_at``
* and opens a new active meter. If no active meter exists, the new meter is
* simply created without closing anything.
*
* **Validation**: ``started_at`` must be **≥** the current active meter's
* own ``started_at`` (no chronological backdate below the active epoch's
* start). Equal timestamps are allowed (replaces the current meter at the
* same logical moment). Violation → 422.
*
* **Retroactive recompute**: if ``started_at`` is in the past, billing
* records from that point forward are re-judged via ``recompute_range`` to
* reflect the new meter attribution. The response includes the count of
* recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
* — the recompute is transparent; callers should re-fetch costs if needed).
*/
post: operations["declare_energy_meter_api_energy_meters_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/energy/meters/{meter_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Patch Energy Meter
* @description Partially update a meter epoch: rename, edit note, or correct started_at.
*
* - ``label``: updates the human-readable label.
* - ``note``: updates the free-form note.
* - ``started_at``: **retroactive correction** — shifts this meter's start
* boundary. The service layer maintains timeline continuity by also
* updating the preceding meter's ``ended_at``. Validation:
* * Must be strictly after the previous meter's own ``started_at``.
* * Must be strictly before this meter's ``ended_at`` (if closed).
* Violation → 422.
*
* **Retroactive recompute when ``started_at`` changes**: billing records in
* the window ``[min(old, new), now)`` are re-judged to reflect the corrected
* meter attribution.
*
* Not found → 404.
*/
patch: operations["patch_energy_meter_api_energy_meters__meter_id__patch"];
trace?: never;
};
"/api/expose": {
parameters: {
query?: never;
@@ -1569,6 +1647,114 @@ export interface components {
*/
sell_normal: number;
};
/**
* MeterDeclareRequest
* @description Request body for POST /api/energy/meters.
*
* Declares a new meter epoch (swap, home move, or initial declaration). The
* service layer closes the current active meter for the given commodity at
* ``started_at`` and opens a new one.
*
* ``started_at`` follows the Principle-A localisation convention: a
* timezone-naive value is interpreted as the **server's local wall-clock time**
* (e.g. CEST midnight → stored as UTC the night before); a timezone-aware
* value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
* every meter declaration must carry an explicit start timestamp.
*
* ``commodity`` defaults to ``"electricity"``; the field is available for
* future use with ``gas`` or ``heating``.
*/
MeterDeclareRequest: {
/** Label */
label: string;
/**
* Started At
* Format: date-time
* @description UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration).
*/
started_at: string;
/** @description Why this epoch was created. One of: initial, meter_swap, home_move, other. */
reason: components["schemas"]["MeterReason"];
/** Note */
note?: string | null;
/**
* Commodity
* @description Energy commodity this meter measures. Defaults to 'electricity'.
* @default electricity
*/
commodity: string;
};
/**
* MeterListResponse
* @description Response schema for GET /api/energy/meters.
*
* Meters are returned in ascending ``started_at`` order so the caller sees
* the historical installation sequence.
*/
MeterListResponse: {
/** Items */
items: components["schemas"]["MeterResponse"][];
/** Total */
total: number;
};
/**
* MeterPatchRequest
* @description Request body for PATCH /api/energy/meters/{id}.
*
* All fields are optional. Only non-``None`` values are applied.
*
* Updating ``started_at`` is a **retroactive correction**: the service layer
* maintains timeline continuity (adjusting the preceding meter's ``ended_at``)
* and the API layer triggers ``recompute_range`` over the affected window so
* that billing attribution is re-judged.
*/
MeterPatchRequest: {
/** Label */
label?: string | null;
/** Note */
note?: string | null;
/**
* Started At
* @description Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window.
*/
started_at?: string | null;
};
/**
* MeterReason
* @description Allowed values for the meter epoch creation reason.
* @enum {string}
*/
MeterReason: "initial" | "meter_swap" | "home_move" | "other";
/**
* MeterResponse
* @description Response schema for a single Meter epoch row.
*
* ``ended_at`` is ``null`` for the currently active meter.
*/
MeterResponse: {
/** Id */
id: number;
/** Label */
label: string;
/** Commodity */
commodity: string;
/**
* Started At
* Format: date-time
*/
started_at: string;
/** Ended At */
ended_at: string | null;
/** Reason */
reason: string;
/** Note */
note: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/**
* MetricInfo
* @description Metadata for a single measurable quantity in a device's profile.
@@ -3007,6 +3193,98 @@ export interface operations {
};
};
};
list_energy_meters_api_energy_meters_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeterListResponse"];
};
};
};
};
declare_energy_meter_api_energy_meters_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MeterDeclareRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeterResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
patch_energy_meter_api_energy_meters__meter_id__patch: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
meter_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MeterPatchRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeterResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_expose_api_expose_get: {
parameters: {
query?: never;
+357
View File
@@ -0,0 +1,357 @@
/**
* Tests for MeterManager component.
*
* Coverage:
* 1. Loading state rendering.
* 2. Error state rendering.
* 3. Empty state when no meters exist.
* 4. Meter timeline list rendering (label, dates, active badge, reason).
* 5. "Declare New Meter" button opens form modal.
* 6. Declare meter — form submit calls POST /api/energy/meters.
* 7. Declare meter — 422 (倒挂) error is displayed.
* 8. Edit button opens edit form modal.
* 9. Edit meter — saves label/note via PATCH /api/energy/meters/{meter_id}.
* 10. Edit meter — retroactive started_at triggers recompute notice.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
import { MeterManager } from './MeterManager'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PATCH: (...args: unknown[]) => mockPatch(...args),
DELETE: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const ACTIVE_METER = {
id: 1,
label: 'Initial 2G meter',
commodity: 'electricity',
started_at: '2024-01-15T00:00:00Z',
ended_at: null,
reason: 'initial',
note: null,
created_at: '2024-01-15T00:00:00Z',
}
const CLOSED_METER = {
id: 2,
label: 'Old 4G meter',
commodity: 'electricity',
started_at: '2023-06-01T00:00:00Z',
ended_at: '2024-01-15T00:00:00Z',
reason: 'meter_swap',
note: 'Replaced by grid company',
created_at: '2023-06-01T00:00:00Z',
}
const METERS_RESPONSE = {
items: [CLOSED_METER, ACTIVE_METER],
total: 2,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('MeterManager — loading / error / empty states', () => {
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders(<MeterManager />)
expect(screen.getByTestId('meters-loading')).toBeInTheDocument()
})
it('renders error state when GET fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-load-error')).toBeInTheDocument()
})
})
it('renders empty state when no meters exist', async () => {
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-empty')).toBeInTheDocument()
})
})
})
describe('MeterManager — meter list', () => {
beforeEach(() => vi.clearAllMocks())
it('renders meter timeline with label, dates, status badge, reason', async () => {
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-table')).toBeInTheDocument()
})
// Labels
expect(screen.getByText('Initial 2G meter')).toBeInTheDocument()
expect(screen.getByText('Old 4G meter')).toBeInTheDocument()
// Status badges
expect(screen.getByTestId(`meter-status-${ACTIVE_METER.id}`)).toHaveTextContent('active')
expect(screen.getByTestId(`meter-status-${CLOSED_METER.id}`)).toHaveTextContent('closed')
// Reason badges
expect(screen.getByText('initial')).toBeInTheDocument()
expect(screen.getByText('meter_swap')).toBeInTheDocument()
})
it('renders "Declare New Meter" button', async () => {
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()
})
})
})
describe('MeterManager — declare new meter', () => {
beforeEach(() => vi.clearAllMocks())
it('opens declare modal when "Declare New Meter" is clicked', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => {
expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()
})
it('calls POST /api/energy/meters with correct payload including local-midnight naive datetime', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
mockPost.mockResolvedValue({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
// Fill form
await user.type(screen.getByTestId('meter-label'), 'New meter label')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
// Select reason via the combobox (Mantine Select renders a combobox)
await user.click(screen.getByTestId('meter-reason'))
await waitFor(() => screen.getByText('Initial installation'))
await user.click(screen.getByText('Initial installation'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/meters',
expect.objectContaining({
body: expect.objectContaining({
label: 'New meter label',
// FU10 local-midnight naive convention: no Z suffix
started_at: '2026-01-01T00:00:00',
reason: 'initial',
commodity: 'electricity',
}),
}),
)
})
})
it('displays error when POST fails with 422 (倒挂 / validation error)', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValue(
new ApiError(422, { detail: 'started_at must be ≥ current active meter started_at' }),
)
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
await user.type(screen.getByTestId('meter-label'), 'Bad meter')
await user.type(screen.getByTestId('meter-started-at'), '2020-01-01')
await user.click(screen.getByTestId('meter-reason'))
await waitFor(() => screen.getByText('Meter swap (same address)'))
await user.click(screen.getByText('Meter swap (same address)'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('declare-meter-error')).toBeInTheDocument()
})
expect(screen.getByTestId('declare-meter-error').textContent).toContain(
'started_at must be ≥',
)
})
it('cancel button closes the declare modal', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument())
await user.click(screen.getByTestId('declare-meter-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('declare-meter-modal')).not.toBeInTheDocument()
})
})
})
describe('MeterManager — edit meter', () => {
beforeEach(() => vi.clearAllMocks())
it('opens edit modal when Edit button is clicked', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => {
expect(screen.getByTestId('edit-meter-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()
})
it('calls PATCH /api/energy/meters/{meter_id} when label is changed', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'Renamed meter' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Clear label and type new one
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'Renamed meter')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalledWith(
'/api/energy/meters/{meter_id}',
expect.objectContaining({
params: { path: { meter_id: ACTIVE_METER.id } },
body: expect.objectContaining({ label: 'Renamed meter' }),
}),
)
})
})
it('shows recompute notice when started_at is changed (retroactive correction)', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, started_at: '2024-02-01T00:00:00' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change the date field
const dateInput = screen.getByTestId('edit-meter-started-at')
await user.clear(dateInput)
await user.type(dateInput, '2024-02-01')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('meter-recompute-notice')).toBeInTheDocument()
})
})
it('displays error when PATCH fails', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
const { ApiError } = await import('../api/client')
mockPatch.mockRejectedValue(new ApiError(422, { detail: 'started_at conflict' }))
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change label so there's something to patch
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'Different label')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('edit-meter-error')).toBeInTheDocument()
})
expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict')
})
})
+497
View File
@@ -0,0 +1,497 @@
/**
* MeterManager — electricity meter timeline UI.
*
* Features:
* - Table of meter epochs: label / interval (started_at → ended_at or "active") /
* active badge / reason.
* - "Declare New Meter" button: form with label + date (started_at) + reason +
* optional note. Sends local-midnight naive datetime per FU10 convention.
* - Edit modal: update label, note, or correct started_at (retroactive).
* - Retroactive feedback: if started_at is changed, a success notice mentions
* that affected billing periods have been recomputed.
* - Loading / error / empty states.
*/
import { useState } from 'react'
import {
Table,
Button,
Group,
Text,
Loader,
Center,
Alert,
Stack,
Badge,
ScrollArea,
Modal,
TextInput,
Textarea,
Select,
Notification,
} from '@mantine/core'
import {
useMeters,
useDeclareMeter,
useUpdateMeter,
type MeterResponse,
type MeterReason,
} from './hooks'
import { ApiError } from '../api/client'
import { formatLocalDate } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const REASON_OPTIONS: { value: MeterReason; label: string }[] = [
{ value: 'initial', label: 'Initial installation' },
{ value: 'meter_swap', label: 'Meter swap (same address)' },
{ value: 'home_move', label: 'Home / address move' },
{ value: 'other', label: 'Other' },
]
/**
* Convert a local date string "YYYY-MM-DD" to a naive local-midnight datetime
* string (no Z suffix) following the FU10 / ContractForm convention.
* The backend interprets naive datetimes as server local wall-clock time.
*/
function toLocalMidnightNaive(dateStr: string): string {
return `${dateStr}T00:00:00`
}
// ---------------------------------------------------------------------------
// Declare meter form (modal)
// ---------------------------------------------------------------------------
interface DeclareMeterFormProps {
onClose: () => void
onSaved: () => void
}
function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
const [label, setLabel] = useState('')
const [dateStr, setDateStr] = useState('')
const [reason, setReason] = useState<string | null>(null)
const [note, setNote] = useState('')
const [error, setError] = useState<string | null>(null)
const declareMutation = useDeclareMeter()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (!label.trim()) {
setError('Label is required.')
return
}
if (!dateStr) {
setError('Start date is required.')
return
}
if (!reason) {
setError('Reason is required.')
return
}
try {
await declareMutation.mutateAsync({
label: label.trim(),
started_at: toLocalMidnightNaive(dateStr),
reason: reason as MeterReason,
note: note.trim() || undefined,
commodity: 'electricity',
})
onSaved()
onClose()
} catch (err) {
if (err instanceof ApiError) {
const detail = (err.body as { detail?: string } | null)?.detail
setError(detail ?? `Error ${err.status}: failed to declare meter.`)
} else {
setError('Failed to declare meter. Please try again.')
}
}
}
return (
<Modal
opened
onClose={onClose}
title="Declare New Meter"
size="md"
data-testid="declare-meter-modal"
>
<form onSubmit={handleSubmit} data-testid="declare-meter-form">
<Stack gap="sm">
<TextInput
label="Label"
description={'Human-readable identifier, e.g. "2G meter @ Dorpsstraat 1"'}
required
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
data-testid="meter-label"
/>
<TextInput
label="Start date"
description="Date in YYYY-MM-DD format (interpreted as local midnight)"
type="date"
required
value={dateStr}
onChange={(e) => setDateStr(e.currentTarget.value)}
data-testid="meter-started-at"
/>
<Select
label="Reason"
required
data={REASON_OPTIONS}
value={reason}
onChange={setReason}
data-testid="meter-reason"
/>
<Textarea
label="Note (optional)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
data-testid="meter-note"
/>
{error && (
<Alert color="red" data-testid="declare-meter-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="default"
onClick={onClose}
data-testid="declare-meter-cancel"
>
Cancel
</Button>
<Button
type="submit"
loading={declareMutation.isPending}
data-testid="declare-meter-submit"
>
Declare Meter
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Edit meter form (modal)
// ---------------------------------------------------------------------------
interface EditMeterFormProps {
meter: MeterResponse
onClose: () => void
onSaved: (retroactive: boolean) => void
}
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
const [label, setLabel] = useState(meter.label)
const [note, setNote] = useState(meter.note ?? '')
// Convert UTC started_at to a local date string for the <input type="date">.
// We parse as UTC (appending Z if needed) and format to "YYYY-MM-DD" in local tz.
const initialDateStr = (() => {
const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/)
? meter.started_at + 'Z'
: meter.started_at
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
})()
const [dateStr, setDateStr] = useState(initialDateStr)
const [error, setError] = useState<string | null>(null)
const updateMutation = useUpdateMeter()
// Detect if the user changed started_at (retroactive correction).
const startedAtChanged = dateStr !== initialDateStr
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
const patchBody: { label?: string | null; note?: string | null; started_at?: string | null } = {}
if (label.trim() !== meter.label) patchBody.label = label.trim()
const noteVal = note.trim() || null
if (noteVal !== meter.note) patchBody.note = noteVal
if (startedAtChanged && dateStr) {
patchBody.started_at = toLocalMidnightNaive(dateStr)
}
if (Object.keys(patchBody).length === 0) {
onClose()
return
}
try {
await updateMutation.mutateAsync({ id: meter.id, body: patchBody })
onSaved(startedAtChanged)
onClose()
} catch (err) {
if (err instanceof ApiError) {
const detail = (err.body as { detail?: string } | null)?.detail
setError(detail ?? `Error ${err.status}: failed to update meter.`)
} else {
setError('Failed to update meter. Please try again.')
}
}
}
return (
<Modal
opened
onClose={onClose}
title={`Edit Meter — ${meter.label}`}
size="md"
data-testid="edit-meter-modal"
>
<form onSubmit={handleSubmit} data-testid="edit-meter-form">
<Stack gap="sm">
<TextInput
label="Label"
required
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
data-testid="edit-meter-label"
/>
<TextInput
label="Start date"
description="Retroactive correction: shifts the epoch boundary and re-judges billing periods"
type="date"
value={dateStr}
onChange={(e) => setDateStr(e.currentTarget.value)}
data-testid="edit-meter-started-at"
/>
<Textarea
label="Note (optional)"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
data-testid="edit-meter-note"
/>
{error && (
<Alert color="red" data-testid="edit-meter-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
type="button"
variant="default"
onClick={onClose}
data-testid="edit-meter-cancel"
>
Cancel
</Button>
<Button
type="submit"
loading={updateMutation.isPending}
data-testid="edit-meter-submit"
>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Meter timeline table
// ---------------------------------------------------------------------------
interface MeterTableProps {
meters: MeterResponse[]
onEdit: (meter: MeterResponse) => void
}
function MeterTable({ meters, onEdit }: MeterTableProps) {
if (meters.length === 0) {
return (
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
No meters declared yet. Click "Declare New Meter" to add one.
</Text>
)
}
return (
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="meters-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Label</Table.Th>
<Table.Th>Commodity</Table.Th>
<Table.Th>From</Table.Th>
<Table.Th>To</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Reason</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{meters.map((meter) => {
const isActive = meter.ended_at === null
return (
<Table.Tr key={meter.id} data-testid={`meter-row-${meter.id}`}>
<Table.Td>
<Text fw={500} size="sm">
{meter.label}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{meter.commodity}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{formatLocalDate(meter.started_at)}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{meter.ended_at ? formatLocalDate(meter.ended_at) : '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge
color={isActive ? 'green' : 'gray'}
variant="light"
size="sm"
data-testid={`meter-status-${meter.id}`}
>
{isActive ? 'active' : 'closed'}
</Badge>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm" color="blue">
{meter.reason}
</Badge>
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="outline"
onClick={() => onEdit(meter)}
data-testid={`meter-edit-${meter.id}`}
>
Edit
</Button>
</Group>
</Table.Td>
</Table.Tr>
)
})}
</Table.Tbody>
</Table>
</ScrollArea>
)
}
// ---------------------------------------------------------------------------
// MeterManager — top-level
// ---------------------------------------------------------------------------
export function MeterManager() {
const metersQuery = useMeters()
const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
const [recomputeNotice, setRecomputeNotice] = useState(false)
// ---------------------------------------------------------------------------
// Render states
// ---------------------------------------------------------------------------
if (metersQuery.isLoading) {
return (
<Center py="xl" data-testid="meters-loading">
<Loader />
</Center>
)
}
if (metersQuery.isError || !metersQuery.data) {
return (
<Alert color="red" data-testid="meters-load-error">
Failed to load meters. Please refresh.
</Alert>
)
}
const meters = metersQuery.data.items
return (
<Stack gap="lg" data-testid="meter-manager">
<Group justify="space-between" align="center">
<Text fw={500}>Electricity Meters</Text>
<Button
onClick={() => setShowDeclareForm(true)}
data-testid="meter-declare-button"
>
Declare New Meter
</Button>
</Group>
{recomputeNotice && (
<Notification
color="teal"
title="Billing periods recomputed"
onClose={() => setRecomputeNotice(false)}
data-testid="meter-recompute-notice"
>
The start date was corrected. Affected billing periods have been
re-judged and cost attributions updated.
</Notification>
)}
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} />
{/* Declare new meter */}
{showDeclareForm && (
<DeclareMeterForm
onClose={() => setShowDeclareForm(false)}
onSaved={() => setShowDeclareForm(false)}
/>
)}
{/* Edit existing meter */}
{editMeter && (
<EditMeterForm
meter={editMeter}
onClose={() => setEditMeter(null)}
onSaved={(retroactive) => {
setEditMeter(null)
if (retroactive) setRecomputeNotice(true)
}}
/>
)}
</Stack>
)
}
+66
View File
@@ -214,6 +214,12 @@ export function useMetrics(uuid: string) {
// ===========================================================================
// Re-exported energy types for consumers
export type MeterResponse = components['schemas']['MeterResponse']
export type MeterListResponse = components['schemas']['MeterListResponse']
export type MeterDeclareRequest = components['schemas']['MeterDeclareRequest']
export type MeterPatchRequest = components['schemas']['MeterPatchRequest']
export type MeterReason = components['schemas']['MeterReason']
export type ContractResponse = components['schemas']['ContractResponse']
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
@@ -434,6 +440,66 @@ export function useRecomputeCosts() {
})
}
// ===========================================================================
// Meter hooks — typed TanStack Query wrappers for /api/energy/meters.
//
// Query-key conventions:
// ['energy-meters'] — meter list
// ===========================================================================
// ---------------------------------------------------------------------------
// Query: list all meter epochs
// ---------------------------------------------------------------------------
export function useMeters() {
return useQuery({
queryKey: ['energy-meters'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/meters')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Mutation: declare a new meter epoch (swap / home move / initial)
// ---------------------------------------------------------------------------
export function useDeclareMeter() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: MeterDeclareRequest) =>
apiClient.POST('/api/energy/meters', { body }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
// Invalidate cost-related queries: a new meter may trigger recompute server-side.
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
},
})
}
// ---------------------------------------------------------------------------
// Mutation: update (PATCH) a meter epoch (label / note / started_at)
// ---------------------------------------------------------------------------
export function useUpdateMeter() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: MeterPatchRequest }) =>
apiClient.PATCH('/api/energy/meters/{meter_id}', {
params: { path: { meter_id: id } },
body,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
// Retroactive started_at correction triggers recompute server-side.
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
},
})
}
// ---------------------------------------------------------------------------
// Query: time-range readings for a device (window + limit — never full-table)
// ---------------------------------------------------------------------------
+38
View File
@@ -381,6 +381,44 @@ describe('EnergyPage — test-read', () => {
})
})
describe('EnergyPage — meters tab', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders Meters tab in the tab list', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('tab-meters')).toBeInTheDocument()
})
expect(screen.getByTestId('tab-meters').textContent).toContain('Meters')
})
it('renders meters panel when Meters tab is clicked', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/devices') {
return Promise.resolve({ data: { items: [DEVICE], total: 1 } })
}
if (path === '/api/energy/meters') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
return Promise.resolve({ data: null })
})
renderEnergy()
await waitFor(() => expect(screen.getByTestId('tab-meters')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('tab-meters'))
await waitFor(() => {
expect(screen.getByTestId('panel-meters')).toBeInTheDocument()
})
})
})
describe('EnergyPage — auto-refresh switch', () => {
beforeEach(() => {
vi.clearAllMocks()
+8
View File
@@ -42,6 +42,7 @@ import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMe
import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts'
import { ContractManager } from '../energy/ContractManager'
import { MeterManager } from '../energy/MeterManager'
import { TibberPrices } from '../energy/TibberPrices'
import { CostView } from '../energy/CostView'
import { DsmrPanel } from '../energy/DsmrPanel'
@@ -661,6 +662,9 @@ export function EnergyPage() {
<Tabs.Tab value="devices" data-testid="tab-devices">
Devices
</Tabs.Tab>
<Tabs.Tab value="meters" data-testid="tab-meters">
Meters
</Tabs.Tab>
<Tabs.Tab value="contracts" data-testid="tab-contracts">
Contracts
</Tabs.Tab>
@@ -679,6 +683,10 @@ export function EnergyPage() {
<DevicesTab />
</Tabs.Panel>
<Tabs.Panel value="meters" data-testid="panel-meters">
<MeterManager />
</Tabs.Panel>
<Tabs.Panel value="contracts" data-testid="panel-contracts">
<ContractManager />
</Tabs.Panel>
+341
View File
@@ -1379,6 +1379,155 @@
}
}
},
"/api/energy/meters": {
"get": {
"tags": [
"api-energy-meters"
],
"summary": "List Energy Meters",
"description": "List all meter epochs in ascending ``started_at`` order.\n\nReturns the full historical sequence of meter installations across all\ncommodities. The active meter (``ended_at=null``) appears last because it\nhas the latest ``started_at``.",
"operationId": "list_energy_meters_api_energy_meters_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterListResponse"
}
}
}
}
}
},
"post": {
"tags": [
"api-energy-meters"
],
"summary": "Declare Energy Meter",
"description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The response includes the count of\nrecomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``\n— the recompute is transparent; callers should re-fetch costs if needed).",
"operationId": "declare_energy_meter_api_energy_meters_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterDeclareRequest"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/energy/meters/{meter_id}": {
"patch": {
"tags": [
"api-energy-meters"
],
"summary": "Patch Energy Meter",
"description": "Partially update a meter epoch: rename, edit note, or correct started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``: updates the free-form note.\n- ``started_at``: **retroactive correction** — shifts this meter's start\n boundary. The service layer maintains timeline continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n * Must be strictly after the previous meter's own ``started_at``.\n * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing records in\nthe window ``[min(old, new), now)`` are re-judged to reflect the corrected\nmeter attribution.\n\nNot found → 404.",
"operationId": "patch_energy_meter_api_energy_meters__meter_id__patch",
"parameters": [
{
"name": "meter_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Meter Id"
}
},
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterPatchRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MeterResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/expose": {
"get": {
"tags": [
@@ -3334,6 +3483,198 @@
"title": "ManualTariffSchema",
"description": "Fixed-tariff breakdown for manual contracts.\n\nPrices are the *effective* buy prices as used by the billing engine\n(energy_buy_x + energy_tax + ode) and the raw sell prices."
},
"MeterDeclareRequest": {
"properties": {
"label": {
"type": "string",
"maxLength": 255,
"minLength": 1,
"title": "Label"
},
"started_at": {
"type": "string",
"format": "date-time",
"title": "Started At",
"description": "UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration)."
},
"reason": {
"$ref": "#/components/schemas/MeterReason",
"description": "Why this epoch was created. One of: initial, meter_swap, home_move, other."
},
"note": {
"anyOf": [
{
"type": "string",
"maxLength": 1024
},
{
"type": "null"
}
],
"title": "Note"
},
"commodity": {
"type": "string",
"maxLength": 32,
"minLength": 1,
"title": "Commodity",
"description": "Energy commodity this meter measures. Defaults to 'electricity'.",
"default": "electricity"
}
},
"type": "object",
"required": [
"label",
"started_at",
"reason"
],
"title": "MeterDeclareRequest",
"description": "Request body for POST /api/energy/meters.\n\nDeclares a new meter epoch (swap, home move, or initial declaration). The\nservice layer closes the current active meter for the given commodity at\n``started_at`` and opens a new one.\n\n``started_at`` follows the Principle-A localisation convention: a\ntimezone-naive value is interpreted as the **server's local wall-clock time**\n(e.g. CEST midnight → stored as UTC the night before); a timezone-aware\nvalue is converted to UTC as-is. Omitting ``started_at`` is not allowed —\nevery meter declaration must carry an explicit start timestamp.\n\n``commodity`` defaults to ``\"electricity\"``; the field is available for\nfuture use with ``gas`` or ``heating``."
},
"MeterListResponse": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/MeterResponse"
},
"type": "array",
"title": "Items"
},
"total": {
"type": "integer",
"title": "Total"
}
},
"type": "object",
"required": [
"items",
"total"
],
"title": "MeterListResponse",
"description": "Response schema for GET /api/energy/meters.\n\nMeters are returned in ascending ``started_at`` order so the caller sees\nthe historical installation sequence."
},
"MeterPatchRequest": {
"properties": {
"label": {
"anyOf": [
{
"type": "string",
"maxLength": 255,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Label"
},
"note": {
"anyOf": [
{
"type": "string",
"maxLength": 1024
},
{
"type": "null"
}
],
"title": "Note"
},
"started_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Started At",
"description": "Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window."
}
},
"type": "object",
"title": "MeterPatchRequest",
"description": "Request body for PATCH /api/energy/meters/{id}.\n\nAll fields are optional. Only non-``None`` values are applied.\n\nUpdating ``started_at`` is a **retroactive correction**: the service layer\nmaintains timeline continuity (adjusting the preceding meter's ``ended_at``)\nand the API layer triggers ``recompute_range`` over the affected window so\nthat billing attribution is re-judged."
},
"MeterReason": {
"type": "string",
"enum": [
"initial",
"meter_swap",
"home_move",
"other"
],
"title": "MeterReason",
"description": "Allowed values for the meter epoch creation reason."
},
"MeterResponse": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"label": {
"type": "string",
"title": "Label"
},
"commodity": {
"type": "string",
"title": "Commodity"
},
"started_at": {
"type": "string",
"format": "date-time",
"title": "Started At"
},
"ended_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Ended At"
},
"reason": {
"type": "string",
"title": "Reason"
},
"note": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Note"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"id",
"label",
"commodity",
"started_at",
"ended_at",
"reason",
"note",
"created_at"
],
"title": "MeterResponse",
"description": "Response schema for a single Meter epoch row.\n\n``ended_at`` is ``null`` for the currently active meter."
},
"MetricInfo": {
"properties": {
"key": {
+308
View File
@@ -1077,6 +1077,138 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/energy/meters:
get:
tags:
- api-energy-meters
summary: List Energy Meters
description: 'List all meter epochs in ascending ``started_at`` order.
Returns the full historical sequence of meter installations across all
commodities. The active meter (``ended_at=null``) appears last because it
has the latest ``started_at``.'
operationId: list_energy_meters_api_energy_meters_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/MeterListResponse'
post:
tags:
- api-energy-meters
summary: Declare Energy Meter
description: 'Declare a new meter epoch (swap, home move, or initial declaration).
Closes the current active meter for the given commodity at ``started_at``
and opens a new active meter. If no active meter exists, the new meter is
simply created without closing anything.
**Validation**: ``started_at`` must be **≥** the current active meter''s
own ``started_at`` (no chronological backdate below the active epoch''s
start). Equal timestamps are allowed (replaces the current meter at the
same logical moment). Violation → 422.
**Retroactive recompute**: if ``started_at`` is in the past, billing
records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The response includes the count of
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
— the recompute is transparent; callers should re-fetch costs if needed).'
operationId: declare_energy_meter_api_energy_meters_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MeterDeclareRequest'
responses:
'201':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/MeterResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/energy/meters/{meter_id}:
patch:
tags:
- api-energy-meters
summary: Patch Energy Meter
description: "Partially update a meter epoch: rename, edit note, or correct\
\ started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``:\
\ updates the free-form note.\n- ``started_at``: **retroactive correction**\
\ — shifts this meter's start\n boundary. The service layer maintains timeline\
\ continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n\
\ * Must be strictly after the previous meter's own ``started_at``.\n \
\ * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation\
\ → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing\
\ records in\nthe window ``[min(old, new), now)`` are re-judged to reflect\
\ the corrected\nmeter attribution.\n\nNot found → 404."
operationId: patch_energy_meter_api_energy_meters__meter_id__patch
parameters:
- name: meter_id
in: path
required: true
schema:
type: integer
title: Meter Id
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MeterPatchRequest'
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/MeterResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/expose:
get:
tags:
@@ -2595,6 +2727,182 @@ components:
Prices are the *effective* buy prices as used by the billing engine
(energy_buy_x + energy_tax + ode) and the raw sell prices.'
MeterDeclareRequest:
properties:
label:
type: string
maxLength: 255
minLength: 1
title: Label
started_at:
type: string
format: date-time
title: Started At
description: UTC (or server-local naive) datetime from which this meter
epoch starts. May be in the past (retroactive declaration).
reason:
$ref: '#/components/schemas/MeterReason'
description: 'Why this epoch was created. One of: initial, meter_swap,
home_move, other.'
note:
anyOf:
- type: string
maxLength: 1024
- type: 'null'
title: Note
commodity:
type: string
maxLength: 32
minLength: 1
title: Commodity
description: Energy commodity this meter measures. Defaults to 'electricity'.
default: electricity
type: object
required:
- label
- started_at
- reason
title: MeterDeclareRequest
description: 'Request body for POST /api/energy/meters.
Declares a new meter epoch (swap, home move, or initial declaration). The
service layer closes the current active meter for the given commodity at
``started_at`` and opens a new one.
``started_at`` follows the Principle-A localisation convention: a
timezone-naive value is interpreted as the **server''s local wall-clock time**
(e.g. CEST midnight → stored as UTC the night before); a timezone-aware
value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
every meter declaration must carry an explicit start timestamp.
``commodity`` defaults to ``"electricity"``; the field is available for
future use with ``gas`` or ``heating``.'
MeterListResponse:
properties:
items:
items:
$ref: '#/components/schemas/MeterResponse'
type: array
title: Items
total:
type: integer
title: Total
type: object
required:
- items
- total
title: MeterListResponse
description: 'Response schema for GET /api/energy/meters.
Meters are returned in ascending ``started_at`` order so the caller sees
the historical installation sequence.'
MeterPatchRequest:
properties:
label:
anyOf:
- type: string
maxLength: 255
minLength: 1
- type: 'null'
title: Label
note:
anyOf:
- type: string
maxLength: 1024
- type: 'null'
title: Note
started_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Started At
description: Retroactive correction of the meter epoch start timestamp. Triggers
billing recompute over the affected window.
type: object
title: MeterPatchRequest
description: 'Request body for PATCH /api/energy/meters/{id}.
All fields are optional. Only non-``None`` values are applied.
Updating ``started_at`` is a **retroactive correction**: the service layer
maintains timeline continuity (adjusting the preceding meter''s ``ended_at``)
and the API layer triggers ``recompute_range`` over the affected window so
that billing attribution is re-judged.'
MeterReason:
type: string
enum:
- initial
- meter_swap
- home_move
- other
title: MeterReason
description: Allowed values for the meter epoch creation reason.
MeterResponse:
properties:
id:
type: integer
title: Id
label:
type: string
title: Label
commodity:
type: string
title: Commodity
started_at:
type: string
format: date-time
title: Started At
ended_at:
anyOf:
- type: string
format: date-time
- type: 'null'
title: Ended At
reason:
type: string
title: Reason
note:
anyOf:
- type: string
- type: 'null'
title: Note
created_at:
type: string
format: date-time
title: Created At
type: object
required:
- id
- label
- commodity
- started_at
- ended_at
- reason
- note
- created_at
title: MeterResponse
description: 'Response schema for a single Meter epoch row.
``ended_at`` is ``null`` for the currently active meter.'
MetricInfo:
properties:
key:
+1 -1
View File
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
from app.config import get_settings
APP_BASELINE_REVISION = "20260624_12_dsmr_decouple_telegram_id"
APP_BASELINE_REVISION = "20260625_13_meter_table"
class AppDatabaseAdoptionError(RuntimeError):
+662
View File
@@ -0,0 +1,662 @@
"""Tests for M7-T05: Meter CRUD + swap declaration + retroactive recompute API.
Coverage matrix
---------------
GET /api/energy/meters
- unauthenticated 401
- authenticated, no meters 200, items=[]
- after declaring meters items ordered by started_at asc
POST /api/energy/meters
- unauthenticated 401
- missing CSRF 403
- valid (first meter, no active) 201, MeterResponse
- valid swap (active meter exists) 201, old meter closed, new meter active
- retroactive started_at 201, triggers recompute for affected window
- started_at before active meter's started_at (overlap) → 422
PATCH /api/energy/meters/{id}
- unauthenticated 401
- missing CSRF 403
- not found 404
- rename label 200, label updated
- edit note 200, note updated
- correct started_at (retroactive) 200, triggers recompute for affected window
- started_at interval violation 422
Retroactive recompute integration
- PATCH started_at change triggers recompute over min(old, new)..now window
- POST retroactive declaration triggers recompute from new started_at
"""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.models.energy import Meter
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
_CSRF = "test-csrf-token"
def _login(client: TestClient) -> None:
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
def _declare_payload(**overrides) -> dict:
base = {
"label": "Test Meter",
"started_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC).isoformat(),
"reason": "initial",
"commodity": "electricity",
}
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def meters_client(auth_database):
"""TestClient + SQLAlchemy engine for Meter API tests."""
from app.main import create_app
app_url = auth_database["app_url"]
engine = create_engine(app_url, connect_args={"check_same_thread": False})
fastapi_app = create_app()
with TestClient(fastapi_app) as test_client:
yield test_client, engine
engine.dispose()
# ---------------------------------------------------------------------------
# GET /api/energy/meters
# ---------------------------------------------------------------------------
def test_list_meters_unauthenticated_returns_401(meters_client):
client, _ = meters_client
resp = client.get("/api/energy/meters")
assert resp.status_code == 401
def test_list_meters_empty(meters_client):
client, _ = meters_client
_login(client)
resp = client.get("/api/energy/meters")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
def test_list_meters_ordered_by_started_at(meters_client):
"""After declaring two meters, list returns them in ascending started_at order."""
client, _ = meters_client
_login(client)
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
# Declare first meter (initial)
resp1 = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp1.status_code == 201
# Declare swap meter
resp2 = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp2.status_code == 201
resp = client.get("/api/energy/meters")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
items = body["items"]
# Ordered by started_at ascending: Meter A first, Meter B second
assert items[0]["label"] == "Meter A"
assert items[1]["label"] == "Meter B"
# Meter B is active (ended_at is null)
assert items[1]["ended_at"] is None
# Meter A is closed
assert items[0]["ended_at"] is not None
# ---------------------------------------------------------------------------
# POST /api/energy/meters
# ---------------------------------------------------------------------------
def test_declare_meter_unauthenticated_returns_401(meters_client):
client, _ = meters_client
resp = client.post(
"/api/energy/meters",
json=_declare_payload(),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_declare_meter_missing_csrf_returns_403(meters_client):
client, _ = meters_client
_login(client)
resp = client.post("/api/energy/meters", json=_declare_payload())
assert resp.status_code == 403
def test_declare_meter_first_no_active(meters_client):
"""Declaring the first meter succeeds without closing any previous meter."""
client, engine = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label="First Meter", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
body = resp.json()
assert body["label"] == "First Meter"
assert body["commodity"] == "electricity"
assert body["reason"] == "initial"
assert body["ended_at"] is None # active
# DB check: one meter, active
with Session(engine) as s:
meters = s.execute(select(Meter)).scalars().all()
assert len(meters) == 1
assert meters[0].ended_at is None
def test_declare_meter_swap_closes_previous(meters_client):
"""Declaring a swap closes the previous active meter at started_at."""
client, engine = meters_client
_login(client)
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 3, 15, 12, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
# First meter
resp1 = client.post(
"/api/energy/meters",
json=_declare_payload(label="Old Meter", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp1.status_code == 201
old_id = resp1.json()["id"]
# Swap
resp2 = client.post(
"/api/energy/meters",
json=_declare_payload(label="New Meter", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp2.status_code == 201
body2 = resp2.json()
assert body2["label"] == "New Meter"
assert body2["ended_at"] is None # new meter is active
# DB check: old meter is closed at t1
with Session(engine) as s:
old_meter = s.get(Meter, old_id)
assert old_meter is not None
assert old_meter.ended_at is not None
# ended_at should equal t1 (modulo naive/aware round-trip)
ended_naive = old_meter.ended_at
if ended_naive.tzinfo is None:
ended_naive = ended_naive.replace(tzinfo=UTC)
assert ended_naive == t1
def test_declare_meter_overlap_returns_422(meters_client):
"""Declaring a meter with started_at before active meter's started_at → 422."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC)
t_before = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
# Declare first meter
resp = client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
# Attempt to declare a meter before t0 → overlap error
# (t_before is in the past so would trigger recompute, but service layer raises first)
resp2 = client.post(
"/api/energy/meters",
json=_declare_payload(
label="Backdated Meter", started_at=t_before.isoformat(), reason="meter_swap"
),
headers={"X-CSRF-Token": _CSRF},
)
assert resp2.status_code == 422
assert "started_at" in resp2.json()["detail"].lower()
def test_declare_meter_missing_fields_returns_422(meters_client):
"""Missing required fields (started_at, reason) → 422 from Pydantic validation."""
client, _ = meters_client
_login(client)
resp = client.post(
"/api/energy/meters",
json={"label": "No Reason Meter"}, # missing started_at and reason
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
def test_declare_meter_response_fields(meters_client):
"""POST response contains all expected MeterResponse fields."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(
label="Full Fields Meter",
started_at=t0.isoformat(),
reason="home_move",
note="Testing note",
),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
body = resp.json()
for field in ("id", "label", "commodity", "started_at", "ended_at", "reason", "note", "created_at"):
assert field in body, f"Missing field {field!r} in response"
assert body["note"] == "Testing note"
assert body["reason"] == "home_move"
def test_declare_meter_retroactive_triggers_recompute(meters_client):
"""Declaring a meter with started_at in the past triggers recompute_range.
Both POST calls are made with recompute_range mocked so the test does not
spend time iterating over thousands of empty quarter-hour periods.
"""
client, _ = meters_client
_login(client)
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
t_past = datetime(2025, 3, 1, 0, 0, 0, tzinfo=UTC)
with patch(
"app.api.routes.api.meters.recompute_range", return_value=5
) as mock_recompute:
# First meter (initial); also in the past, so recompute is called here too.
client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
# Reset call count before the swap we are actually testing.
mock_recompute.reset_mock()
# Retroactive swap: started_at in the past → should trigger recompute
resp = client.post(
"/api/energy/meters",
json=_declare_payload(
label="Retroactive Swap", started_at=t_past.isoformat(), reason="meter_swap"
),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
# recompute_range should have been called with start == t_past
assert mock_recompute.called
call_args = mock_recompute.call_args
recompute_start = call_args[0][1] # positional arg index 1 (session is 0)
# Normalise for comparison
if recompute_start.tzinfo is None:
recompute_start = recompute_start.replace(tzinfo=UTC)
assert recompute_start <= t_past
# ---------------------------------------------------------------------------
# PATCH /api/energy/meters/{id}
# ---------------------------------------------------------------------------
def test_patch_meter_unauthenticated_returns_401(meters_client):
client, _ = meters_client
resp = client.patch(
"/api/energy/meters/1",
json={"label": "Renamed"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 401
def test_patch_meter_missing_csrf_returns_403(meters_client):
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
resp = client.patch(f"/api/energy/meters/{meter_id}", json={"label": "Renamed"})
assert resp.status_code == 403
def test_patch_meter_not_found_returns_404(meters_client):
client, _ = meters_client
_login(client)
resp = client.patch(
"/api/energy/meters/99999",
json={"label": "Does Not Exist"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 404
def test_patch_meter_rename_label(meters_client):
"""PATCH label updates the meter's human-readable label."""
client, engine = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label="Original Label", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
resp = client.patch(
f"/api/energy/meters/{meter_id}",
json={"label": "Updated Label"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["label"] == "Updated Label"
# DB check
with Session(engine) as s:
m = s.get(Meter, meter_id)
assert m.label == "Updated Label"
def test_patch_meter_edit_note(meters_client):
"""PATCH note updates the meter's note field."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp = client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat(), note=None),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
resp = client.patch(
f"/api/energy/meters/{meter_id}",
json={"note": "Added a note"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["note"] == "Added a note"
def test_patch_meter_started_at_retroactive_triggers_recompute(meters_client):
"""PATCH started_at triggers recompute over min(old, new)..now window."""
client, _ = meters_client
_login(client)
# Set up two meters: initial + swap. All POST calls are mocked to avoid
# running recompute over thousands of empty historical periods.
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
resp_b = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
meter_b_id = resp_b.json()["id"]
# Correct Meter B's started_at to a slightly different past timestamp
t1_corrected = datetime(2024, 12, 15, 0, 0, 0, tzinfo=UTC) # earlier than t1
with patch(
"app.api.routes.api.meters.recompute_range", return_value=10
) as mock_recompute:
resp = client.patch(
f"/api/energy/meters/{meter_b_id}",
json={"started_at": t1_corrected.isoformat()},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
# recompute should be triggered
assert mock_recompute.called
call_args = mock_recompute.call_args
recompute_start = call_args[0][1]
if recompute_start.tzinfo is None:
recompute_start = recompute_start.replace(tzinfo=UTC)
# Window start should be min(t1_corrected, t1) = t1_corrected
assert recompute_start <= t1_corrected
def test_patch_meter_started_at_interval_violation_returns_422(meters_client):
"""PATCH started_at that would create an invalid interval → 422."""
client, _ = meters_client
_login(client)
# Set up: initial meter A, then swap to B. POST calls mocked to avoid slow recompute.
t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp_a = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
meter_a_id = resp_a.json()["id"]
client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
# Try to set Meter A's started_at to after its ended_at (t1) → interval error
t_too_late = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC)
resp = client.patch(
f"/api/energy/meters/{meter_a_id}",
json={"started_at": t_too_late.isoformat()},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
"""PATCH that only changes label does NOT trigger recompute."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0) as mock_recompute:
resp = client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
meter_id = resp.json()["id"]
mock_recompute.reset_mock()
resp = client.patch(
f"/api/energy/meters/{meter_id}",
json={"label": "Renamed Only"},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
# recompute should NOT be triggered (no started_at change)
assert not mock_recompute.called
# ---------------------------------------------------------------------------
# Timeline continuity (integration: no recompute mock)
# ---------------------------------------------------------------------------
def test_swap_timeline_continuity(meters_client):
"""After two swaps, meter timeline is contiguous and self-consistent."""
client, engine = meters_client
_login(client)
t0 = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
t2 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
# Declare 3 meters in sequence. POST calls mocked to avoid slow recompute over
# years of empty quarter-hour periods.
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
for label, ts, reason in [
("M1", t0, "initial"),
("M2", t1, "meter_swap"),
("M3", t2, "meter_swap"),
]:
resp = client.post(
"/api/energy/meters",
json=_declare_payload(label=label, started_at=ts.isoformat(), reason=reason),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 201
# Verify timeline via list endpoint
resp = client.get("/api/energy/meters")
items = resp.json()["items"]
assert len(items) == 3
# M1: [t0, t1); M2: [t1, t2); M3: [t2, None)
m1 = next(i for i in items if i["label"] == "M1")
m2 = next(i for i in items if i["label"] == "M2")
m3 = next(i for i in items if i["label"] == "M3")
assert m1["ended_at"] is not None
assert m2["ended_at"] is not None
assert m3["ended_at"] is None # active
# ended_at of M1 == started_at of M2 (contiguous)
m1_ended = datetime.fromisoformat(m1["ended_at"]).replace(tzinfo=None)
m2_started = datetime.fromisoformat(m2["started_at"]).replace(tzinfo=None)
assert m1_ended == m2_started
m2_ended = datetime.fromisoformat(m2["ended_at"]).replace(tzinfo=None)
m3_started = datetime.fromisoformat(m3["started_at"]).replace(tzinfo=None)
assert m2_ended == m3_started
# ---------------------------------------------------------------------------
# Reason enum validation
# ---------------------------------------------------------------------------
def test_declare_meter_invalid_reason_returns_422(meters_client):
"""Unknown reason value → 422 from Pydantic enum validation."""
client, _ = meters_client
_login(client)
t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
resp = client.post(
"/api/energy/meters",
json=_declare_payload(started_at=t0.isoformat(), reason="unknown_reason_xyz"),
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Retroactive recompute: integration (no mock) — window coverage check
# ---------------------------------------------------------------------------
def test_patch_started_at_earlier_updates_boundary(meters_client):
"""Moving started_at earlier should update the previous meter's ended_at."""
client, engine = meters_client
_login(client)
t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
t1_earlier = datetime(2024, 12, 1, 0, 0, 0, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
resp_a = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
headers={"X-CSRF-Token": _CSRF},
)
meter_a_id = resp_a.json()["id"]
resp_b = client.post(
"/api/energy/meters",
json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
headers={"X-CSRF-Token": _CSRF},
)
meter_b_id = resp_b.json()["id"]
# Correct Meter B's started_at to t1_earlier (moves boundary earlier)
resp = client.patch(
f"/api/energy/meters/{meter_b_id}",
json={"started_at": t1_earlier.isoformat()},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert resp.json()["id"] == meter_b_id
# DB check: Meter A's ended_at should now equal t1_earlier
with Session(engine) as s:
meter_a = s.get(Meter, meter_a_id)
assert meter_a is not None
ended = meter_a.ended_at
if ended is not None and ended.tzinfo is None:
ended = ended.replace(tzinfo=UTC)
assert ended == t1_earlier
+547 -18
View File
@@ -41,9 +41,11 @@ from app.models.energy import (
EnergyContract,
EnergyContractVersion,
EnergyCostPeriod,
Meter,
TibberPrice,
)
from app.services.energy_cost import (
_MAX_DELTA_KWH,
compute_closed_periods,
compute_period,
floor_to_quarter,
@@ -179,6 +181,45 @@ def _make_reading(
return r
def _make_meter(
session: Session,
*,
started_at: datetime,
ended_at: datetime | None = None,
label: str = "Test Meter",
commodity: str = "electricity",
reason: str = "initial",
note: str | None = None,
) -> Meter:
"""Insert and flush a Meter row; return the ORM object."""
now = datetime.now(_UTC)
m = Meter(
label=label,
commodity=commodity,
started_at=started_at,
ended_at=ended_at,
reason=reason,
note=note,
created_at=now,
)
session.add(m)
session.flush()
return m
def _make_active_meter(session: Session, *, started_at: datetime | None = None) -> Meter:
"""Insert and flush an active electricity meter covering the full test day.
By default the meter starts at 2026-06-23 00:00 UTC (the beginning of the
test day), covering all boundaries used by the standard test helpers
(_T0 = 10:00, _T1 = 10:15, etc.).
"""
if started_at is None:
# Start well before any test boundary to cover the whole test day.
started_at = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC)
return _make_meter(session, started_at=started_at, ended_at=None)
def _make_tibber_price(
session: Session,
*,
@@ -252,30 +293,37 @@ class TestFloorToQuarter:
class TestRegisterAt:
"""register_at now requires a Meter argument; all tests use an active meter."""
def test_returns_none_when_no_readings(self, energy_db: Session) -> None:
result = register_at(energy_db, _ts(10, 0))
meter = _make_active_meter(energy_db)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0), meter)
assert result is None
def test_returns_most_recent_at_or_before_boundary(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
# Insert two readings: one before boundary, one after.
_make_reading(energy_db, recorded_at=_ts(9, 55), d1="100.0", d2="200.0", r1="10.0", r2="20.0", source_id=1)
_make_reading(energy_db, recorded_at=_ts(10, 5), d1="999.0", d2="999.0", r1="999.0", r2="999.0", source_id=2)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is not None
assert result["d1"] == Decimal("100.0")
assert result["d2"] == Decimal("200.0")
def test_exact_boundary_included(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="500.0", d2="600.0", r1="50.0", r2="60.0", source_id=1)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is not None
assert result["d1"] == Decimal("500.0")
def test_missing_register_key_returns_none(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
r = DsmrReading(
recorded_at=_ts(10, 0),
source_id=99,
@@ -284,10 +332,11 @@ class TestRegisterAt:
energy_db.add(r)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is None
def test_null_register_value_returns_none(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
r = DsmrReading(
recorded_at=_ts(10, 0),
source_id=88,
@@ -301,20 +350,61 @@ class TestRegisterAt:
energy_db.add(r)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is None
def test_values_are_decimal(self, energy_db: Session) -> None:
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="20915.154", d2="18372.099",
r1="1234.567", r2="890.123", source_id=1)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0))
result = register_at(energy_db, _ts(10, 0), meter)
assert result is not None
assert isinstance(result["d1"], Decimal)
assert result["d1"] == Decimal("20915.154")
assert result["r2"] == Decimal("890.123")
def test_reading_outside_meter_window_excluded(self, energy_db: Session) -> None:
"""A reading before meter.started_at must not be returned (cross-meter isolation)."""
# Meter starts at 10:00 — a reading at 09:55 is from the old epoch.
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=None)
_make_reading(energy_db, recorded_at=_ts(9, 55), d1="100.0", d2="200.0",
r1="10.0", r2="20.0", source_id=1)
energy_db.commit()
# Boundary is 10:00; the reading at 09:55 is before meter.started_at.
result = register_at(energy_db, _ts(10, 0), meter)
assert result is None, (
"register_at must not return a reading from before meter.started_at"
)
def test_reading_at_meter_started_at_included(self, energy_db: Session) -> None:
"""A reading exactly at meter.started_at must be included (half-open lower bound)."""
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=None)
_make_reading(energy_db, recorded_at=_ts(10, 0), d1="500.0", d2="600.0",
r1="50.0", r2="60.0", source_id=1)
energy_db.commit()
result = register_at(energy_db, _ts(10, 0), meter)
assert result is not None
assert result["d1"] == Decimal("500.0")
def test_reading_at_meter_ended_at_excluded(self, energy_db: Session) -> None:
"""A reading exactly at meter.ended_at must be excluded (half-open upper bound)."""
# Meter covers [10:00, 10:15) — a reading at 10:15 belongs to the next epoch.
meter = _make_meter(energy_db, started_at=_ts(10, 0), ended_at=_ts(10, 15))
_make_reading(energy_db, recorded_at=_ts(10, 15), d1="500.0", d2="600.0",
r1="50.0", r2="60.0", source_id=1)
energy_db.commit()
# Boundary is 10:15, reading is at 10:15 = ended_at → excluded.
result = register_at(energy_db, _ts(10, 15), meter)
assert result is None, (
"register_at must exclude a reading exactly at meter.ended_at "
"(half-open upper bound)"
)
# ---------------------------------------------------------------------------
# 1-2. compute_period — manual dual-tariff
@@ -351,12 +441,14 @@ _END_R2 = "3000.100"
def _setup_manual_scenario(session: Session) -> EnergyContractVersion:
"""Create active manual contract + two boundary readings; return the version."""
"""Create active manual contract + active meter + two boundary readings; return the version."""
contract = _make_contract(session, kind="manual", active=True)
version = _make_version(
session, contract, _MANUAL_VALUES,
effective_from=_ts(0, 0), # covers t0=10:00
)
# Active meter covering the full test day (started before T0).
_make_active_meter(session)
# Start reading (at t0)
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
@@ -535,6 +627,7 @@ class TestComputePeriodTibber:
def _setup(self, session: Session, total: float = 0.25) -> tuple[EnergyContractVersion, TibberPrice]:
contract = _make_contract(session, kind="tibber", active=True)
version = _make_version(session, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
_make_active_meter(session)
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(session, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -600,6 +693,7 @@ class TestComputePeriodMissingTibberPrice:
"""When Tibber price is absent for the period, no EnergyCostPeriod is written."""
contract = _make_contract(energy_db, kind="tibber", active=True)
_make_version(energy_db, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -619,6 +713,7 @@ class TestComputePeriodMissingTibberPrice:
"""A TibberPrice with starts_at > t0 must NOT be used; period is skipped."""
contract = _make_contract(energy_db, kind="tibber", active=True)
_make_version(energy_db, contract, _TIBBER_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -643,9 +738,10 @@ class TestComputePeriodMissingTibberPrice:
class TestComputePeriodMissingReadings:
def test_degraded_when_start_reading_missing(self, energy_db: Session) -> None:
"""No DsmrReading at or before t0 → degraded row written."""
"""No DsmrReading at or before t0 within the meter window → degraded row written."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Only an end reading; no start reading.
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
@@ -663,7 +759,7 @@ class TestComputePeriodMissingReadings:
assert row.net_cost == 0.0
def test_degraded_when_end_reading_missing(self, energy_db: Session) -> None:
"""No DsmrReading at or before t1 → degraded row written.
"""No DsmrReading at or before t1 within the meter window → degraded row written.
We place a reading BEFORE t0 (so t0 boundary has data) but the first
reading AT OR AFTER t1 is only after t1+5min, leaving the t1 boundary
@@ -677,6 +773,7 @@ class TestComputePeriodMissingReadings:
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Only a reading AFTER t1 — no reading at or before t1.
_make_reading(energy_db, recorded_at=_ts(10, 20), d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
@@ -694,6 +791,7 @@ class TestComputePeriodMissingReadings:
"""Degraded rows written due to missing readings have contract_version_id=None."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# No readings at all.
energy_db.commit()
@@ -711,6 +809,7 @@ class TestComputePeriodMissingReadings:
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# First compute: no readings at all → degraded.
energy_db.commit()
@@ -786,6 +885,8 @@ class TestCrossVersionSelection:
effective_from=_ts(8, 0),
effective_to=None,
)
# Active meter covering the full test day.
_make_active_meter(session)
session.commit()
return v1, v2
@@ -857,9 +958,10 @@ class TestCrossVersionSelection:
class TestSummarize:
def _setup_two_periods(self, session: Session) -> None:
"""Insert contract + readings for two consecutive 15-min periods and compute them."""
"""Insert contract + meter + readings for two consecutive 15-min periods and compute them."""
contract = _make_contract(session, kind="manual", active=True)
_make_version(session, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(session)
# Period 1: [10:00, 10:15)
_make_reading(session, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
@@ -954,6 +1056,7 @@ class TestSummarize:
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# No readings at all → degraded period.
energy_db.commit()
compute_period(energy_db, _T0)
@@ -977,6 +1080,7 @@ class TestSummarize:
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
energy_db.commit()
# Summarize over exactly 1 UTC day (no periods in DB — only standing/credits).
@@ -1294,12 +1398,28 @@ class TestSummarizePrincipleC:
class TestComputeClosedPeriods:
def test_skips_future_periods(self, energy_db: Session) -> None:
"""Periods whose t1 > now must not be computed."""
"""Periods whose t1 > now must not be computed.
The meter covers from before the lookback window so that all past periods
have a meter; the contract effective_from=now ensures that every past
period's contract-version lookup returns None, causing them all to be
skipped (no row written). Only the current open period [now, now+15min)
is a "future" period and the normal tick never writes it.
Written = 0 confirms that no row was written (neither the future period
nor any past period without a contract).
"""
now = datetime.now(_UTC)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=datetime.now(_UTC))
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=now)
# Meter covering from well before the 7-day lookback window so every past
# period has a meter. Without a matching contract version those past
# periods are all skipped (not written as degraded).
_make_meter(energy_db, started_at=now - timedelta(days=10), ended_at=None)
energy_db.commit()
# The period [now, now+15min) is still open — should not be computed.
# All closed past periods: meter OK, but no contract version → skip, no write.
written = compute_closed_periods(energy_db)
assert written == 0 # nothing written (no closed periods with data)
@@ -1314,6 +1434,8 @@ class TestComputeClosedPeriods:
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=past_t0 - timedelta(hours=1))
# Meter covering from before past_t0.
_make_meter(energy_db, started_at=past_t0 - timedelta(hours=1), ended_at=None)
_make_reading(energy_db, recorded_at=past_t0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=past_t1, d1=_END_D1, d2=_END_D2,
@@ -1349,6 +1471,8 @@ class TestComputeClosedPeriods:
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=past_t0 - timedelta(hours=1))
# Meter covering from before past_t0.
_make_meter(energy_db, started_at=past_t0 - timedelta(hours=1), ended_at=None)
# First compute: no readings → degraded.
energy_db.commit()
compute_period(energy_db, past_t0)
@@ -1386,6 +1510,7 @@ class TestRecomputeRange:
"""recompute_range must overwrite non-degraded rows (explicit opt-in)."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -1415,6 +1540,7 @@ class TestRecomputeRange:
"""recompute_range returns the number of periods actually written."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Add readings for two consecutive periods: [10:00,10:15), [10:15,10:30).
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
@@ -1436,6 +1562,7 @@ class TestRecomputeRange:
"""Calling recompute_range twice must not create duplicate rows."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
@@ -1469,6 +1596,7 @@ class TestRecomputeRange:
# Step 1: successful compute.
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
start_reading = _make_reading(
energy_db, recorded_at=_T0,
d1=_START_D1, d2=_START_D2, r1=_START_R1, r2=_START_R2, source_id=1,
@@ -1528,16 +1656,19 @@ class TestRegisterAtFreshness:
``_READING_MAX_STALENESS`` (15 minutes) old at a given boundary is treated
as absent so the period is marked degraded rather than producing a
zero-delta fake-success row.
All tests now pass a meter that covers the reading and boundary times.
"""
def test_stale_reading_returns_none(self, energy_db: Session) -> None:
"""A reading older than 15 min before the boundary must be rejected."""
reading_time = _ts(10, 0) # 10:00
boundary = _ts(10, 30) # 10:30 — 30 min later (> 15 min staleness)
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time, source_id=1)
energy_db.commit()
result = register_at(energy_db, boundary)
result = register_at(energy_db, boundary, meter)
assert result is None, (
"register_at must return None when the closest reading is more than "
"15 minutes before the boundary"
@@ -1547,12 +1678,13 @@ class TestRegisterAtFreshness:
"""A reading within 15 min of the boundary must be returned normally."""
reading_time = _ts(10, 5) # 10:05
boundary = _ts(10, 15) # 10:15 — only 10 min gap (within window)
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time,
d1="30000.0", d2="15000.0", r1="1000.0", r2="500.0",
source_id=1)
energy_db.commit()
result = register_at(energy_db, boundary)
result = register_at(energy_db, boundary, meter)
assert result is not None, (
"register_at must return the reading when it is within the 15-min staleness window"
)
@@ -1562,12 +1694,13 @@ class TestRegisterAtFreshness:
"""A reading exactly 15 min before the boundary sits at the edge — accepted."""
reading_time = _ts(10, 0) # 10:00
boundary = _ts(10, 15) # 10:15 — exactly 15 min gap
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time,
d1="40000.0", d2="20000.0", r1="2000.0", r2="1000.0",
source_id=1)
energy_db.commit()
result = register_at(energy_db, boundary)
result = register_at(energy_db, boundary, meter)
# boundary - reading == 15 min == staleness limit → NOT stale (strict <)
assert result is not None
@@ -1581,12 +1714,13 @@ class TestRegisterAtFreshness:
"""
# Insert a reading at a realistic "now" time.
reading_time = _ts(10, 0) # 10:00 on 2026-06-23
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=reading_time, source_id=1)
energy_db.commit()
# Query with a far-future boundary (e.g. end of month, same day +8 hours)
far_future_boundary = _ts(18, 0) # 18:00 — 8 hours later
result = register_at(energy_db, far_future_boundary)
result = register_at(energy_db, far_future_boundary, meter)
assert result is None, (
"register_at must return None for a far-future boundary rather than "
"the latest historical reading"
@@ -1615,6 +1749,7 @@ class TestFuturePeriodDegraded:
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Only a reading at 10:00 — far from the 14:00/14:15 boundaries.
_make_reading(energy_db, recorded_at=_ts(10, 0), source_id=1)
energy_db.commit()
@@ -1643,12 +1778,14 @@ class TestRecomputeRangeNoFuturePeriods:
future end datetime; the only periods that may be written are those
where t1 <= now. No row with period_start > now should appear in the DB.
"""
now = datetime.now(UTC)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
# Meter covering from before now.
_make_meter(energy_db, started_at=now - timedelta(hours=1), ended_at=None)
# No readings needed — we only care that future rows are NOT written.
energy_db.commit()
now = datetime.now(UTC)
far_future_end = now + timedelta(days=7)
# Use a past start so there are some candidate periods.
past_start = floor_to_quarter(now - timedelta(minutes=30))
@@ -1678,15 +1815,21 @@ class TestRecomputeRangeNoFuturePeriods:
We only verify that no future rows exist after step 1 (Fix B), because
that is the core slot-squatting prevention.
"""
now = datetime.now(UTC)
contract = _make_contract(energy_db, kind="manual", active=True)
# Contract effective from far past so all periods in scope have a version.
_make_version(
energy_db, contract, _MANUAL_VALUES,
effective_from=datetime(2020, 1, 1, tzinfo=UTC),
)
# Meter covering from far past.
_make_meter(
energy_db,
started_at=datetime(2020, 1, 1, tzinfo=UTC),
ended_at=None,
)
energy_db.commit()
now = datetime.now(UTC)
far_future = now + timedelta(days=30)
past_start = floor_to_quarter(now - timedelta(hours=1))
@@ -1714,6 +1857,7 @@ class TestNormalPeriodsUnaffected:
"""A normal period with readings seconds before each boundary → non-degraded."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Readings placed very close to boundaries (as DSMR normally delivers them).
# t0=10:00, reading at 09:59:50 (10 s before); t1=10:15, reading at 10:14:55 (5 s before)
@@ -1734,3 +1878,388 @@ class TestNormalPeriodsUnaffected:
)
# import_cost matches hand-calc: same deltas as standard scenario
assert abs(row.import_cost - 0.4101) < 1e-6
# ---------------------------------------------------------------------------
# M7-T03 New tests: meter-aware billing engine
# ---------------------------------------------------------------------------
class TestMeterAwareComputePeriod:
"""M7-T03 Acceptance criteria: meter-aware compute_period behaviour.
Covers:
Same-meter period: delta computed correctly, meter_id attributed.
Cross-meter boundary period: degraded.
No active meter coverage: degraded with meter_id=None.
Negative delta: degraded (D6 guard no negative costs).
Super-large delta exceeding _MAX_DELTA_KWH: degraded (D6 guard).
Recompute re-judges meter attribution.
No-contract skip preserved inside single-meter path.
"""
# ① Same-meter period: delta correct + meter_id attributed
def test_same_meter_period_computes_correctly_with_meter_id(
self, energy_db: Session
) -> None:
"""A normal period within a single meter epoch: costs correct, meter_id set."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
meter = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
# Cost correctness (same as manual scenario hand-calc).
assert row.degraded is False
assert abs(row.import_cost - 0.4101) < 1e-9
assert abs(row.export_revenue - 0.005) < 1e-9
assert abs(row.net_cost - 0.4051) < 1e-9
# meter_id must be set to the active meter's id.
assert row.meter_id == meter.id, (
f"Expected meter_id={meter.id}, got {row.meter_id}"
)
# ② Cross-meter boundary: degraded
def test_cross_meter_boundary_period_is_degraded(self, energy_db: Session) -> None:
"""A period spanning two meter epochs must be written as degraded.
Scenario:
- Old meter: [09:00, 10:15) covers t0=10:00 but NOT t1=10:15.
- New meter: [10:15, ) covers t1=10:15.
- Period [10:00, 10:15): m0 != m1 degraded with meter_id=m0.id.
"""
old_meter = _make_meter(energy_db, started_at=_ts(9, 0), ended_at=_T1)
new_meter = _make_meter(energy_db, started_at=_T1, ended_at=None)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
# Readings exist, but the period still spans two meters.
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True # a row was written
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, (
"A period spanning two meter epochs must be written as degraded"
)
# meter_id should be m0 (t0's meter), not m1.
assert row.meter_id == old_meter.id, (
f"Cross-meter degraded row should attribute to old_meter (id={old_meter.id}), "
f"got meter_id={row.meter_id}"
)
# No real cost should be produced.
assert row.import_cost == 0.0
assert row.net_cost == 0.0
# Suppress unused variable warning.
_ = new_meter
# ③ No active meter coverage: degraded with meter_id=None
def test_no_meter_coverage_is_degraded_with_null_meter_id(
self, energy_db: Session
) -> None:
"""When no meter epoch covers t0, the period is written as degraded(meter_id=None)."""
# No meter inserted at all.
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, "No-meter period must be written as degraded"
assert row.meter_id is None, (
"No-meter degraded row must have meter_id=None, "
f"got meter_id={row.meter_id}"
)
assert row.import_cost == 0.0
# ④ Negative delta: degraded (D6 guard)
def test_negative_delta_is_degraded(self, energy_db: Session) -> None:
"""A period with any negative register delta → degraded (D6 guard).
Negative deltas occur after a meter reset or DSMR rollover and must
never produce a negative cost row.
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# End reading has LOWER d1 than start → negative delta for d1.
_make_reading(energy_db, recorded_at=_T0, d1="20000.500", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1="20000.000", d2="10001.200",
r1="5000.000", r2="3000.100", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, (
"A period with a negative delta must be written as degraded (no negative costs)"
)
assert row.import_cost == 0.0
assert row.net_cost == 0.0
# ⑤ Super-large delta: degraded (D6 guard)
def test_super_large_delta_is_degraded(self, energy_db: Session) -> None:
"""A period with a delta > _MAX_DELTA_KWH → degraded (D6 guard).
Implausibly large deltas indicate an anomaly (wrong scale, DSMR
reporting bug) and must never produce a grossly inflated cost row.
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# d1 delta = 200 kWh >> _MAX_DELTA_KWH (100 kWh).
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1="10200.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, (
f"A delta > {_MAX_DELTA_KWH} kWh must be written as degraded (D6 guard)"
)
assert row.import_cost == 0.0
assert row.net_cost == 0.0
# ⑤b Delta exactly at limit is NOT degraded (guard fires at strictly >)
def test_delta_exactly_at_max_limit_not_degraded(self, energy_db: Session) -> None:
"""A delta exactly equal to _MAX_DELTA_KWH is NOT degraded.
The guard condition is strictly > _MAX_DELTA_KWH, so a delta of exactly
100 kWh passes through and is computed normally. This is intentional:
the threshold is set far above any plausible residential consumption
(400 kW average over 15 min) to avoid false positives.
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# d1 delta = _MAX_DELTA_KWH exactly (100 kWh) — must NOT degrade.
max_delta = float(_MAX_DELTA_KWH)
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=str(10000.0 + max_delta),
d2="10000.000", r1="5000.000", r2="3000.000", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is False, (
f"A delta exactly at _MAX_DELTA_KWH ({max_delta} kWh) must NOT trigger "
"the D6 guard (guard condition is strictly >)"
)
assert row.import_cost > 0
# ⑤c Delta strictly above limit IS degraded
def test_delta_strictly_above_max_limit_is_degraded(self, energy_db: Session) -> None:
"""A delta strictly greater than _MAX_DELTA_KWH → degraded (D6 guard fires)."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# d1 delta = _MAX_DELTA_KWH + 0.001 (strictly over threshold) → degraded.
max_delta_plus = float(_MAX_DELTA_KWH) + 0.001
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=str(10000.0 + max_delta_plus),
d2="10000.000", r1="5000.000", r2="3000.000", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True, (
f"A delta of {max_delta_plus} kWh (> _MAX_DELTA_KWH) must trigger D6 guard"
)
# ⑤c Delta just below limit is NOT degraded
def test_delta_just_below_max_limit_not_degraded(self, energy_db: Session) -> None:
"""A delta just below _MAX_DELTA_KWH must NOT trigger the D6 guard."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# d1 delta = 99.999 kWh — just under 100, should compute normally.
_make_reading(energy_db, recorded_at=_T0, d1="10000.000", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1="10099.999", d2="10000.000",
r1="5000.000", r2="3000.000", source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is False, (
"A delta of 99.999 kWh must NOT trigger the D6 guard (guard fires at > 100 kWh)"
)
assert row.import_cost > 0
# ⑥ recompute re-judges meter attribution
def test_recompute_re_judges_meter_attribution(self, energy_db: Session) -> None:
"""recompute_range must re-attribute meter_id to the current meter_at judgment.
Scenario:
1. Compute period [10:00, 10:15) with meter M1 active row has meter_id=M1.id.
2. Retroactively close M1 at 10:00 and open M2 from 09:00 (so M2 covers both
boundaries, by updating the meter row directly simulating update_meter).
3. Call recompute_range row's meter_id must now be M2.id.
"""
# Step 1: initial compute with M1.
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
m1 = _make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
compute_period(energy_db, _T0)
energy_db.commit()
row_before = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row_before.meter_id == m1.id, "Pre-condition: meter_id should be m1.id"
# Step 2: retroactively replace M1 with M2.
# Close M1 (make it a zero-width closed epoch before any test reading).
# Open M2 covering the whole day.
m1.ended_at = datetime(2026, 6, 22, 0, 0, 0, tzinfo=_UTC) # before test day
m2 = _make_meter(
energy_db,
started_at=datetime(2026, 6, 22, 0, 0, 0, tzinfo=_UTC),
ended_at=None,
label="Replacement Meter",
)
energy_db.commit()
# Step 3: recompute the range — should re-attribute to m2.
count = recompute_range(energy_db, _T0, _T1)
assert count == 1
energy_db.expire_all()
row_after = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row_after.meter_id == m2.id, (
f"After recompute, meter_id should be m2.id={m2.id}, "
f"got {row_after.meter_id}"
)
assert row_after.degraded is False
# ⑦ No-contract skip preserved inside single-meter path
def test_no_contract_is_skip_not_degraded(self, energy_db: Session) -> None:
"""Single-meter path with no active contract → skip (no row written).
This ensures that 'meter OK, contract missing' still produces a skip
(not a degraded row), preserving the existing skip semantics.
"""
# Active meter but NO contract.
_make_active_meter(energy_db)
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_END_D1, d2=_END_D2,
r1=_END_R1, r2=_END_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is False, (
"Single-meter, no-contract period must be skipped (return False, no row)"
)
rows = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalars().all()
assert len(rows) == 0, (
"No EnergyCostPeriod row must be written when skipping due to missing contract"
)
# Degraded within-meter rows get the correct meter_id
def test_degraded_within_meter_has_meter_id(self, energy_db: Session) -> None:
"""Degraded rows due to missing readings within a known meter get meter_id set."""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
meter = _make_active_meter(energy_db)
# No readings at all → will degrade due to missing readings.
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is True
assert row.meter_id == meter.id, (
f"Within-meter degraded row must carry meter_id={meter.id}, "
f"got {row.meter_id}"
)
# Delta sanity guard with all-zero deltas (zero is fine, not negative)
def test_zero_delta_not_degraded(self, energy_db: Session) -> None:
"""A period with all-zero deltas (no energy used) must NOT be degraded.
Zero is valid perfectly matching start/end readings means no energy
was consumed or produced in that window.
"""
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
_make_active_meter(energy_db)
# Identical start and end readings → all deltas = 0.
_make_reading(energy_db, recorded_at=_T0, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=1)
_make_reading(energy_db, recorded_at=_T1, d1=_START_D1, d2=_START_D2,
r1=_START_R1, r2=_START_R2, source_id=2)
energy_db.commit()
result = compute_period(energy_db, _T0)
assert result is True
row = energy_db.execute(
select(EnergyCostPeriod).where(EnergyCostPeriod.period_start == _T0)
).scalar_one()
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
assert row.import_cost == 0.0
assert row.net_cost == 0.0
+364 -75
View File
@@ -90,6 +90,37 @@ def _make_period(
return p
def _make_active_meter(
session: Session,
*,
started_at: datetime,
label: str = "Test Meter",
commodity: str = "electricity",
reason: str = "initial",
) -> Any:
"""Insert an active (ended_at=None) Meter row and flush.
Helper for M7-T04 tests: every cumulative-getter test that expects a
non-None return value must declare an active electricity meter so the
D2 anchor (meter.started_at) can be resolved.
"""
from app.models.energy import Meter
now = datetime.now(tz=timezone.utc)
m = Meter(
label=label,
commodity=commodity,
started_at=started_at,
ended_at=None,
reason=reason,
note=None,
created_at=now,
)
session.add(m)
session.flush()
return m
def _make_settings(
*,
mqtt_enabled: bool = True,
@@ -398,14 +429,21 @@ def test_sell_price_getter_reads_manual_snapshot(energy_db) -> None:
def test_import_cost_total_sums_non_degraded_rows(energy_db) -> None:
"""import_cost_total must return SUM of non-degraded import_cost values only."""
"""import_cost_total must return SUM of non-degraded import_cost values only.
M7-T04 (D2): active electricity meter required so the getter can anchor on
meter.started_at. The meter is started before t0 so both non-degraded rows
fall within [anchor, now).
"""
from app.integrations.expose import build_catalog
t0 = datetime(2025, 3, 1, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2025, 3, 1, 6, 15, tzinfo=timezone.utc)
t2 = datetime(2025, 3, 1, 6, 30, tzinfo=timezone.utc)
meter_start = datetime(2025, 3, 1, 0, 0, tzinfo=timezone.utc)
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
# Two good rows: 0.10 + 0.20 = 0.30
_make_period(session, period_start=t0, import_cost=0.10, degraded=False)
_make_period(session, period_start=t1, import_cost=0.20, degraded=False)
@@ -420,20 +458,26 @@ def test_import_cost_total_sums_non_degraded_rows(energy_db) -> None:
)
value = import_entry.entity.value_getter(session)
# No active contract → fixed_costs = 0; only metered sum = 0.30.
assert value == pytest.approx(0.30), (
f"Expected cumulative import_cost 0.30 (non-degraded only), got {value!r}"
)
def test_export_revenue_total_sums_non_degraded_rows(energy_db) -> None:
"""export_revenue_total must return SUM of non-degraded export_revenue values only."""
"""export_revenue_total must return SUM of non-degraded export_revenue values only.
M7-T04 (D2): active electricity meter required so the getter can anchor.
"""
from app.integrations.expose import build_catalog
t0 = datetime(2025, 3, 2, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2025, 3, 2, 6, 15, tzinfo=timezone.utc)
t2 = datetime(2025, 3, 2, 6, 30, tzinfo=timezone.utc)
meter_start = datetime(2025, 3, 2, 0, 0, tzinfo=timezone.utc)
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
# Two good rows: 0.05 + 0.07 = 0.12
_make_period(session, period_start=t0, export_revenue=0.05, degraded=False)
_make_period(session, period_start=t1, export_revenue=0.07, degraded=False)
@@ -448,6 +492,7 @@ def test_export_revenue_total_sums_non_degraded_rows(energy_db) -> None:
)
value = export_entry.entity.value_getter(session)
# No active contract → credits = 0; only metered sum = 0.12.
assert value == pytest.approx(0.12), (
f"Expected cumulative export_revenue 0.12 (non-degraded only), got {value!r}"
)
@@ -459,13 +504,17 @@ def test_cumulative_getter_excludes_degraded_import_cost_row(energy_db) -> None:
This verifies the exclusion filter on degraded=True rows.
(In practice compute_period writes 0.0 for degraded rows; but if a row was
previously successful and then set degraded, its import_cost could be non-zero.)
M7-T04 (D2): active electricity meter required.
"""
from app.integrations.expose import build_catalog
t0 = datetime(2025, 4, 1, 10, 0, tzinfo=timezone.utc)
t1 = datetime(2025, 4, 1, 10, 15, tzinfo=timezone.utc)
meter_start = datetime(2025, 4, 1, 0, 0, tzinfo=timezone.utc)
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
_make_period(session, period_start=t0, import_cost=0.50, degraded=False)
# A row that is degraded but somehow has a non-zero import_cost (edge case):
_make_period(session, period_start=t1, import_cost=0.99, degraded=True)
@@ -478,7 +527,7 @@ def test_cumulative_getter_excludes_degraded_import_cost_row(energy_db) -> None:
)
value = import_entry.entity.value_getter(session)
# Only the non-degraded row should contribute: 0.50
# Only the non-degraded row should contribute: 0.50 (no contract → fixed_costs=0).
assert value == pytest.approx(0.50), (
f"Degraded row must not be included in SUM; expected 0.50, got {value!r}"
)
@@ -880,18 +929,17 @@ _STANDING_VALUES = {
def test_import_cost_total_includes_standing_charges(energy_db) -> None:
"""import_cost_total getter must add prorated standing charges from active contract.
Principle B/D + FU11 anchor guardrail: the getter delegates to
summarize(anchor now), where anchor = max(contract_start, recording_start).
M7-T04 (D2): anchor = active electricity meter's started_at.
The getter delegates to summarize(anchor now), where anchor = meter.started_at.
With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day.
The getter uses summarize, which counts whole *local* elapsed days under Principle C.
We pin the timezone to UTC for simplicity: local days = UTC days.
Setup:
- effective_from = 10 full UTC days ago (exact UTC midnight).
- First period placed AT effective_from (same UTC midnight), so recording_start =
contract_start anchor = max(contract_start, recording_start) = contract_start.
- Under UTC pinned, Principle C counts days [effective_from.date(), today] = 11 days.
- meter.started_at = effective_from = 10 full UTC days ago (exact UTC midnight).
- First period placed AT meter.started_at anchor = meter.started_at.
- Under UTC pinned, Principle C counts days [meter.started_at.date(), today] = 11 days.
"""
from decimal import Decimal
from datetime import timedelta
@@ -901,20 +949,20 @@ 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)
# anchor = exactly 10 UTC days ago at midnight
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10)
# 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
# Under UTC timezone (pinned), Principle C counts whole days from effective_from.date()
# Under UTC timezone (pinned), Principle C counts whole days from meter.started_at.date()
# to today (inclusive) = 11 days total (10 + today).
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11
expected_days = (now_utc.date() - meter_started_at.date()).days + 1 # = 11
import_cost_sum = 5.00 # EUR
# Place period rows AT effective_from (= recording_start = contract_start → same anchor).
# This ensures anchor = max(effective_from, effective_from) = effective_from exactly.
t0 = effective_from # first period starts exactly at contract effective_from
t0 = meter_started_at # first period at anchor
t1 = t0 + timedelta(minutes=15)
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version(
session,
values=_STANDING_VALUES,
@@ -953,12 +1001,11 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
"""export_revenue_total getter must add prorated heffingskorting from active contract.
Principle B/D + FU11 anchor guardrail: delegates to summarize(anchor now),
where anchor = max(contract_start, recording_start).
M7-T04 (D2): anchor = active electricity meter's started_at.
With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
Timezone pinned to UTC for deterministic local-day counting.
Setup: period placed AT effective_from recording_start = contract_start same anchor.
Setup: meter.started_at = effective_from = 4 days ago 5 days total (incl. today).
"""
from decimal import Decimal
from datetime import timedelta
@@ -968,17 +1015,18 @@ 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)
effective_from = 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
# Under UTC pinned: expected_days = days from effective_from.date() to today inclusive.
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5
# Under UTC pinned: expected_days = days from meter.started_at.date() to today inclusive.
expected_days = (now_utc.date() - meter_started_at.date()).days + 1 # = 5
# Place period rows AT effective_from → recording_start = contract_start = same anchor.
t0 = effective_from
t0 = meter_started_at
t1 = t0 + timedelta(minutes=15)
export_sum = 2.50 # EUR
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version(
session,
values=_STANDING_VALUES,
@@ -1014,19 +1062,18 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> None:
"""When contract version effective_from is in the future, standing cumulative must be 0.
"""When contract version effective_from is in the future, standing charges must be 0.
Principle D: anchor = effective_from (future).
summarize(anchor now) has a negative/empty window 0 days, 0 fixed_costs.
The global `has_data` check passes (there are non-degraded rows from the past),
but no periods fall within [anchor, now] window metered = 0, fixed = 0.
The getter returns 0 + 0 = 0.
M7-T04 (D2): anchor = active electricity meter's started_at (in the past).
The summarize window [meter.started_at, now) DOES include the period data,
but Principle C does not count future local days for fixed costs. Since
the contract's effective_from is in the future, no days in [meter.started_at,
today] fall under a valid contract version, so fixed_costs = 0.
Note: under the new design, if a contract has a future effective_from, the
getter returns the pure metered sum from the summarize window (which is 0
since no periods exist after the future anchor). The old test expected to
return the raw global SUM, but with Principle D the anchor is the future
date window is empty metered = 0.
With the D2 anchor in the past, the metered sum IS returned (unlike the old
FU11 design where the anchor was the future effective_from empty window 0).
Expected: value = import_cost_sum + 0 (no standing days)
"""
from datetime import timedelta
from unittest.mock import patch
@@ -1035,14 +1082,15 @@ def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> N
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc)
# Meter started 2 days ago; period is 1 day ago (within meter window).
meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=2)
future_effective = now_utc + timedelta(days=30)
# Place a period BEFORE the future effective_from (global sum = 4.00)
# but it won't be in [anchor, now] window.
t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
import_cost_sum = 4.00
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version(
session,
values=_STANDING_VALUES,
@@ -1060,19 +1108,21 @@ def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> N
)
value = import_entry.entity.value_getter(session)
# anchor is in future → summarize([future, now]) is empty → metered=0, fixed_costs=0
# Principle C: no future local days counted → 0 fixed costs.
# Result = 0 (no periods in window) + 0 (0 standing days) = 0.
assert value == pytest.approx(0.0, abs=1e-9), (
f"When effective_from is in future, summarize window is empty → value must be 0.0; "
f"got {value!r}"
# D2: anchor = meter.started_at (past) → metered_import = import_cost_sum.
# Principle C: contract effective_from is in the future → 0 standing days → fixed_costs = 0.
# Result = import_cost_sum + 0.
assert value == pytest.approx(import_cost_sum, rel=1e-9), (
f"When effective_from is in future, standing = 0, value = metered sum; "
f"expected {import_cost_sum}, got {value!r}"
)
def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None:
"""When contract version effective_from is in the future, credit cumulative must be 0.
"""When contract version effective_from is in the future, tax credit must be 0.
Same logic as import: anchor is future summarize window empty 0 credits.
M7-T04 (D2): anchor = active electricity meter's started_at (in the past).
Summarize window includes the period, but Principle C does not count future
days, so credits = 0. Expected: value = export_sum + 0 credits.
"""
from datetime import timedelta
from unittest.mock import patch
@@ -1081,12 +1131,14 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) ->
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc)
meter_started_at = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=2)
future_effective = now_utc + timedelta(days=60)
t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
export_sum = 3.00
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version(
session,
values=_STANDING_VALUES,
@@ -1104,9 +1156,10 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) ->
)
value = export_entry.entity.value_getter(session)
# anchor is in future → empty window → 0 credits
assert value == pytest.approx(0.0, abs=1e-9), (
f"When effective_from is in future, credit must be 0; got {value!r}"
# D2: metered_export = export_sum; Principle C: future effective_from → 0 credits.
assert value == pytest.approx(export_sum, rel=1e-9), (
f"When effective_from is in future, credit = 0, value = metered sum; "
f"expected {export_sum}, got {value!r}"
)
@@ -1115,14 +1168,22 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) ->
# ---------------------------------------------------------------------------
def test_import_cost_total_falls_back_to_sum_when_no_active_contract(energy_db) -> None:
"""When no active contract exists, import_cost_total must return the raw SUM only."""
def test_import_cost_total_returns_metered_sum_when_no_active_contract(energy_db) -> None:
"""When active meter exists but no active contract, import_cost_total returns metered sum.
M7-T04 (D2): anchor = meter.started_at; summarize runs without a contract
fixed_costs = 0; the return value is the pure metered sum within the meter window.
This is a behaviour change from FU11 (which returned a global SUM) D2 only
counts periods since meter.started_at.
"""
from app.integrations.expose import build_catalog
t0 = datetime(2026, 1, 1, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2026, 1, 1, 6, 15, tzinfo=timezone.utc)
meter_start = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
# Inactive contract — active=False
_make_contract_with_version(
session,
@@ -1141,20 +1202,27 @@ def test_import_cost_total_falls_back_to_sum_when_no_active_contract(energy_db)
)
value = import_entry.entity.value_getter(session)
# No active contract → standing cumulative = 0 → value = pure SUM = 4.00
# D2: anchor = meter.started_at; both periods fall within [anchor, now].
# No active contract → fixed_costs = 0 → value = pure metered SUM = 4.00.
assert value == pytest.approx(4.00, rel=1e-9), (
f"Expected pure SUM=4.00 with no active contract, got {value!r}"
f"Expected metered SUM=4.00 (no contract → no standing), got {value!r}"
)
def test_export_revenue_total_falls_back_to_sum_when_no_active_contract(energy_db) -> None:
"""When no active contract exists, export_revenue_total must return the raw SUM only."""
def test_export_revenue_total_returns_metered_sum_when_no_active_contract(energy_db) -> None:
"""When active meter exists but no active contract, export_revenue_total returns metered sum.
M7-T04 (D2): anchor = meter.started_at; summarize with no contract credits = 0;
return value = pure metered export sum within the meter window.
"""
from app.integrations.expose import build_catalog
t0 = datetime(2026, 2, 1, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2026, 2, 1, 6, 15, tzinfo=timezone.utc)
meter_start = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_start)
# Inactive contract — active=False
_make_contract_with_version(
session,
@@ -1173,9 +1241,9 @@ def test_export_revenue_total_falls_back_to_sum_when_no_active_contract(energy_d
)
value = export_entry.entity.value_getter(session)
# No active contract → credit cumulative = 0 → value = pure SUM = 2.00
# D2: anchor = meter.started_at; no contract → credits = 0 → value = metered sum = 2.00.
assert value == pytest.approx(2.00, rel=1e-9), (
f"Expected pure SUM=2.00 with no active contract, got {value!r}"
f"Expected metered SUM=2.00 (no contract → no credits), got {value!r}"
)
@@ -1629,14 +1697,15 @@ def test_daily_getter_returns_none_when_no_non_degraded_periods(energy_db) -> No
# ---------------------------------------------------------------------------
def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_db) -> None:
"""FU11: anchor = max(contract_start, recording_start) — no pre-recording fixed costs.
def test_cumulative_anchor_is_meter_started_at(energy_db) -> None:
"""M7-T04 (D2): anchor = active electricity meter's started_at, not recording_start.
Scenario: contract effective_from = 180 days ago (lots of standing charges if used
as anchor), but first non-degraded period_start = 7 days ago. Expected: anchor is
7 days ago only 8 days of standing charges (7 + today under Principle C).
Scenario: contract effective_from = 180 days ago; meter.started_at = 7 days ago;
first non-degraded period_start = 7 days ago.
Expected: anchor = meter.started_at (7 days ago) 8 days of standing charges.
Without the fix, anchor would be 180 days ago ~180 days × 3 EUR/day = 540+.
This replaces the old FU11 test (anchor=recording_start). Under D2 the anchor
is always the active meter's started_at, irrespective of when data recording began.
"""
from decimal import Decimal
from datetime import timedelta
@@ -1648,14 +1717,15 @@ def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_
now_utc = datetime.now(timezone.utc)
midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
# Contract starts 180 days ago (far before any data)
# Contract starts 180 days ago (far before the meter)
contract_start = midnight_today - timedelta(days=180)
# First (and only) data period starts 7 days ago
recording_start = midnight_today - timedelta(days=7)
# Active meter started 7 days ago — this is the D2 anchor
meter_started_at = midnight_today - timedelta(days=7)
t0 = recording_start # first period at recording start
t0 = meter_started_at # first period at meter start
with Session(energy_db) as session:
_make_active_meter(session, started_at=meter_started_at)
_make_contract_with_version(
session,
values=_STANDING_VALUES,
@@ -1673,25 +1743,244 @@ def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_
)
value = import_entry.entity.value_getter(session)
# Anchor = recording_start (7 days ago UTC midnight).
# Principle C (UTC pinned): count local days from recording_start.date() to today inclusive.
# Anchor = meter.started_at (7 days ago).
# Principle C (UTC pinned): count local days from meter_started_at.date() to today inclusive.
# That's 7 + 1 = 8 days.
expected_days = (now_utc.date() - recording_start.date()).days + 1 # = 8
expected_days = (now_utc.date() - meter_started_at.date()).days + 1 # = 8
daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day
expected = float(Decimal("5.00") + daily_standing * expected_days)
# Without the fix, value would include ~180 days × 3 EUR/day = ~€540+ of extra standing.
# With the fix, value ≈ 5.00 + 3.0 × 8 = 29.00.
assert value == pytest.approx(expected, rel=1e-6), (
f"Expected anchor=recording_start anchor, "
f"Expected anchor=meter.started_at anchor, "
f"import_cost_total={expected} (metered=5.00 + standing={float(daily_standing*expected_days)} "
f"over {expected_days} days), got {value!r}"
)
# Also verify that using the old anchor (contract start) would give a very different result.
# Verify that using the old FU11 anchor (contract start, 180 days ago) would give a
# very different result (>€500 more standing), confirming we're NOT using it.
old_expected_days = (now_utc.date() - contract_start.date()).days + 1 # ~181 days
old_expected = float(Decimal("5.00") + daily_standing * old_expected_days)
assert abs(value - old_expected) > 100, (
f"Old (wrong) anchor would yield ~{old_expected:.2f}, "
f"the new value {value:.2f} should differ by >€100"
f"D2 anchor (meter.started_at) expected ~{expected:.2f}, "
f"old FU11 anchor (contract_start) would yield ~{old_expected:.2f}; "
f"difference must be >€100"
)
# ---------------------------------------------------------------------------
# M7-T04 new tests: per-meter cumulative reset (D2)
# ---------------------------------------------------------------------------
def test_cumulative_returns_none_when_no_active_meter(energy_db) -> None:
"""M7-T04 None protection: no active electricity meter → import/export total = None.
This is the key D2 None protection: without an active meter the anchor
cannot be resolved, so the getter returns None rather than a stale/wrong value.
Periods exist (non-degraded), but no Meter row with ended_at IS NULL is present.
"""
from app.integrations.expose import build_catalog
t0 = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc)
t1 = datetime(2026, 3, 1, 10, 15, tzinfo=timezone.utc)
with Session(energy_db) as session:
# No Meter row at all — cumulative getters must return None.
_make_period(session, period_start=t0, import_cost=1.00, degraded=False)
_make_period(session, period_start=t1, export_revenue=0.50, degraded=False)
session.commit()
with Session(energy_db) as session:
catalog = build_catalog(session)
import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_total")
export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_total")
import_val = import_entry.entity.value_getter(session)
export_val = export_entry.entity.value_getter(session)
assert import_val is None, (
f"import_cost_total must be None with no active meter, got {import_val!r}"
)
assert export_val is None, (
f"export_revenue_total must be None with no active meter, got {export_val!r}"
)
def test_cumulative_resets_after_meter_swap(energy_db) -> None:
"""M7-T04 (D2): after a meter swap the cumulative only counts the new meter's periods.
Scenario:
- Old meter: started_at = 30 days ago. Two non-degraded periods (3.00 total).
- Swap at: 7 days ago. Old meter closed, new active meter opened.
- New meter: started_at = 7 days ago. One non-degraded period (1.00).
- Expected: import_cost_total = 1.00 + any standing charges from [7 days ago, now].
Old-meter periods (period_start < new_meter.started_at) fall outside the
[anchor, now) window and are excluded, giving a clean reset to zero for the
new meter epoch.
"""
from decimal import Decimal
from datetime import timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.models.energy import Meter as _Meter
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc)
midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
old_meter_start = midnight_today - timedelta(days=30)
swap_at = midnight_today - timedelta(days=7) # new meter anchor
# Old-meter periods (before the swap)
old_t0 = old_meter_start
old_t1 = old_meter_start + timedelta(minutes=15)
# New-meter period (after the swap)
new_t0 = swap_at # first period of the new meter epoch
new_meter_import = 1.00 # EUR — only this should be counted (old-meter 3.00 must be excluded)
with Session(energy_db) as session:
# Old meter: closed at swap_at
old_m = _Meter(
label="Old Meter",
commodity="electricity",
started_at=old_meter_start,
ended_at=swap_at, # closed
reason="initial",
note=None,
created_at=datetime.now(timezone.utc),
)
session.add(old_m)
session.flush()
# New meter: active (ended_at IS NULL) — D2 anchor
new_m = _Meter(
label="New Meter",
commodity="electricity",
started_at=swap_at,
ended_at=None, # active
reason="meter_swap",
note=None,
created_at=datetime.now(timezone.utc),
)
session.add(new_m)
session.flush()
# Old-meter periods (before swap): should NOT appear in post-swap cumulative
_make_period(session, period_start=old_t0, import_cost=2.00, degraded=False)
_make_period(session, period_start=old_t1, import_cost=1.00, degraded=False)
# New-meter period (at swap point / after swap)
_make_period(session, period_start=new_t0, import_cost=new_meter_import, degraded=False)
# Active contract starting well before the old meter
_make_contract_with_version(
session,
values=_STANDING_VALUES,
effective_from=old_meter_start,
active=True,
)
session.commit()
with Session(energy_db) as session:
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session)
import_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_total"
)
value = import_entry.entity.value_getter(session)
# Anchor = new meter's started_at (7 days ago).
# Old-meter periods (period_start < swap_at) fall outside [anchor, now) → excluded.
# Only new_t0 (= swap_at = anchor) is within the window → metered = 1.00.
# Standing charges: anchor = 7 days ago → 8 days (Principle C, UTC pinned).
expected_days = (now_utc.date() - swap_at.date()).days + 1 # = 8
daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day
expected = float(Decimal(str(new_meter_import)) + daily_standing * expected_days)
assert value == pytest.approx(expected, rel=1e-6), (
f"Post-swap cumulative must only include new meter periods. "
f"Expected {expected} (metered={new_meter_import} + "
f"standing={float(daily_standing * expected_days)} over {expected_days} days), "
f"got {value!r}"
)
# Paranoia: if old-meter periods were accidentally included, value would be
# much larger (old_meter_import = 3.00 would inflate it).
assert value < new_meter_import + float(daily_standing * (expected_days + 1)) + 0.5, (
f"Value suspiciously large — old-meter periods may be included: {value!r}"
)
def test_daily_getters_unaffected_by_d2_meter_anchor(energy_db) -> None:
"""M7-T04: daily import/export getters must still use the local-day window.
D2 only changes the cumulative (*_total) anchor. The *_today getters
use today's local-day window [local midnight, local tomorrow midnight) in UTC.
They must continue to work correctly regardless of the meter anchor change,
and do NOT require an active meter (they use today's window directly).
Setup: period 1h ago (today's local window, TZ=UTC), active contract.
Expected: value = import_today + fixed_for_today, independent of any meter.
"""
from decimal import Decimal
from datetime import timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc)
# 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():
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
import_cost_today = 0.88
export_today = 0.44
with Session(energy_db) as session:
# No active meter added intentionally — daily getters must not need it.
_make_contract_with_version(
session,
values=_STANDING_VALUES,
effective_from=effective_from,
active=True,
)
_make_period(
session,
period_start=t0,
import_cost=import_cost_today,
export_revenue=export_today,
degraded=False,
)
session.commit()
with Session(energy_db) as session:
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session)
import_today_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_today"
)
export_today_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_today"
)
import_val = import_today_entry.entity.value_getter(session)
export_val = export_today_entry.entity.value_getter(session)
# Principle C: today counts as 1 day.
daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day
daily_credit = Decimal("365") / Decimal("365") # = 1.0 EUR/day
expected_import = float(Decimal(str(import_cost_today)) + daily_standing * 1)
expected_export = float(Decimal(str(export_today)) + daily_credit * 1)
assert import_val == pytest.approx(expected_import, rel=1e-6), (
f"import_cost_today must use local-day window regardless of meter; "
f"expected {expected_import}, got {import_val!r}"
)
assert export_val == pytest.approx(expected_export, rel=1e-6), (
f"export_revenue_today must use local-day window regardless of meter; "
f"expected {expected_export}, got {export_val!r}"
)
+443 -9
View File
@@ -1,13 +1,15 @@
"""Tests for M6-T01: energy pricing and DSMR metering tables and ORM models.
"""Tests for energy pricing, DSMR metering, and meter epoch tables and ORM models.
Covers:
1. Migration shape: upgrade to head creates all five tables with correct columns,
1. Migration shape: upgrade to head creates all six tables with correct columns,
constraints, and indexes; downgrade -1 cleanly removes them.
2. ORM metadata: Base.metadata.tables contains all five tables; FKs are RESTRICT;
2. ORM metadata: Base.metadata.tables contains all six tables; FKs are RESTRICT;
JSON columns are present; unique and index constraints are correct.
3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head.
4. Basic ORM round-trip: insert + retrieve for each table, JSON payload round-trip.
5. FK RESTRICT: deleting a referenced parent row must fail when children exist.
6. Meter model: fields, nullability, FK from energy_cost_period.
7. Migration backfill: initial meter creation, idempotency, audit.
"""
from __future__ import annotations
@@ -27,6 +29,7 @@ from app.models.energy import (
DsmrReading,
EnergyContract,
EnergyContractVersion,
Meter,
TibberPrice,
EnergyCostPeriod,
)
@@ -92,10 +95,11 @@ def energy_db_with_fk(tmp_path: Path):
def test_energy_tables_exist_after_upgrade(energy_db):
"""All five energy tables must exist after upgrade to head."""
"""All six energy tables must exist after upgrade to head."""
inspector = inspect(energy_db)
table_names = inspector.get_table_names()
expected_tables = {
"meter",
"dsmr_reading",
"energy_contract",
"energy_contract_version",
@@ -200,7 +204,7 @@ def test_energy_cost_period_columns(energy_db):
"import_cost", "export_revenue", "net_cost", "currency",
"pricing", "degraded", "computed_at",
}
nullable = {"contract_version_id"}
nullable = {"contract_version_id", "meter_id"}
for col_name in non_nullable:
assert col_name in columns, f"Missing column: {col_name}"
@@ -234,10 +238,35 @@ def test_energy_cost_period_fk_to_contract_version(energy_db):
"""energy_cost_period.contract_version_id must have a FK referencing energy_contract_version.id."""
inspector = inspect(energy_db)
fks = inspector.get_foreign_keys("energy_cost_period")
assert len(fks) == 1, f"Expected 1 FK on energy_cost_period, got {len(fks)}"
fk = fks[0]
# Two FKs: contract_version_id → energy_contract_version.id
# meter_id → meter.id
fk_by_col = {
col: fk
for fk in fks
for col in fk["constrained_columns"]
}
assert "contract_version_id" in fk_by_col, (
"energy_cost_period must have a FK on contract_version_id"
)
fk = fk_by_col["contract_version_id"]
assert fk["referred_table"] == "energy_contract_version"
assert "contract_version_id" in fk["constrained_columns"]
assert "id" in fk["referred_columns"]
def test_energy_cost_period_fk_to_meter(energy_db):
"""energy_cost_period.meter_id must have a FK referencing meter.id."""
inspector = inspect(energy_db)
fks = inspector.get_foreign_keys("energy_cost_period")
fk_by_col = {
col: fk
for fk in fks
for col in fk["constrained_columns"]
}
assert "meter_id" in fk_by_col, (
"energy_cost_period must have a FK on meter_id"
)
fk = fk_by_col["meter_id"]
assert fk["referred_table"] == "meter"
assert "id" in fk["referred_columns"]
@@ -308,8 +337,9 @@ def test_dsmr_decouple_migration_downgrade_restores_source_id_unique(tmp_path: P
def test_base_metadata_contains_energy_tables():
"""Base.metadata.tables must include all five new energy tables."""
"""Base.metadata.tables must include all six energy tables."""
expected = {
"meter",
"dsmr_reading",
"energy_contract",
"energy_contract_version",
@@ -366,6 +396,17 @@ def test_energy_cost_period_fk_ondelete_restrict():
)
def test_energy_cost_period_meter_id_fk_ondelete_restrict():
"""The FK from energy_cost_period.meter_id must be ON DELETE RESTRICT."""
table = Base.metadata.tables["energy_cost_period"]
col = table.columns["meter_id"]
assert col.foreign_keys, "meter_id must have a foreign key"
fk = next(iter(col.foreign_keys))
assert fk.ondelete == "RESTRICT", (
f"meter_id FK ondelete must be RESTRICT, got: {fk.ondelete!r}"
)
def test_dsmr_reading_recorded_at_unique_in_metadata():
"""DsmrReading.recorded_at must be the unique de-dup key in ORM metadata,
and source_id must NOT be unique (decoupled from the telegram id)."""
@@ -739,3 +780,396 @@ def test_cost_period_restrict_prevents_version_deletion(tmp_path: Path):
session.commit()
finally:
engine.dispose()
# ---------------------------------------------------------------------------
# 6. Meter model tests (M7-T01)
# ---------------------------------------------------------------------------
def test_meter_table_exists_after_upgrade(energy_db):
"""meter table must exist after upgrade to head."""
inspector = inspect(energy_db)
assert "meter" in inspector.get_table_names(), "meter table missing after upgrade to head"
def test_meter_columns(energy_db):
"""meter must have all required columns with correct nullability."""
inspector = inspect(energy_db)
columns = {col["name"]: col for col in inspector.get_columns("meter")}
non_nullable = {"id", "label", "commodity", "started_at", "reason", "created_at"}
nullable = {"ended_at", "note"}
for col_name in non_nullable:
assert col_name in columns, f"Missing column: {col_name}"
assert not columns[col_name]["nullable"], f"{col_name} should be NOT NULL"
for col_name in nullable:
assert col_name in columns, f"Missing column: {col_name}"
assert columns[col_name]["nullable"], f"{col_name} should be nullable"
def test_meter_orm_metadata():
"""Meter must be registered in Base.metadata with correct field types."""
assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
table = Base.metadata.tables["meter"]
assert "id" in table.columns
assert "label" in table.columns
assert "commodity" in table.columns
assert "started_at" in table.columns
assert "ended_at" in table.columns
assert "reason" in table.columns
assert "note" in table.columns
assert "created_at" in table.columns
def test_meter_insert_and_retrieve(energy_db):
"""A Meter row can be inserted and retrieved with all fields intact."""
now = datetime.now(tz=timezone.utc)
with Session(energy_db) as session:
m = Meter(
label="Test meter @ Dorpsstraat 1",
commodity="electricity",
started_at=now,
ended_at=None,
reason="initial",
note="2G smart meter",
created_at=now,
)
session.add(m)
session.commit()
meter_id = m.id
with Session(energy_db) as session:
fetched = session.get(Meter, meter_id)
assert fetched is not None
assert fetched.label == "Test meter @ Dorpsstraat 1"
assert fetched.commodity == "electricity"
assert fetched.reason == "initial"
assert fetched.note == "2G smart meter"
assert fetched.ended_at is None
def test_meter_ended_at_nullable(energy_db):
"""Meter.ended_at and Meter.note can both be None (active/ongoing meter)."""
now = datetime.now(tz=timezone.utc)
with Session(energy_db) as session:
m = Meter(
label="Active meter",
commodity="electricity",
started_at=now,
ended_at=None,
reason="home_move",
note=None,
created_at=now,
)
session.add(m)
session.commit()
meter_id = m.id
with Session(energy_db) as session:
fetched = session.get(Meter, meter_id)
assert fetched is not None
assert fetched.ended_at is None
assert fetched.note is None
def test_energy_cost_period_meter_id_nullable(energy_db):
"""EnergyCostPeriod.meter_id must be nullable (no meter FK required)."""
now = datetime.now(tz=timezone.utc)
with Session(energy_db) as session:
period = EnergyCostPeriod(
period_start=now,
d1_kwh=0.5,
d2_kwh=1.2,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.225,
export_revenue=0.0,
net_cost=0.225,
currency="EUR",
pricing={"kind": "manual"},
contract_version_id=None,
meter_id=None,
degraded=False,
computed_at=now,
)
session.add(period)
session.commit()
period_id = period.id
with Session(energy_db) as session:
fetched = session.get(EnergyCostPeriod, period_id)
assert fetched is not None
assert fetched.meter_id is None
def test_energy_cost_period_meter_id_fk_enforced(tmp_path: Path):
"""Inserting an EnergyCostPeriod with a non-existent meter_id must fail (RESTRICT FK)."""
db_path = tmp_path / "meter_fk_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = _engine_with_fk(db_url)
try:
now = datetime.now(tz=timezone.utc)
with pytest.raises(sqlalchemy.exc.IntegrityError):
with Session(engine) as session:
period = EnergyCostPeriod(
period_start=now,
d1_kwh=0.0,
d2_kwh=0.0,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.0,
export_revenue=0.0,
net_cost=0.0,
currency="EUR",
pricing={},
contract_version_id=None,
meter_id=9999, # non-existent meter
degraded=False,
computed_at=now,
)
session.add(period)
session.commit()
finally:
engine.dispose()
def test_meter_restrict_prevents_deletion_with_cost_periods(tmp_path: Path):
"""Deleting a Meter that has cost periods attributed to it must fail (ON DELETE RESTRICT)."""
db_path = tmp_path / "meter_restrict_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = _engine_with_fk(db_url)
try:
now = datetime.now(tz=timezone.utc)
with Session(engine) as session:
m = Meter(
label="RESTRICT Test Meter",
commodity="electricity",
started_at=now,
ended_at=None,
reason="initial",
note=None,
created_at=now,
)
session.add(m)
session.flush()
period = EnergyCostPeriod(
period_start=now,
d1_kwh=0.1,
d2_kwh=0.2,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.05,
export_revenue=0.0,
net_cost=0.05,
currency="EUR",
pricing={"kind": "manual"},
contract_version_id=None,
meter_id=m.id,
degraded=False,
computed_at=now,
)
session.add(period)
session.commit()
meter_id = m.id
with pytest.raises(sqlalchemy.exc.IntegrityError):
with Session(engine) as session:
session.execute(
text("DELETE FROM meter WHERE id = :mid"),
{"mid": meter_id},
)
session.commit()
finally:
engine.dispose()
# ---------------------------------------------------------------------------
# 7. Migration backfill tests (M7-T01)
# ---------------------------------------------------------------------------
def test_migration_empty_db_no_initial_meter(tmp_path: Path):
"""Upgrading an empty database must not create any meter rows."""
db_path = tmp_path / "empty_backfill_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
with Session(engine) as session:
count = session.execute(text("SELECT COUNT(*) FROM meter")).scalar()
assert count == 0, f"Expected 0 meter rows in empty DB after upgrade, got {count}"
engine.dispose()
def test_migration_backfill_creates_initial_meter_from_readings(tmp_path: Path):
"""Upgrading with existing dsmr_reading rows must create exactly one initial meter
and back-fill all energy_cost_period rows with its id."""
from datetime import timedelta
db_path = tmp_path / "backfill_readings_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
# Upgrade only to the revision just before M7-T01 (i.e. pre-meter).
command.upgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
now = datetime.now(tz=timezone.utc)
t0 = now - timedelta(hours=2)
t1 = now - timedelta(hours=1)
with Session(engine) as session:
# Insert two dsmr_reading rows.
session.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
"VALUES (:ts, NULL, '{}')"
),
{"ts": t0.strftime("%Y-%m-%dT%H:%M:%S")},
)
session.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
"VALUES (:ts, NULL, '{}')"
),
{"ts": t1.strftime("%Y-%m-%dT%H:%M:%S")},
)
# Insert two energy_cost_period rows (no meter_id column yet at this revision).
session.execute(
text(
"INSERT INTO energy_cost_period "
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, "
"import_cost, export_revenue, net_cost, currency, pricing, "
"contract_version_id, degraded, computed_at) "
"VALUES (:ps, 0.1, 0.2, 0.0, 0.0, 0.05, 0.0, 0.05, 'EUR', '{}', "
"NULL, 0, :now)"
),
{"ps": t0.strftime("%Y-%m-%dT%H:%M:%S"), "now": now.strftime("%Y-%m-%dT%H:%M:%S")},
)
session.execute(
text(
"INSERT INTO energy_cost_period "
"(period_start, d1_kwh, d2_kwh, r1_kwh, r2_kwh, "
"import_cost, export_revenue, net_cost, currency, pricing, "
"contract_version_id, degraded, computed_at) "
"VALUES (:ps, 0.1, 0.2, 0.0, 0.0, 0.05, 0.0, 0.05, 'EUR', '{}', "
"NULL, 0, :now)"
),
{"ps": t1.strftime("%Y-%m-%dT%H:%M:%S"), "now": now.strftime("%Y-%m-%dT%H:%M:%S")},
)
session.commit()
engine.dispose()
# Now upgrade to head (applies M7-T01 migration with backfill).
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
with Session(engine) as session:
# Exactly one initial meter must exist.
meter_count = session.execute(
text("SELECT COUNT(*) FROM meter WHERE reason = 'initial' AND ended_at IS NULL")
).scalar()
assert meter_count == 1, f"Expected 1 initial meter, got {meter_count}"
# All non-degraded energy_cost_period rows must have meter_id set.
null_count = session.execute(
text(
"SELECT COUNT(*) FROM energy_cost_period "
"WHERE meter_id IS NULL AND degraded = 0"
)
).scalar()
assert null_count == 0, (
f"Expected 0 non-degraded periods with NULL meter_id, got {null_count}"
)
# The initial meter started_at must match the earliest dsmr_reading.recorded_at.
meter_row = session.execute(
text("SELECT started_at FROM meter WHERE reason = 'initial' LIMIT 1")
).fetchone()
assert meter_row is not None
meter_started = meter_row[0]
# Both meter_started and t0 are now UTC strings / datetimes — just verify
# the date portion matches (second-level precision is sufficient).
assert t0.strftime("%Y-%m-%dT%H:%M:%S") in str(meter_started), (
f"initial meter started_at {meter_started!r} should match earliest reading {t0}"
)
engine.dispose()
def test_migration_backfill_idempotent(tmp_path: Path):
"""Running upgrade head twice must not create duplicate initial meters or corrupt data."""
db_path = tmp_path / "idempotent_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
# Upgrade to pre-meter revision and insert a reading.
command.upgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
now = datetime.now(tz=timezone.utc)
engine = create_engine(db_url, connect_args={"check_same_thread": False})
with Session(engine) as session:
session.execute(
text(
"INSERT INTO dsmr_reading (recorded_at, source_id, payload) "
"VALUES (:ts, NULL, '{}')"
),
{"ts": now.strftime("%Y-%m-%dT%H:%M:%S")},
)
session.commit()
engine.dispose()
# First upgrade to head.
command.upgrade(alembic_cfg, "head")
# Downgrade and re-upgrade to simulate idempotency (tests the guard condition).
command.downgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
with Session(engine) as session:
meter_count = session.execute(
text("SELECT COUNT(*) FROM meter WHERE reason = 'initial' AND ended_at IS NULL")
).scalar()
assert meter_count == 1, (
f"Expected exactly 1 initial meter after repeated upgrade, got {meter_count}"
)
engine.dispose()
def test_migration_downgrade_removes_meter_table(tmp_path: Path):
"""Downgrading from M7-T01 must remove the meter table and the meter_id column."""
db_path = tmp_path / "downgrade_meter_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
inspector = inspect(engine)
assert "meter" in inspector.get_table_names(), "meter must exist before downgrade"
ecp_cols = {col["name"] for col in inspector.get_columns("energy_cost_period")}
assert "meter_id" in ecp_cols, "meter_id must exist in energy_cost_period before downgrade"
engine.dispose()
# Downgrade one step (removes the meter table + meter_id column).
command.downgrade(alembic_cfg, "20260624_12_dsmr_decouple_telegram_id")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
inspector = inspect(engine)
assert "meter" not in inspector.get_table_names(), "meter must be gone after downgrade"
ecp_cols_after = {col["name"] for col in inspector.get_columns("energy_cost_period")}
assert "meter_id" not in ecp_cols_after, (
"meter_id must be removed from energy_cost_period after downgrade"
)
engine.dispose()
+602
View File
@@ -0,0 +1,602 @@
"""Tests for M7-T02: Meter service layer.
Coverage
--------
1. ``meter_at`` half-open interval boundary semantics.
2. ``declare_meter`` first declaration (no active meter), normal swap, backdate rejection.
3. Mutual exclusion each commodity has at most one active meter after swaps.
4. Interval continuity old meter's ended_at == new meter's started_at after swap.
5. Different commodities are independent (electricity swap doesn't touch gas meters).
6. ``list_meters`` ordering and commodity filtering.
7. ``update_meter`` label/note update, started_at retroactive correction with
interval consistency, and validation errors.
8. ``update_meter`` first-meter (no previous) retroactive started_at change.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, event as sa_event
from sqlalchemy.orm import Session
from app.models.energy import Meter
from app.services.meters import (
MeterIntervalError,
MeterOverlapError,
declare_meter,
list_meters,
meter_at,
update_meter,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_app_alembic_config(database_url: str) -> Config:
cfg = Config("alembic_app.ini")
cfg.set_main_option("sqlalchemy.url", database_url)
return cfg
def _engine_with_fk(db_url: str):
"""Create a SQLAlchemy engine with SQLite FK enforcement enabled."""
engine = create_engine(db_url, connect_args={"check_same_thread": False})
@sa_event.listens_for(engine, "connect")
def _enable_fk(dbapi_conn, _rec):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys = ON")
cursor.close()
return engine
@pytest.fixture()
def meter_db(tmp_path: Path):
"""Temporary SQLite DB upgraded to the current Alembic head with FK enforcement."""
db_path = tmp_path / "meter_service_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = _engine_with_fk(db_url)
yield engine
engine.dispose()
@pytest.fixture()
def session(meter_db):
"""Provide a single SQLAlchemy session for a test, auto-rolling back on exit."""
with Session(meter_db) as s:
yield s
# Tests that commit explicitly are fine; for read-only tests the context
# manager handles cleanup.
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_T0 = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) # base timestamp for tests
def _make_meter(
session: Session,
*,
started_at: datetime,
ended_at: datetime | None = None,
label: str = "Test meter",
commodity: str = "electricity",
reason: str = "initial",
note: str | None = None,
) -> Meter:
"""Insert a Meter row directly (bypassing service logic) for test setup."""
m = Meter(
label=label,
commodity=commodity,
started_at=started_at,
ended_at=ended_at,
reason=reason,
note=note,
created_at=datetime.now(UTC),
)
session.add(m)
session.flush()
return m
# ---------------------------------------------------------------------------
# 1. meter_at — half-open interval semantics
# ---------------------------------------------------------------------------
class TestMeterAt:
def test_returns_none_when_no_meters(self, session: Session):
"""meter_at must return None when the table is empty."""
assert meter_at(session, _T0) is None
def test_returns_active_meter_for_ts_after_start(self, session: Session):
"""An active meter (ended_at IS NULL) covers any ts ≥ started_at."""
m = _make_meter(session, started_at=_T0, ended_at=None)
result = meter_at(session, _T0 + timedelta(hours=1))
assert result is not None
assert result.id == m.id
def test_exact_started_at_is_inclusive(self, session: Session):
"""ts == started_at must be covered by that meter (left-closed boundary)."""
m = _make_meter(session, started_at=_T0, ended_at=None)
result = meter_at(session, _T0)
assert result is not None
assert result.id == m.id
def test_ts_before_started_at_returns_none(self, session: Session):
"""ts < started_at must not be covered."""
_make_meter(session, started_at=_T0, ended_at=None)
result = meter_at(session, _T0 - timedelta(seconds=1))
assert result is None
def test_exact_ended_at_is_exclusive(self, session: Session):
"""ts == ended_at must NOT be covered by the closed meter (right-open boundary)."""
t1 = _T0 + timedelta(hours=2)
_make_meter(session, started_at=_T0, ended_at=t1, label="Old meter")
# The new active meter starts exactly at t1.
new = _make_meter(session, started_at=t1, ended_at=None, label="New meter")
result = meter_at(session, t1)
assert result is not None
assert result.id == new.id
def test_ts_just_before_ended_at_is_covered(self, session: Session):
"""ts just before ended_at must still be covered by the closing meter."""
t1 = _T0 + timedelta(hours=2)
m = _make_meter(session, started_at=_T0, ended_at=t1)
result = meter_at(session, t1 - timedelta(seconds=1))
assert result is not None
assert result.id == m.id
def test_commodity_filter(self, session: Session):
"""meter_at must only return the meter for the requested commodity."""
m_elec = _make_meter(session, started_at=_T0, commodity="electricity")
_make_meter(session, started_at=_T0, commodity="gas")
result = meter_at(session, _T0, commodity="electricity")
assert result is not None
assert result.id == m_elec.id
result_gas = meter_at(session, _T0, commodity="gas")
assert result_gas is not None
assert result_gas.commodity == "gas"
def test_no_meter_for_unknown_commodity(self, session: Session):
"""meter_at returns None when no meter exists for the requested commodity."""
_make_meter(session, started_at=_T0, commodity="electricity")
assert meter_at(session, _T0, commodity="heating") is None
def test_two_contiguous_epochs_correct_routing(self, session: Session):
"""With two contiguous meters, meter_at routes each ts to the correct epoch."""
t1 = _T0 + timedelta(hours=3)
m0 = _make_meter(session, started_at=_T0, ended_at=t1, label="Meter 0")
m1 = _make_meter(session, started_at=t1, ended_at=None, label="Meter 1")
# ts in first epoch
assert meter_at(session, _T0 + timedelta(hours=1)).id == m0.id
# ts exactly at boundary → second epoch
assert meter_at(session, t1).id == m1.id
# ts in second epoch
assert meter_at(session, t1 + timedelta(hours=1)).id == m1.id
def test_equal_started_at_swap_meter_at_still_returns_new_active(self, session: Session):
"""Regression: after equal-timestamp swap, meter_at must return the new active meter.
When declare_meter is called with started_at == active.started_at (the
"equal-timestamp replace" allowed by §3.5), the old meter becomes a
zero-width epoch [T0, T0). A previous bug caused meter_at to select
the zero-width row first (same started_at, lower rowid) and then fail
the upper-bound check, returning None for *any* ts >= T0. This test
pins the correct behaviour: meter_at(T0) and meter_at(T0+δ) must both
return the new active meter, not None.
"""
# Declare first meter at T0.
declare_meter(session, label="M1 (original)", started_at=_T0, reason="initial")
session.commit()
# Declare second meter at *the same* T0 — equal-timestamp swap.
new_m = declare_meter(
session, label="M2 (replacement)", started_at=_T0, reason="meter_swap"
)
session.commit()
# meter_at at exactly T0 must return the new active meter.
result_at_T0 = meter_at(session, _T0)
assert result_at_T0 is not None, (
"meter_at(T0) returned None after equal-timestamp swap; "
"the new active meter should cover T0"
)
assert result_at_T0.id == new_m.id, (
f"meter_at(T0) returned meter id={result_at_T0.id} (label={result_at_T0.label!r}), "
f"expected id={new_m.id} (the new active meter)"
)
# meter_at slightly after T0 must also return the new active meter.
result_after_T0 = meter_at(session, _T0 + timedelta(seconds=1))
assert result_after_T0 is not None, (
"meter_at(T0+1s) returned None after equal-timestamp swap"
)
assert result_after_T0.id == new_m.id, (
f"meter_at(T0+1s) returned meter id={result_after_T0.id}, "
f"expected id={new_m.id} (the new active meter)"
)
# ---------------------------------------------------------------------------
# 2 & 3. declare_meter — first declaration, swap, mutual exclusion
# ---------------------------------------------------------------------------
class TestDeclareMeter:
def test_first_declaration_no_active(self, session: Session):
"""Declaring the first meter must create an active meter with ended_at IS NULL."""
m = declare_meter(
session,
label="Initial meter",
started_at=_T0,
reason="initial",
)
session.commit()
fetched = session.get(Meter, m.id)
assert fetched is not None
assert fetched.ended_at is None
assert fetched.commodity == "electricity"
assert fetched.reason == "initial"
def test_swap_closes_old_meter(self, session: Session):
"""Declaring a second meter must close the previous active meter."""
first = declare_meter(
session, label="First meter", started_at=_T0, reason="initial"
)
session.commit()
first_id = first.id
t1 = _T0 + timedelta(days=30)
second = declare_meter(
session, label="Second meter", started_at=t1, reason="meter_swap"
)
session.commit()
# First meter must now be closed at exactly t1.
closed = session.get(Meter, first_id)
assert closed.ended_at is not None
from app.services.meters import _as_utc
assert _as_utc(closed.ended_at) == _as_utc(t1)
# Second meter must be active.
assert second.ended_at is None
def test_swap_interval_contiguous(self, session: Session):
"""Old meter's ended_at must exactly equal new meter's started_at after swap."""
declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
t1 = _T0 + timedelta(days=10)
new_m = declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
session.commit()
# Query the old (now-closed) meter.
meters = list_meters(session, commodity="electricity")
assert len(meters) == 2
old_m = next(m for m in meters if m.id != new_m.id)
from app.services.meters import _as_utc
assert _as_utc(old_m.ended_at) == _as_utc(new_m.started_at), (
"Timeline gap or overlap: old ended_at must == new started_at"
)
def test_at_most_one_active_per_commodity_after_multiple_swaps(self, session: Session):
"""After N swaps, exactly one meter per commodity must be active."""
declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
declare_meter(
session, label="M2", started_at=_T0 + timedelta(days=10), reason="meter_swap"
)
session.commit()
declare_meter(
session, label="M3", started_at=_T0 + timedelta(days=20), reason="meter_swap"
)
session.commit()
active_meters = [m for m in list_meters(session, commodity="electricity") if m.ended_at is None]
assert len(active_meters) == 1, f"Expected 1 active meter, got {len(active_meters)}"
def test_backdate_rejected(self, session: Session):
"""started_at strictly before active meter's started_at must raise MeterOverlapError."""
declare_meter(session, label="Current", started_at=_T0, reason="initial")
session.commit()
with pytest.raises(MeterOverlapError):
declare_meter(
session,
label="Too early",
started_at=_T0 - timedelta(hours=1),
reason="meter_swap",
)
def test_backdate_raises_before_any_db_write(self, session: Session):
"""When backdate is rejected, no new meter row must be written."""
declare_meter(session, label="Current", started_at=_T0, reason="initial")
session.commit()
meters_before = list_meters(session, commodity="electricity")
count_before = len(meters_before)
with pytest.raises(MeterOverlapError):
declare_meter(
session,
label="Bad meter",
started_at=_T0 - timedelta(minutes=5),
reason="meter_swap",
)
# Rollback the failed operation explicitly (simulating what the caller would do).
session.rollback()
# Re-open session to verify state.
with Session(session.get_bind()) as s2:
meters_after = list_meters(s2, commodity="electricity")
assert len(meters_after) == count_before, (
"No extra meter must be written when backdate is rejected"
)
def test_equal_started_at_allowed(self, session: Session):
"""started_at == active meter's started_at must NOT raise (equal is allowed)."""
declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
# Should not raise — equal timestamps are valid (replaces meter at same instant).
new_m = declare_meter(
session, label="M2", started_at=_T0, reason="meter_swap"
)
session.commit()
assert new_m.ended_at is None
def test_note_and_commodity_stored(self, session: Session):
"""declare_meter must persist note and commodity correctly."""
m = declare_meter(
session,
label="Gas meter",
started_at=_T0,
reason="initial",
commodity="gas",
note="Rotameter serial XYZ",
)
session.commit()
fetched = session.get(Meter, m.id)
assert fetched.commodity == "gas"
assert fetched.note == "Rotameter serial XYZ"
# ---------------------------------------------------------------------------
# 5. Different commodities are independent
# ---------------------------------------------------------------------------
class TestCommodityIsolation:
def test_electricity_swap_does_not_affect_gas_meter(self, session: Session):
"""Swapping the electricity meter must not touch the gas meter's active state."""
declare_meter(session, label="Elec 1", started_at=_T0, reason="initial", commodity="electricity")
declare_meter(session, label="Gas 1", started_at=_T0, reason="initial", commodity="gas")
session.commit()
declare_meter(
session,
label="Elec 2",
started_at=_T0 + timedelta(days=5),
reason="meter_swap",
commodity="electricity",
)
session.commit()
# Gas meter must still be active.
gas_meters = list_meters(session, commodity="gas")
active_gas = [m for m in gas_meters if m.ended_at is None]
assert len(active_gas) == 1, "Gas meter must remain active after electricity swap"
assert active_gas[0].label == "Gas 1"
# Electricity: exactly one active.
elec_meters = list_meters(session, commodity="electricity")
active_elec = [m for m in elec_meters if m.ended_at is None]
assert len(active_elec) == 1
assert active_elec[0].label == "Elec 2"
def test_different_commodity_backdate_is_independent(self, session: Session):
"""Backdate validation is per-commodity: gas meter start does not constrain electricity."""
# Declare gas meter at a later time.
declare_meter(
session, label="Gas 1", started_at=_T0 + timedelta(days=10), reason="initial", commodity="gas"
)
session.commit()
# Declaring an electricity meter at an earlier time must succeed (no gas constraint).
m = declare_meter(
session, label="Elec 1", started_at=_T0, reason="initial", commodity="electricity"
)
session.commit()
assert m is not None
# ---------------------------------------------------------------------------
# 6. list_meters
# ---------------------------------------------------------------------------
class TestListMeters:
def test_returns_empty_list_when_no_meters(self, session: Session):
assert list_meters(session) == []
def test_ordered_by_started_at_asc(self, session: Session):
"""list_meters must return meters in ascending started_at order."""
t1 = _T0 + timedelta(days=10)
t2 = _T0 + timedelta(days=20)
declare_meter(session, label="M1", started_at=_T0, reason="initial")
session.commit()
declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
session.commit()
declare_meter(session, label="M3", started_at=t2, reason="meter_swap")
session.commit()
meters = list_meters(session, commodity="electricity")
assert [m.label for m in meters] == ["M1", "M2", "M3"]
def test_commodity_filter_returns_only_matching(self, session: Session):
"""list_meters with commodity kwarg must filter correctly."""
declare_meter(session, label="Elec", started_at=_T0, reason="initial", commodity="electricity")
declare_meter(session, label="Gas", started_at=_T0, reason="initial", commodity="gas")
session.commit()
elec = list_meters(session, commodity="electricity")
gas = list_meters(session, commodity="gas")
all_meters = list_meters(session)
assert len(elec) == 1 and elec[0].commodity == "electricity"
assert len(gas) == 1 and gas[0].commodity == "gas"
assert len(all_meters) == 2
# ---------------------------------------------------------------------------
# 7 & 8. update_meter
# ---------------------------------------------------------------------------
class TestUpdateMeter:
def test_update_label(self, session: Session):
"""update_meter must update label without touching other fields."""
m = _make_meter(session, started_at=_T0)
update_meter(session, m, label="New label")
session.commit()
fetched = session.get(Meter, m.id)
assert fetched.label == "New label"
assert fetched.note is None # unchanged
def test_update_note(self, session: Session):
"""update_meter must update note without touching other fields."""
m = _make_meter(session, started_at=_T0)
update_meter(session, m, note="Some note")
session.commit()
fetched = session.get(Meter, m.id)
assert fetched.note == "Some note"
assert fetched.label == "Test meter" # unchanged
def test_update_label_and_note_simultaneously(self, session: Session):
"""update_meter must update both label and note in a single call."""
m = _make_meter(session, started_at=_T0)
update_meter(session, m, label="Updated label", note="Updated note")
session.commit()
fetched = session.get(Meter, m.id)
assert fetched.label == "Updated label"
assert fetched.note == "Updated note"
def test_update_started_at_first_meter(self, session: Session):
"""Changing started_at of the first (only) active meter must work without a previous meter."""
m = _make_meter(session, started_at=_T0, ended_at=None)
new_start = _T0 + timedelta(hours=2)
update_meter(session, m, started_at=new_start)
session.commit()
fetched = session.get(Meter, m.id)
from app.services.meters import _as_utc
assert _as_utc(fetched.started_at) == _as_utc(new_start)
def test_update_started_at_updates_previous_ended_at(self, session: Session):
"""Retroactive started_at change must propagate to the previous meter's ended_at."""
t1 = _T0 + timedelta(days=10)
prev = _make_meter(session, started_at=_T0, ended_at=t1, label="Prev meter")
curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr meter")
session.commit()
new_start = _T0 + timedelta(days=7) # shift boundary 3 days earlier
update_meter(session, curr, started_at=new_start)
session.commit()
from app.services.meters import _as_utc
fetched_prev = session.get(Meter, prev.id)
fetched_curr = session.get(Meter, curr.id)
# The previous meter's ended_at must now equal the new started_at.
assert _as_utc(fetched_prev.ended_at) == _as_utc(new_start), (
"Previous meter's ended_at must be updated to maintain continuity"
)
# The current meter's started_at must reflect the change.
assert _as_utc(fetched_curr.started_at) == _as_utc(new_start)
def test_update_started_at_continuity_maintained(self, session: Session):
"""After retroactive started_at change, prev.ended_at == curr.started_at (no gap)."""
t1 = _T0 + timedelta(days=10)
prev = _make_meter(session, started_at=_T0, ended_at=t1, label="Prev")
curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr")
session.commit()
new_start = _T0 + timedelta(days=12) # shift boundary 2 days later
update_meter(session, curr, started_at=new_start)
session.commit()
from app.services.meters import _as_utc
fetched_prev = session.get(Meter, prev.id)
fetched_curr = session.get(Meter, curr.id)
assert _as_utc(fetched_prev.ended_at) == _as_utc(fetched_curr.started_at), (
"Timeline must remain contiguous after retroactive started_at shift"
)
def test_update_started_at_rejects_at_or_past_ended_at(self, session: Session):
"""New started_at ≥ ended_at must raise MeterIntervalError (empty/inverted epoch)."""
t1 = _T0 + timedelta(days=10)
m = _make_meter(session, started_at=_T0, ended_at=t1)
with pytest.raises(MeterIntervalError):
update_meter(session, m, started_at=t1) # == ended_at → empty epoch
with pytest.raises(MeterIntervalError):
update_meter(session, m, started_at=t1 + timedelta(hours=1)) # > ended_at
def test_update_started_at_rejects_at_or_before_prev_started_at(self, session: Session):
"""New started_at ≤ prev.started_at must raise MeterIntervalError."""
t1 = _T0 + timedelta(days=10)
_make_meter(session, started_at=_T0, ended_at=t1, label="Prev")
curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr")
session.commit()
with pytest.raises(MeterIntervalError):
# exactly at prev.started_at — would collapse prev epoch to zero
update_meter(session, curr, started_at=_T0)
with pytest.raises(MeterIntervalError):
# strictly before prev.started_at — inverts prev epoch
update_meter(session, curr, started_at=_T0 - timedelta(hours=1))
def test_noop_call_does_not_raise(self, session: Session):
"""Calling update_meter with all None args must succeed without error."""
m = _make_meter(session, started_at=_T0)
result = update_meter(session, m)
assert result is m
def test_update_started_at_no_previous_meter_shift_backward(self, session: Session):
"""For the first meter (no previous), shifting started_at backward must succeed."""
m = _make_meter(session, started_at=_T0, ended_at=None)
earlier = _T0 - timedelta(days=5)
update_meter(session, m, started_at=earlier)
session.commit()
from app.services.meters import _as_utc
fetched = session.get(Meter, m.id)
assert _as_utc(fetched.started_at) == _as_utc(earlier)