Compare commits
22
Commits
96e85fb48e
...
v1.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b65f700d56 | ||
|
|
f4cea3874b | ||
|
|
134f0abb5f | ||
|
|
3e04b15656 | ||
|
|
f2e8f6a8e7 | ||
|
|
90a03e7fd6 | ||
|
|
d3fc90b320 | ||
|
|
d4acfc438a | ||
|
|
c26160b10b | ||
|
|
efbe36d7c0 | ||
|
|
f663981cdb | ||
|
|
da05fd2f09 | ||
|
|
188168b16a | ||
|
|
f1e7ce3133 | ||
|
|
e3d0d17cac | ||
|
|
33f5de0591 | ||
|
|
839faaf95b | ||
|
|
82bb329500 | ||
|
|
17057ed41e | ||
|
|
227b146c2c | ||
|
|
32a20785ee | ||
|
|
2544514f52 |
@@ -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")
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""add uuid column to meter table
|
||||||
|
|
||||||
|
Adds a stable ``uuid`` (UUID v4 string) column to the ``meter`` table so that
|
||||||
|
each meter epoch has a durable identity anchor suitable for use as an HA
|
||||||
|
Discovery ``unique_id``.
|
||||||
|
|
||||||
|
**Migration strategy (SQLite-safe)**:
|
||||||
|
|
||||||
|
SQLite does not support adding a NOT NULL + UNIQUE column to a non-empty table
|
||||||
|
in a single ``ALTER TABLE ADD COLUMN`` statement (adding a NOT NULL column
|
||||||
|
without a default value is rejected if the table already has rows). The
|
||||||
|
safe approach used here is:
|
||||||
|
|
||||||
|
1. Add ``uuid`` as a **nullable** column (SQLite allows this).
|
||||||
|
2. **Back-fill** every existing ``meter`` row with a distinct ``str(uuid4())``
|
||||||
|
value. Each row gets its *own* random UUID — not a shared value — so the
|
||||||
|
subsequent UNIQUE constraint is satisfied.
|
||||||
|
3. Use ``batch_alter_table`` (which re-creates the table under the hood in
|
||||||
|
SQLite) to alter the column to ``NOT NULL`` and add a UNIQUE constraint.
|
||||||
|
|
||||||
|
**Idempotency**: only rows where ``uuid IS NULL`` are back-filled; rows that
|
||||||
|
already have a uuid (e.g. from a repeated upgrade after a partial failure) are
|
||||||
|
left untouched.
|
||||||
|
|
||||||
|
**Audit**: after back-fill, the count of rows with ``uuid IS NULL`` must be
|
||||||
|
exactly zero; if not, the migration raises ``RuntimeError`` and rolls back.
|
||||||
|
|
||||||
|
**Data safety**: this migration is additive only — no existing rows are deleted
|
||||||
|
or overwritten; it only adds a new column and fills it in.
|
||||||
|
|
||||||
|
Revision ID: 20260625_14_meter_uuid
|
||||||
|
Revises: 20260625_13_meter_table
|
||||||
|
Create Date: 2026-06-25 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260625_14_meter_uuid"
|
||||||
|
down_revision: Union[str, None] = "20260625_13_meter_table"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 1. Add uuid as a nullable column. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
with op.batch_alter_table("meter", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column("uuid", sa.String(length=36), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 2. Back-fill: assign a distinct UUID to every row that has #
|
||||||
|
# uuid IS NULL. Each row gets its own random value so that the #
|
||||||
|
# subsequent UNIQUE constraint is satisfied. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
rows = conn.execute(sa.text("SELECT id FROM meter WHERE uuid IS NULL")).fetchall()
|
||||||
|
for (meter_id,) in rows:
|
||||||
|
new_uuid = str(_uuid.uuid4())
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE meter SET uuid = :uuid WHERE id = :mid"),
|
||||||
|
{"uuid": new_uuid, "mid": meter_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 3. Audit: verify no rows remain with uuid IS NULL. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
null_count_row = conn.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM meter WHERE uuid IS NULL")
|
||||||
|
).fetchone()
|
||||||
|
null_count: int = null_count_row[0] if null_count_row else 0
|
||||||
|
|
||||||
|
if null_count != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"meter.uuid back-fill audit failed: {null_count} meter row(s) still have "
|
||||||
|
"uuid IS NULL after back-fill. Migration aborted to protect data integrity."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 4. Alter column to NOT NULL + UNIQUE (requires batch on SQLite). #
|
||||||
|
# batch_alter_table re-creates the table, so the UNIQUE constraint #
|
||||||
|
# and NOT NULL are applied atomically. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
with op.batch_alter_table("meter", schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"uuid",
|
||||||
|
existing_type=sa.String(length=36),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
batch_op.create_unique_constraint("uq_meter_uuid", ["uuid"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop the UNIQUE constraint and the uuid column (batch on SQLite).
|
||||||
|
with op.batch_alter_table("meter", schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint("uq_meter_uuid", type_="unique")
|
||||||
|
batch_op.drop_column("uuid")
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""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 _trigger_discovery_republish(session: Session) -> None:
|
||||||
|
"""Call publish_discovery after a meter write operation (best-effort).
|
||||||
|
|
||||||
|
No-op if MQTT / discovery is not enabled or the broker is not connected
|
||||||
|
(publish_discovery guards internally). All errors are swallowed so that a
|
||||||
|
discovery failure never breaks the API response.
|
||||||
|
|
||||||
|
Must be called **after** db.commit() so that publish_discovery sees the
|
||||||
|
final committed state of the meter table when it rebuilds the catalog.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.services.ha_discovery import publish_discovery
|
||||||
|
|
||||||
|
publish_discovery(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_trigger_discovery_republish: publish_discovery raised an error")
|
||||||
|
|
||||||
|
|
||||||
|
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 recompute is transparent — the
|
||||||
|
response body is the created meter (``MeterResponse``) only and does **not**
|
||||||
|
include a recompute count; callers should re-fetch costs if they need the
|
||||||
|
updated totals.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Trigger HA discovery re-publish so the new active meter's energy-cost
|
||||||
|
# device/sensor configuration is pushed to Home Assistant. Best-effort:
|
||||||
|
# failures are logged and swallowed; the API response is not affected.
|
||||||
|
_trigger_discovery_republish(db)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Trigger HA discovery re-publish so label renames on the active meter
|
||||||
|
# propagate to the HA device name. Best-effort: failures are logged and
|
||||||
|
# swallowed; the API response is not affected.
|
||||||
|
_trigger_discovery_republish(db)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -488,6 +488,7 @@ def test_read(
|
|||||||
device.port,
|
device.port,
|
||||||
device.unit_id,
|
device.unit_id,
|
||||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||||
|
function_code=profile.function_code,
|
||||||
)
|
)
|
||||||
payload: dict[str, Any] = decode_profile(profile, registers)
|
payload: dict[str, Any] = decode_profile(profile, registers)
|
||||||
return ModbusTestReadResponse(ok=True, payload=payload)
|
return ModbusTestReadResponse(ok=True, payload=payload)
|
||||||
|
|||||||
+127
-67
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Any, Callable, Optional, Protocol
|
from typing import Any, Callable, Optional, Protocol
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -369,16 +370,44 @@ register_provider(_modbus_provider)
|
|||||||
# Energy Cost provider
|
# Energy Cost provider
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# *_today 当日窗口翻天的宽限:本地午夜后 5 秒才切到新的一天,避免在 00:00:0x 把
|
||||||
|
# 归零后的值发出去、被慢几秒的 HA 钟记成前一天的 23:59:59(归错小时桶)。
|
||||||
|
_TODAY_RESET_GRACE = timedelta(seconds=5)
|
||||||
|
|
||||||
|
|
||||||
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
||||||
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
"""Enumerate ExposableEntity objects for the energy cost subsystem.
|
||||||
|
|
||||||
Produces 4 sensor entities grouped under a single HA device "Energy Cost":
|
Produces 6 sensor entities grouped under a single HA device whose identity
|
||||||
|
is anchored to the **current active electricity meter**:
|
||||||
|
|
||||||
- ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
|
- ``buy_price_now`` — current effective buy price (EUR/kWh or local currency).
|
||||||
- ``sell_price_now`` — current effective sell price (EUR/kWh or local currency).
|
- ``sell_price_now`` — current effective sell price (EUR/kWh or local currency).
|
||||||
- ``import_cost_total`` — cumulative import cost (total_increasing, monetary).
|
- ``import_cost_total`` — cumulative import cost (total, monetary).
|
||||||
- ``export_revenue_total`` — cumulative export revenue (total_increasing, monetary).
|
- ``export_revenue_total`` — cumulative export revenue (total, monetary).
|
||||||
|
- ``import_cost_today`` — today's import cost (total_increasing, monetary).
|
||||||
|
- ``export_revenue_today`` — today's export revenue (total_increasing, monetary).
|
||||||
|
|
||||||
|
Active meter requirement
|
||||||
|
------------------------
|
||||||
|
**If no active electricity meter exists, the provider returns ``[]``.**
|
||||||
|
No energy-cost entities are exposed to HA until a meter has been declared.
|
||||||
|
This prevents spurious sensor creation with an undefined device identity.
|
||||||
|
|
||||||
|
HA device identity (换表 → 新 sensor)
|
||||||
|
--------------------------------------
|
||||||
|
``identifiers[1]`` is set to the active meter's **uuid** (not the fixed
|
||||||
|
string ``"energy-cost"``). ``ha_discovery.py`` uses ``identifiers[1]`` as
|
||||||
|
the MQTT node_id and as part of the ``unique_id`` for every entity.
|
||||||
|
Declaring a new active electricity meter produces a new uuid → new node_id /
|
||||||
|
unique_id → HA creates a brand-new sensor, cleanly isolating post-swap data.
|
||||||
|
|
||||||
|
Entity key stability
|
||||||
|
--------------------
|
||||||
|
Entity keys remain the fixed stable strings (``"energy.buy_price_now"`` etc.),
|
||||||
|
**not** derived from the meter uuid. The ``exposed_entity_toggle`` table uses
|
||||||
|
keys as its primary handle; keeping them stable means toggled-on entities stay
|
||||||
|
enabled after a meter swap without requiring the user to re-tick them.
|
||||||
|
|
||||||
Current-price algorithm (source-agnostic, with fallback)
|
Current-price algorithm (source-agnostic, with fallback)
|
||||||
---------------------------------------------------------
|
---------------------------------------------------------
|
||||||
@@ -399,8 +428,9 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
Cumulative totals
|
Cumulative totals
|
||||||
-----------------
|
-----------------
|
||||||
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
|
``SUM(import_cost)`` and ``SUM(export_revenue)`` over **all non-degraded**
|
||||||
``energy_cost_period`` rows. Degraded rows carry 0 costs and are excluded
|
``energy_cost_period`` rows within the current meter's window. Degraded rows
|
||||||
to avoid double-counting when they are later overwritten by real values.
|
carry 0 costs and are excluded to avoid double-counting when they are later
|
||||||
|
overwritten by real values.
|
||||||
|
|
||||||
Currency
|
Currency
|
||||||
--------
|
--------
|
||||||
@@ -414,16 +444,37 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
- ``"energy.sell_price_now"``
|
- ``"energy.sell_price_now"``
|
||||||
- ``"energy.import_cost_total"``
|
- ``"energy.import_cost_total"``
|
||||||
- ``"energy.export_revenue_total"``
|
- ``"energy.export_revenue_total"``
|
||||||
|
- ``"energy.import_cost_today"``
|
||||||
|
- ``"energy.export_revenue_today"``
|
||||||
|
|
||||||
DeviceInfo identifiers
|
DeviceInfo identifiers
|
||||||
----------------------
|
----------------------
|
||||||
**Two-element tuple** ``("energy-cost", "energy-cost")`` so that
|
**Two-element tuple** ``("energy-cost", meter.uuid)`` so that
|
||||||
``ha_discovery.py``'s ``entity.device.identifiers[1]`` is always valid
|
``ha_discovery.py``'s ``entity.device.identifiers[1]`` resolves to the
|
||||||
(the service uses index [1] as the node_id throughout).
|
meter uuid (used as the MQTT node_id and unique_id seed throughout).
|
||||||
"""
|
"""
|
||||||
from app.models.energy import EnergyCostPeriod # local import to avoid circular
|
from app.models.energy import EnergyCostPeriod, Meter # local import to avoid circular
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
# --- Determine currency and representative pricing from the latest non-degraded row ---
|
# --- Require an active electricity meter; return [] if none exists ---
|
||||||
|
# Using an inline query (ended_at IS NULL) rather than a service-layer helper
|
||||||
|
# to avoid a new public dependency and remain consistent with the value_getter
|
||||||
|
# implementations below (which use the same inline pattern).
|
||||||
|
active_meter: Meter | None = session.execute(
|
||||||
|
select(Meter)
|
||||||
|
.where(
|
||||||
|
Meter.commodity == "electricity",
|
||||||
|
Meter.ended_at.is_(None),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if active_meter is None:
|
||||||
|
# No active electricity meter → do not expose any energy-cost entities.
|
||||||
|
# HA will not see these sensors until a meter is declared.
|
||||||
|
return []
|
||||||
|
|
||||||
|
# --- Determine currency from the latest non-degraded row ---
|
||||||
|
|
||||||
latest_period: EnergyCostPeriod | None = (
|
latest_period: EnergyCostPeriod | None = (
|
||||||
session.query(EnergyCostPeriod)
|
session.query(EnergyCostPeriod)
|
||||||
@@ -436,13 +487,15 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
if latest_period is not None and latest_period.currency:
|
if latest_period is not None and latest_period.currency:
|
||||||
currency = latest_period.currency
|
currency = latest_period.currency
|
||||||
|
|
||||||
# --- Shared DeviceInfo (2-element identifiers — required by ha_discovery.py [1] access) ---
|
# --- Shared DeviceInfo anchored to the active meter's uuid ---
|
||||||
|
# identifiers[1] = meter.uuid drives the MQTT node_id and unique_id in
|
||||||
|
# ha_discovery.py. Swapping the meter produces a new uuid → new HA sensor.
|
||||||
# provides_availability=False: the energy-cost device has only sensors and no
|
# provides_availability=False: the energy-cost device has only sensors and no
|
||||||
# online/offline heartbeat, so its entities must be "always available" in HA.
|
# online/offline heartbeat, so its entities must be "always available" in HA.
|
||||||
# (Otherwise HA shows them unavailable despite state being published.)
|
# (Otherwise HA shows them unavailable despite state being published.)
|
||||||
device_info = DeviceInfo(
|
device_info = DeviceInfo(
|
||||||
identifiers=("energy-cost", "energy-cost"),
|
identifiers=("energy-cost", active_meter.uuid),
|
||||||
name="Energy Cost",
|
name=active_meter.label,
|
||||||
provides_availability=False,
|
provides_availability=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -549,53 +602,57 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
def _make_import_cost_getter() -> Callable[["Session"], Any]:
|
||||||
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
"""Return a getter for the cumulative import cost plus prorated standing charges.
|
||||||
|
|
||||||
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``,
|
D2 (M7): anchor = current active electricity meter's ``started_at``.
|
||||||
where ``anchor_utc`` is the **max** of:
|
After a meter swap the cumulative resets to zero for the new meter —
|
||||||
- the earliest version's effective_from (contract billing start), and
|
old-meter periods have ``period_start < new_meter.started_at`` and fall
|
||||||
- the earliest non-degraded EnergyCostPeriod.period_start (recording start).
|
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
|
No active electricity meter → returns None (cannot anchor; safer than
|
||||||
(e.g. contract starts Jan 1 but data recording only starts Jun 17 — we do not
|
returning a stale or wrong value). The check is done via an inline
|
||||||
want to include ~€270 of standing charges for a period with no meter data).
|
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
|
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:
|
def _getter(sess: "Session") -> Any:
|
||||||
from datetime import UTC, datetime as _dt
|
from datetime import UTC, datetime as _dt
|
||||||
from app.services.contracts import active_contract_versions
|
from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
|
||||||
from app.services.contracts import _as_utc as _cu
|
|
||||||
from app.services.energy_cost import summarize as _summarize
|
from app.services.energy_cost import summarize as _summarize
|
||||||
from app.models.energy import EnergyCostPeriod as _ECP
|
from sqlalchemy import func as _func, select as _select
|
||||||
from sqlalchemy import func as _func
|
|
||||||
|
|
||||||
# Quick check: any non-degraded period at all? (avoids full summarize overhead
|
# Quick check: any non-degraded period at all?
|
||||||
# when there are zero periods, which should return None)
|
|
||||||
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
|
has_data = sess.query(_func.sum(_ECP.import_cost)).filter(
|
||||||
_ECP.degraded.is_(False)
|
_ECP.degraded.is_(False)
|
||||||
).scalar()
|
).scalar()
|
||||||
if has_data is None:
|
if has_data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
versions = active_contract_versions(sess)
|
# D2: anchor = active electricity meter's started_at.
|
||||||
if not versions:
|
# Inline query (ended_at IS NULL) is more robust than meter_at(now)
|
||||||
# No active contract — return pure SUM(import_cost) without standing charges.
|
# because it avoids the edge case where started_at is in the future.
|
||||||
return float(has_data)
|
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).
|
if active_meter is None:
|
||||||
# This prevents accumulating fixed costs for days with no meter data.
|
# No active electricity meter — cannot anchor; return None.
|
||||||
contract_anchor_utc = _cu(versions[0].effective_from)
|
return None
|
||||||
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
|
|
||||||
|
|
||||||
|
from app.services.contracts import _as_utc as _cu
|
||||||
|
anchor_utc = _cu(active_meter.started_at)
|
||||||
now_utc = _dt.now(UTC)
|
now_utc = _dt.now(UTC)
|
||||||
|
|
||||||
result = _summarize(sess, anchor_utc, now_utc)
|
result = _summarize(sess, anchor_utc, now_utc)
|
||||||
@@ -608,20 +665,18 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
def _make_export_revenue_getter() -> Callable[["Session"], Any]:
|
||||||
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
"""Return a getter for the cumulative export revenue plus prorated tax credit.
|
||||||
|
|
||||||
Principle B/D: delegates entirely to ``summarize(sess, anchor_utc, now_utc)``.
|
D2 (M7): anchor = current active electricity meter's ``started_at``.
|
||||||
Anchor is the same max(contract_start, recording_start) logic as import getter.
|
Same reasoning as the import cost getter — see its docstring.
|
||||||
|
|
||||||
value = summarize(anchor → now).metered_export + summarize(anchor → now).credits
|
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:
|
def _getter(sess: "Session") -> Any:
|
||||||
from datetime import UTC, datetime as _dt
|
from datetime import UTC, datetime as _dt
|
||||||
from app.services.contracts import active_contract_versions
|
from app.models.energy import EnergyCostPeriod as _ECP, Meter as _Meter
|
||||||
from app.services.contracts import _as_utc as _cu
|
|
||||||
from app.services.energy_cost import summarize as _summarize
|
from app.services.energy_cost import summarize as _summarize
|
||||||
from app.models.energy import EnergyCostPeriod as _ECP
|
from sqlalchemy import func as _func, select as _select
|
||||||
from sqlalchemy import func as _func
|
|
||||||
|
|
||||||
# Quick check: any non-degraded period at all?
|
# Quick check: any non-degraded period at all?
|
||||||
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
has_data = sess.query(_func.sum(_ECP.export_revenue)).filter(
|
||||||
@@ -630,22 +685,22 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
if has_data is None:
|
if has_data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
versions = active_contract_versions(sess)
|
# D2: anchor = active electricity meter's started_at.
|
||||||
if not versions:
|
active_meter = sess.execute(
|
||||||
# No active contract — return pure SUM(export_revenue) without credits.
|
_select(_Meter)
|
||||||
return float(has_data)
|
.where(
|
||||||
|
_Meter.commodity == "electricity",
|
||||||
|
_Meter.ended_at.is_(None),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
# anchor = max(contract billing start, earliest recording start).
|
if active_meter is None:
|
||||||
contract_anchor_utc = _cu(versions[0].effective_from)
|
# No active electricity meter — cannot anchor; return None.
|
||||||
earliest_period_start = sess.query(_func.min(_ECP.period_start)).filter(
|
return None
|
||||||
_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
|
|
||||||
|
|
||||||
|
from app.services.contracts import _as_utc as _cu
|
||||||
|
anchor_utc = _cu(active_meter.started_at)
|
||||||
now_utc = _dt.now(UTC)
|
now_utc = _dt.now(UTC)
|
||||||
|
|
||||||
result = _summarize(sess, anchor_utc, now_utc)
|
result = _summarize(sess, anchor_utc, now_utc)
|
||||||
@@ -685,7 +740,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
# Today's window in UTC, using monkeypatch-safe module attribute calls.
|
||||||
today_local = _tz_mod.local_now().date()
|
# Grace: subtract _TODAY_RESET_GRACE so that in the first 5 seconds after
|
||||||
|
# local midnight the getter still returns yesterday's window. This prevents
|
||||||
|
# a "归零后的值" from being published while HA's clock (which may lag a few
|
||||||
|
# seconds) would stamp it as 23:59:59 of the previous day.
|
||||||
|
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
|
||||||
tomorrow_local = today_local + _td(days=1)
|
tomorrow_local = today_local + _td(days=1)
|
||||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||||
@@ -722,7 +781,8 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
|
|||||||
if not versions:
|
if not versions:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
today_local = _tz_mod.local_now().date()
|
# Grace: same logic as import_cost_today — see that getter's comment.
|
||||||
|
today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date()
|
||||||
tomorrow_local = today_local + _td(days=1)
|
tomorrow_local = today_local + _td(days=1)
|
||||||
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
today_start_utc = _tz_mod.local_midnight_utc(today_local)
|
||||||
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Modbus TCP driver — thin wrapper around pymodbus.
|
"""Modbus TCP driver — thin wrapper around pymodbus.
|
||||||
|
|
||||||
This module provides a single public function ``read_blocks`` that performs
|
This module provides a single public function ``read_blocks`` that performs
|
||||||
one or more FC04 (Read Input Registers) block reads against a Modbus TCP
|
one or more block reads against a Modbus TCP gateway — using either FC04
|
||||||
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map.
|
(Read Input Registers) or FC03 (Read Holding Registers), selected per call
|
||||||
|
via the ``function_code`` argument — and returns a flat
|
||||||
|
``dict[register_address -> 16-bit_value]`` map. The function code comes from
|
||||||
|
the device profile (e.g. SDM120 uses FC04, DDSU666 uses FC03).
|
||||||
|
|
||||||
Design decisions
|
Design decisions
|
||||||
----------------
|
----------------
|
||||||
@@ -80,9 +83,10 @@ def read_blocks(
|
|||||||
unit_id: int,
|
unit_id: int,
|
||||||
blocks: Sequence[Block],
|
blocks: Sequence[Block],
|
||||||
*,
|
*,
|
||||||
|
function_code: int = 4,
|
||||||
timeout: float = 3.0,
|
timeout: float = 3.0,
|
||||||
) -> dict[int, int]:
|
) -> dict[int, int]:
|
||||||
"""Read one or more contiguous register blocks via FC04 (input registers).
|
"""Read one or more contiguous register blocks via FC03 or FC04.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -96,6 +100,11 @@ def read_blocks(
|
|||||||
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
Sequence of ``{"start": int, "count": int}`` dicts describing the
|
||||||
contiguous register ranges to read. ``count`` is the number of
|
contiguous register ranges to read. ``count`` is the number of
|
||||||
16-bit registers (not bytes).
|
16-bit registers (not bytes).
|
||||||
|
function_code:
|
||||||
|
Modbus read function code: ``4`` for FC04 (Read Input Registers,
|
||||||
|
default — SDM120) or ``3`` for FC03 (Read Holding Registers —
|
||||||
|
DDSU666 and other devices that expose measurements as holding
|
||||||
|
registers). Comes from the device profile's ``function_code`` field.
|
||||||
timeout:
|
timeout:
|
||||||
TCP connect/read timeout in seconds (default 3 s).
|
TCP connect/read timeout in seconds (default 3 s).
|
||||||
|
|
||||||
@@ -107,12 +116,21 @@ def read_blocks(
|
|||||||
|
|
||||||
Raises
|
Raises
|
||||||
------
|
------
|
||||||
|
ModbusDriverError
|
||||||
|
If ``function_code`` is neither 3 nor 4 (validated before any
|
||||||
|
connection is attempted).
|
||||||
ModbusConnectionError
|
ModbusConnectionError
|
||||||
If the TCP connection to the gateway fails.
|
If the TCP connection to the gateway fails.
|
||||||
ModbusResponseError
|
ModbusResponseError
|
||||||
If the gateway returns a Modbus exception frame or an unexpected
|
If the gateway returns a Modbus exception frame or an unexpected
|
||||||
number of registers.
|
number of registers.
|
||||||
"""
|
"""
|
||||||
|
if function_code not in (3, 4):
|
||||||
|
raise ModbusDriverError(
|
||||||
|
f"Unsupported read function code FC{function_code:02d} "
|
||||||
|
f"(only FC03 holding-register and FC04 input-register reads are supported)"
|
||||||
|
)
|
||||||
|
|
||||||
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
client = ModbusTcpClient(host, port=port, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
connected = client.connect()
|
connected = client.connect()
|
||||||
@@ -125,7 +143,7 @@ def read_blocks(
|
|||||||
for block in blocks:
|
for block in blocks:
|
||||||
start: int = block["start"]
|
start: int = block["start"]
|
||||||
count: int = block["count"]
|
count: int = block["count"]
|
||||||
_read_block(client, unit_id, start, count, registers)
|
_read_block(client, unit_id, start, count, registers, function_code=function_code)
|
||||||
|
|
||||||
return registers
|
return registers
|
||||||
|
|
||||||
@@ -145,9 +163,18 @@ def _read_block(
|
|||||||
start: int,
|
start: int,
|
||||||
count: int,
|
count: int,
|
||||||
result: dict[int, int],
|
result: dict[int, int],
|
||||||
|
*,
|
||||||
|
function_code: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Read one block and merge into *result*. Raises on any error."""
|
"""Read one block and merge into *result*. Raises on any error.
|
||||||
|
|
||||||
|
``function_code`` is assumed already validated to be 3 or 4 by the caller
|
||||||
|
(``read_blocks``); 3 dispatches FC03 (holding) and 4 dispatches FC04 (input).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
if function_code == 3:
|
||||||
|
response = client.read_holding_registers(start, count=count, device_id=unit_id)
|
||||||
|
else: # function_code == 4 (input registers)
|
||||||
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
response = client.read_input_registers(start, count=count, device_id=unit_id)
|
||||||
except ConnectionException as exc:
|
except ConnectionException as exc:
|
||||||
raise ModbusConnectionError(
|
raise ModbusConnectionError(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: ddsu666
|
||||||
|
description: CHINT DDSU666 single-phase smart meter
|
||||||
|
function_code: 3 # holding registers (FC03) — DDSU666 has NO input registers (no FC04)
|
||||||
|
word_order: big # high register first — ASSUMED; verify with a known voltage reading
|
||||||
|
byte_order: big # high byte first within each register (confirmed by manual CRC example)
|
||||||
|
|
||||||
|
# NOTE: the manual gives no worked float-decode example, so word_order is a best-guess
|
||||||
|
# (standard big-endian, high register first, matching sdm120). After wiring the meter,
|
||||||
|
# read 0x2000 (voltage) — it should decode to ~230 V. If it decodes to garbage, the
|
||||||
|
# device uses the opposite word order and this profile (and the decoder) need adjusting.
|
||||||
|
# Byte order is confirmed big-endian from the manual (Appendix A, Table A.4: 0x1388 -> 13 88).
|
||||||
|
|
||||||
|
blocks:
|
||||||
|
# Instantaneous quantities 0x2000–0x200F: voltage, current, P, Q, (rsv), PF, (rsv), Freq.
|
||||||
|
# 16 contiguous registers — single bulk read. (DDSU666 manual Table 9.)
|
||||||
|
- { start: 0x2000, count: 0x0010 }
|
||||||
|
# Active energy — import (0x4000) and export (0x400A) read as two small blocks rather
|
||||||
|
# than one span, to avoid touching the undocumented/reserved 0x4002–0x4009 gap.
|
||||||
|
- { start: 0x4000, count: 0x0002 }
|
||||||
|
- { start: 0x400A, count: 0x0002 }
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
# Addresses are the raw Modbus protocol addresses (hex) from DDSU666 manual Table 9,
|
||||||
|
# read via FC03. Each float32 occupies two consecutive 16-bit registers.
|
||||||
|
|
||||||
|
- key: voltage
|
||||||
|
address: 0x2000 # U — Voltage (V)
|
||||||
|
type: float32
|
||||||
|
unit: "V"
|
||||||
|
device_class: voltage
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: current
|
||||||
|
address: 0x2002 # I — Current (A)
|
||||||
|
type: float32
|
||||||
|
unit: "A"
|
||||||
|
device_class: current
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: active_power
|
||||||
|
address: 0x2004 # P — Active power. Manual unit is kW (NOT W like sdm120).
|
||||||
|
type: float32
|
||||||
|
unit: "kW"
|
||||||
|
device_class: power
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: reactive_power
|
||||||
|
address: 0x2006 # Q — Reactive power (kvar)
|
||||||
|
type: float32
|
||||||
|
unit: "kvar"
|
||||||
|
device_class: reactive_power
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: power_factor
|
||||||
|
address: 0x200A # PF — Power factor (dimensionless)
|
||||||
|
type: float32
|
||||||
|
unit: ""
|
||||||
|
device_class: power_factor
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: frequency
|
||||||
|
address: 0x200E # Freq — Frequency (Hz)
|
||||||
|
type: float32
|
||||||
|
unit: "Hz"
|
||||||
|
device_class: frequency
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: import_energy
|
||||||
|
address: 0x4000 # Ep — positive/forward active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
|
|
||||||
|
- key: export_energy
|
||||||
|
address: 0x400A # -Ep — reverse active energy (kWh)
|
||||||
|
type: float32
|
||||||
|
unit: "kWh"
|
||||||
|
device_class: energy
|
||||||
|
state_class: total_increasing
|
||||||
|
ha_component: sensor
|
||||||
@@ -30,20 +30,39 @@ logger = logging.getLogger(__name__)
|
|||||||
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
|
||||||
_DEFAULT_TIMEOUT = 15.0
|
_DEFAULT_TIMEOUT = 15.0
|
||||||
|
|
||||||
# GraphQL query to fetch a range of 15-minute price nodes.
|
# GraphQL query to fetch the forward-looking today + tomorrow price curve at
|
||||||
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
|
# 15-minute resolution.
|
||||||
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max,
|
#
|
||||||
# but the Tibber API typically starts from the current slot and returns at
|
# ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
|
||||||
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
|
# * ``today`` — always the full current local day (96 quarter-hourly slots,
|
||||||
# "today + tomorrow").
|
# 00:00 → 23:45 local), regardless of the current time.
|
||||||
|
# * ``tomorrow`` — the full next local day (96 slots) once Tibber publishes the
|
||||||
|
# day-ahead prices (around 13:00–15:00 local); empty before that.
|
||||||
|
#
|
||||||
|
# This is deliberately NOT ``priceInfoRange``: that field is a historical cursor
|
||||||
|
# connection whose range ends at "now" (it never returns future slots), so it
|
||||||
|
# cannot supply upcoming prices. ``priceInfo`` is forward-looking, so every
|
||||||
|
# 15-minute slot's price is present in the DB *before* the slot closes — which is
|
||||||
|
# what makes per-slot billing accurate (each period finds its own exact slot
|
||||||
|
# instead of falling back to a stale earlier price) and keeps the live current-
|
||||||
|
# price entity fresh. The hourly refresh job re-runs this query, so tomorrow's
|
||||||
|
# prices are picked up within an hour of publication without a restart.
|
||||||
_PRICE_RANGE_QUERY = """
|
_PRICE_RANGE_QUERY = """
|
||||||
{
|
{
|
||||||
viewer {
|
viewer {
|
||||||
homes {
|
homes {
|
||||||
id
|
id
|
||||||
currentSubscription {
|
currentSubscription {
|
||||||
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
|
priceInfo(resolution: QUARTER_HOURLY) {
|
||||||
nodes {
|
today {
|
||||||
|
startsAt
|
||||||
|
total
|
||||||
|
energy
|
||||||
|
tax
|
||||||
|
currency
|
||||||
|
level
|
||||||
|
}
|
||||||
|
tomorrow {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
energy
|
energy
|
||||||
@@ -230,11 +249,15 @@ def fetch_price_range(
|
|||||||
*,
|
*,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = _DEFAULT_TIMEOUT,
|
||||||
) -> list[PricePoint]:
|
) -> list[PricePoint]:
|
||||||
"""Fetch a range of 15-minute price nodes from the Tibber API.
|
"""Fetch the forward-looking today + tomorrow 15-minute price curve from Tibber.
|
||||||
|
|
||||||
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query
|
Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
|
||||||
and parses every returned node into a ``PricePoint``. The number of nodes
|
and parses every node from both lists (today first, then tomorrow) into a
|
||||||
is not assumed — all returned nodes are parsed regardless of count.
|
``PricePoint``. ``priceInfo`` is forward-looking — ``today`` is always the
|
||||||
|
full current local day and ``tomorrow`` is populated once Tibber publishes the
|
||||||
|
day-ahead prices — so upcoming slots are returned, unlike ``priceInfoRange``
|
||||||
|
which only reaches "now". ``tomorrow`` may be empty (before publication); the
|
||||||
|
number of nodes is not assumed and all returned nodes are parsed.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -268,10 +291,15 @@ def fetch_price_range(
|
|||||||
home = _pick_home(homes, home_id)
|
home = _pick_home(homes, home_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
|
price_info = home["currentSubscription"]["priceInfo"]
|
||||||
|
today = price_info["today"]
|
||||||
|
tomorrow = price_info["tomorrow"]
|
||||||
except (KeyError, TypeError) as exc:
|
except (KeyError, TypeError) as exc:
|
||||||
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc
|
raise TibberError("Tibber API response missing priceInfo today/tomorrow") from exc
|
||||||
|
|
||||||
|
# tomorrow is null/empty until Tibber publishes the day-ahead prices; treat
|
||||||
|
# a missing list as empty so we still return today's slots.
|
||||||
|
nodes = list(today or []) + list(tomorrow or [])
|
||||||
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+29
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,6 +17,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 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.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.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.modbus import router as api_modbus_router
|
||||||
from app.api.routes.api.session import router as api_session_router
|
from app.api.routes.api.session import router as api_session_router
|
||||||
from app.api.routes import status
|
from app.api.routes import status
|
||||||
@@ -35,6 +37,7 @@ from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SE
|
|||||||
from app.services.ha_discovery import publish_discovery, publish_states
|
from app.services.ha_discovery import publish_discovery, publish_states
|
||||||
from app.services.tibber_prices import refresh_prices
|
from app.services.tibber_prices import refresh_prices
|
||||||
from app.services.energy_cost import compute_closed_periods
|
from app.services.energy_cost import compute_closed_periods
|
||||||
|
from app.services.timezone import local_tz
|
||||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -158,6 +161,20 @@ def _run_scheduled_ha_state_publish() -> None:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_midnight_state_publish() -> None:
|
||||||
|
"""本地午夜后不久专门发布一次状态,让 *_today 的每日归零稳稳落在午夜之后
|
||||||
|
(对 HA 钟慢几秒鲁棒)。best-effort:失败仅记日志,不影响调度器。"""
|
||||||
|
session_local = get_session_local()
|
||||||
|
session = session_local()
|
||||||
|
try:
|
||||||
|
from app.services.ha_discovery import publish_states
|
||||||
|
publish_states(session)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_run_midnight_state_publish: failed (non-fatal)")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def ensure_auth_db_ready() -> None:
|
def ensure_auth_db_ready() -> None:
|
||||||
session_local = get_session_local()
|
session_local = get_session_local()
|
||||||
session: Session = session_local()
|
session: Session = session_local()
|
||||||
@@ -232,6 +249,17 @@ async def lifespan(_: FastAPI):
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
# Dedicated midnight publish: fire at local 00:00:10 so *_today grace (5 s) has
|
||||||
|
# already elapsed and the day-rolled value is pushed to HA immediately, rather
|
||||||
|
# than waiting for the next 60-second ha-state-publish sweep.
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_midnight_state_publish,
|
||||||
|
trigger=CronTrigger(hour=0, minute=0, second=10, timezone=local_tz()),
|
||||||
|
id="midnight-today-publish",
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
||||||
@@ -280,6 +308,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(api_data_router)
|
app.include_router(api_data_router)
|
||||||
app.include_router(api_energy_router)
|
app.include_router(api_energy_router)
|
||||||
app.include_router(api_energy_contracts_router)
|
app.include_router(api_energy_contracts_router)
|
||||||
|
app.include_router(api_meters_router)
|
||||||
app.include_router(api_expose_router)
|
app.include_router(api_expose_router)
|
||||||
app.include_router(api_modbus_router)
|
app.include_router(api_modbus_router)
|
||||||
app.include_router(api_session_router)
|
app.include_router(api_session_router)
|
||||||
|
|||||||
+79
-1
@@ -1,6 +1,7 @@
|
|||||||
"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.
|
"""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).
|
- dsmr_reading: raw DSMR telegram blobs (10-second down-sampled).
|
||||||
- energy_contract: contract head (manual or tibber, one active at a time).
|
- energy_contract: contract head (manual or tibber, one active at a time).
|
||||||
- energy_contract_version: versioned pricing values; append-only for auditability.
|
- energy_contract_version: versioned pricing values; append-only for auditability.
|
||||||
@@ -10,6 +11,7 @@ Five tables:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
|
||||||
@@ -19,6 +21,71 @@ from sqlalchemy.types import JSON
|
|||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid4_str() -> str:
|
||||||
|
return str(_uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Stable internal identity — used as HA Discovery unique_id anchor.
|
||||||
|
uuid: Mapped[str] = mapped_column(
|
||||||
|
String(36), unique=True, nullable=False, default=_uuid4_str
|
||||||
|
)
|
||||||
|
|
||||||
|
# Human-readable label for this physical meter (e.g. address, serial, tariff zone).
|
||||||
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# 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):
|
class DsmrReading(Base):
|
||||||
"""One down-sampled DSMR telegram stored as a full JSON blob.
|
"""One down-sampled DSMR telegram stored as a full JSON blob.
|
||||||
|
|
||||||
@@ -217,6 +284,14 @@ class EnergyCostPeriod(Base):
|
|||||||
ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
|
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
|
# True when the period was computed with incomplete data (missing readings or
|
||||||
# missing price); serves as a flag for later recomputation.
|
# missing price); serves as a flag for later recomputation.
|
||||||
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
@@ -229,6 +304,9 @@ class EnergyCostPeriod(Base):
|
|||||||
back_populates="cost_periods"
|
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.
|
# 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;
|
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
|
||||||
|
|||||||
@@ -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."
|
||||||
|
),
|
||||||
|
)
|
||||||
+275
-50
@@ -1,7 +1,7 @@
|
|||||||
"""Billing engine for DSMR 15-minute energy metering periods.
|
"""Billing engine for DSMR 15-minute energy metering periods.
|
||||||
|
|
||||||
This module implements the two-layer billing model described in §3.4 of the
|
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)**
|
**Layer 1 — per-period metering cost (immutable, price-snapshot)**
|
||||||
``compute_period(session, t0)`` computes the import cost, export revenue, and
|
``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"``
|
- **Register keys**: DSMR payload uses JSON strings like ``"20915.154"``
|
||||||
for cumulative kWh registers. ``register_at`` converts them to Decimal.
|
for cumulative kWh registers. ``register_at`` converts them to Decimal.
|
||||||
- **Degraded vs skip semantics**:
|
- **Degraded vs skip semantics**:
|
||||||
|
- *No meter coverage* (``meter_at`` returns None for t0): write a
|
||||||
|
``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
|
- *Missing readings* (``register_at`` returns None for start or end
|
||||||
boundary): write a ``degraded=True`` row with costs at 0 so the period is
|
boundary within the meter window): write a ``degraded=True`` row with
|
||||||
tracked and can be retried by ``compute_closed_periods``.
|
``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
|
- *Missing Tibber price* (``TibberPriceNotFoundError``): skip entirely (do
|
||||||
not write a row); the period will be retried once prices arrive.
|
not write a row); the period will be retried once prices arrive.
|
||||||
- *Missing active contract version*: skip (no contract to compute against).
|
- *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
|
- **Lookback window in ``compute_closed_periods``**: to avoid scanning all
|
||||||
historical DSMR data on every tick, the function looks back at most 7 days
|
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
|
from the current time. This covers typical short outages (no data / no
|
||||||
contract) while staying bounded. Periods older than 7 days must be
|
contract) while staying bounded. Periods older than 7 days must be
|
||||||
recovered via an explicit ``recompute_range`` call.
|
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
|
from __future__ import annotations
|
||||||
@@ -59,8 +91,9 @@ from app.integrations.pricing.strategies import (
|
|||||||
TibberPriceNotFoundError,
|
TibberPriceNotFoundError,
|
||||||
get_strategy,
|
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.contracts import active_contract_version_at, active_contract_versions
|
||||||
|
from app.services.meters import meter_at
|
||||||
from app.services.timezone import local_date, local_now
|
from app.services.timezone import local_date, local_now
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -81,6 +114,19 @@ _LOOKBACK_DAYS = 7 # maximum lookback window for compute_closed_periods
|
|||||||
# producing a spurious zero-delta "successful" row.
|
# producing a spurious zero-delta "successful" row.
|
||||||
_READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
|
_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")
|
||||||
|
|
||||||
|
# 每日固定费/税补在"本地午夜后多久"才结算入账。延后到 01:05 是为了让累计成本的
|
||||||
|
# 整天阶跃落在新一天、且避开 01:00 整点(HA 长期统计的小时桶边界)。
|
||||||
|
_SETTLEMENT_OFFSET = timedelta(hours=1, minutes=5)
|
||||||
|
|
||||||
# DSMR payload register keys (cumulative kWh, JSON string values).
|
# DSMR payload register keys (cumulative kWh, JSON string values).
|
||||||
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
|
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
|
||||||
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
|
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
|
||||||
@@ -123,15 +169,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:
|
def register_at(
|
||||||
"""Return the four cumulative kWh register values at *boundary*.
|
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``
|
Queries the most recent ``DsmrReading`` with:
|
||||||
and extracts the four energy registers from ``payload``:
|
``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)
|
d1 — electricity_delivered_1 (delivered low-tariff / dal)
|
||||||
d2 — electricity_delivered_2 (delivered high-tariff / normal)
|
d2 — electricity_delivered_2 (delivered high-tariff / normal)
|
||||||
@@ -142,18 +202,40 @@ def register_at(session: Session, boundary: datetime) -> dict[str, Decimal] | No
|
|||||||
-------
|
-------
|
||||||
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
|
dict[str, Decimal] with keys ``d1``, ``d2``, ``r1``, ``r2``, or ``None``
|
||||||
when:
|
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 keys is absent from the payload.
|
||||||
- Any of the four register values is ``None`` (null in JSON).
|
- 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 = (
|
# Build the meter-window constraints: [started_at, ended_at).
|
||||||
session.execute(
|
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)
|
select(DsmrReading)
|
||||||
.where(DsmrReading.recorded_at <= boundary)
|
.where(
|
||||||
|
DsmrReading.recorded_at <= boundary,
|
||||||
|
DsmrReading.recorded_at >= meter_lower,
|
||||||
|
)
|
||||||
.order_by(DsmrReading.recorded_at.desc())
|
.order_by(DsmrReading.recorded_at.desc())
|
||||||
.limit(1)
|
.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:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
@@ -214,8 +296,14 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
Side-effects
|
Side-effects
|
||||||
------------
|
------------
|
||||||
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
|
- Inserts or updates an ``EnergyCostPeriod`` row keyed on ``period_start=t0``.
|
||||||
- If readings are missing at either boundary: inserts/updates a degraded
|
- If no meter covers t0 (``meter_at`` returns None for t0): inserts/updates
|
||||||
row (costs=0, degraded=True).
|
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 active contract version is missing: **skips** (returns False, no write).
|
||||||
- If the Tibber price is missing (TibberPriceNotFoundError): **skips**
|
- If the Tibber price is missing (TibberPriceNotFoundError): **skips**
|
||||||
(returns False, no write).
|
(returns False, no write).
|
||||||
@@ -229,7 +317,50 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
if existing is not None and not existing.degraded and not overwrite:
|
if existing is not None and not existing.degraded and not overwrite:
|
||||||
return False
|
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.
|
# If there is no active contract covering t0, skip the period entirely.
|
||||||
# We do not write a degraded row — there is no meaningful state to recover
|
# We do not write a degraded row — there is no meaningful state to recover
|
||||||
# without a contract (we would not know which strategy to apply once data
|
# without a contract (we would not know which strategy to apply once data
|
||||||
@@ -240,14 +371,13 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
|
logger.debug("compute_period(%s): no active contract version — skipping.", t0.isoformat())
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# --- Boundary readings ---
|
# --- Boundary readings within m0's meter window ---
|
||||||
start_regs = register_at(session, t0)
|
start_regs = register_at(session, t0, m0)
|
||||||
end_regs = register_at(session, t1)
|
end_regs = register_at(session, t1, m0)
|
||||||
|
|
||||||
if start_regs is None or end_regs is None:
|
if start_regs is None or end_regs is None:
|
||||||
# Missing readings → write/update a degraded placeholder so the period
|
# Missing readings within the meter window → degraded with m0 attribution.
|
||||||
# is visible and can be retried by compute_closed_periods once data arrives.
|
_upsert_degraded(session, t0, now, existing, meter_id=m0.id)
|
||||||
_upsert_degraded(session, t0, now, existing)
|
|
||||||
return True # a record was written (degraded)
|
return True # a record was written (degraded)
|
||||||
|
|
||||||
# --- Compute deltas (end − start) ---
|
# --- Compute deltas (end − start) ---
|
||||||
@@ -258,6 +388,26 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
r2=end_regs["r2"] - start_regs["r2"],
|
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 ---
|
# --- Price strategy ---
|
||||||
strategy = get_strategy(version.contract.kind)
|
strategy = get_strategy(version.contract.kind)
|
||||||
try:
|
try:
|
||||||
@@ -288,6 +438,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
existing.currency = version.contract.currency
|
existing.currency = version.contract.currency
|
||||||
existing.pricing = pricing
|
existing.pricing = pricing
|
||||||
existing.contract_version_id = version.id
|
existing.contract_version_id = version.id
|
||||||
|
existing.meter_id = m0.id
|
||||||
existing.degraded = False
|
existing.degraded = False
|
||||||
existing.computed_at = now
|
existing.computed_at = now
|
||||||
else:
|
else:
|
||||||
@@ -303,6 +454,7 @@ def compute_period(session: Session, t0: datetime, *, overwrite: bool = False) -
|
|||||||
currency=version.contract.currency,
|
currency=version.contract.currency,
|
||||||
pricing=pricing,
|
pricing=pricing,
|
||||||
contract_version_id=version.id,
|
contract_version_id=version.id,
|
||||||
|
meter_id=m0.id,
|
||||||
degraded=False,
|
degraded=False,
|
||||||
computed_at=now,
|
computed_at=now,
|
||||||
)
|
)
|
||||||
@@ -316,9 +468,25 @@ def _upsert_degraded(
|
|||||||
t0: datetime,
|
t0: datetime,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
existing: EnergyCostPeriod | None,
|
existing: EnergyCostPeriod | None,
|
||||||
|
*,
|
||||||
|
meter_id: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Insert or update a degraded placeholder for period *t0*.
|
"""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
|
When *existing* is not None (row was previously written — either degraded
|
||||||
or successful), the row is explicitly reset to the standard degraded state.
|
or successful), the row is explicitly reset to the standard degraded state.
|
||||||
This is required for the ``recompute_range`` (overwrite=True) path: if the
|
This is required for the ``recompute_range`` (overwrite=True) path: if the
|
||||||
@@ -326,6 +494,11 @@ def _upsert_degraded(
|
|||||||
since disappeared, the stale non-zero costs must be cleared so the row
|
since disappeared, the stale non-zero costs must be cleared so the row
|
||||||
accurately reflects the current "missing readings" state rather than
|
accurately reflects the current "missing readings" state rather than
|
||||||
masquerading as a valid result.
|
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:
|
if existing is not None:
|
||||||
# Explicitly reset to degraded state — identical field values to the
|
# Explicitly reset to degraded state — identical field values to the
|
||||||
@@ -340,6 +513,7 @@ def _upsert_degraded(
|
|||||||
existing.net_cost = 0.0
|
existing.net_cost = 0.0
|
||||||
existing.pricing = {}
|
existing.pricing = {}
|
||||||
existing.contract_version_id = None
|
existing.contract_version_id = None
|
||||||
|
existing.meter_id = meter_id
|
||||||
existing.degraded = True
|
existing.degraded = True
|
||||||
existing.computed_at = now
|
existing.computed_at = now
|
||||||
else:
|
else:
|
||||||
@@ -355,6 +529,7 @@ def _upsert_degraded(
|
|||||||
currency="EUR", # placeholder; real currency known after contract lookup
|
currency="EUR", # placeholder; real currency known after contract lookup
|
||||||
pricing={},
|
pricing={},
|
||||||
contract_version_id=None,
|
contract_version_id=None,
|
||||||
|
meter_id=meter_id,
|
||||||
degraded=True,
|
degraded=True,
|
||||||
computed_at=now,
|
computed_at=now,
|
||||||
)
|
)
|
||||||
@@ -381,6 +556,7 @@ def compute_closed_periods(session: Session) -> int:
|
|||||||
3. For each boundary, calls ``compute_period(overwrite=False)``, which:
|
3. For each boundary, calls ``compute_period(overwrite=False)``, which:
|
||||||
- Skips periods that already have a *successful* (non-degraded) record.
|
- Skips periods that already have a *successful* (non-degraded) record.
|
||||||
- Retries periods that have a *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
|
- Skips periods for which no active contract version exists or the
|
||||||
Tibber price is unavailable (without writing a degraded row).
|
Tibber price is unavailable (without writing a degraded row).
|
||||||
|
|
||||||
@@ -429,11 +605,17 @@ def recompute_range(session: Session, start: datetime, end: datetime) -> int:
|
|||||||
This is the *explicit opt-in* path for recovering from:
|
This is the *explicit opt-in* path for recovering from:
|
||||||
- Periods where readings or prices arrived late.
|
- Periods where readings or prices arrived late.
|
||||||
- Price corrections (new contract version retroactively applied).
|
- 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.
|
- Any other reason to override the immutability guard.
|
||||||
|
|
||||||
The function iterates over every UTC quarter-hour boundary in
|
The function iterates over every UTC quarter-hour boundary in
|
||||||
``[floor(start), end)`` and calls ``compute_period(overwrite=True)``.
|
``[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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -500,15 +682,33 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
+ fixed_costs -- per-day standing charges, cross-version
|
+ fixed_costs -- per-day standing charges, cross-version
|
||||||
- credits -- per-day heffingskorting, cross-version
|
- credits -- per-day heffingskorting, cross-version
|
||||||
|
|
||||||
**Fixed-cost / credit counting — Principle C**:
|
**Fixed-cost / credit counting — Principle C (symmetric begin/end)**:
|
||||||
Only *already-elapsed* whole local calendar days are counted. For each
|
Both the local calendar day in which *start* falls and the local calendar
|
||||||
local calendar date D in the window ``[start_local_date, min(end_local_date,
|
day in which *end* falls are counted as full days. Fixed charges
|
||||||
tomorrow_local))``, the contract version whose rate covers D (the one whose
|
(network_fee, management_fee) and the energy-tax credit (heffingskorting)
|
||||||
effective_from local-date ≤ D < next version's effective_from local-date) is
|
are assessed on a "service-is-active" basis — if the meter was online on a
|
||||||
used. Days that have not yet started in local time (D > today_local) are
|
given calendar day, the full day's charge/credit applies, regardless of
|
||||||
never counted. This is cross-version: if the active contract has V1 from
|
whether the window starts at midnight or mid-morning.
|
||||||
June 1 and V2 from June 25, querying June 1–30 uses V1 for days 1-24 and V2
|
|
||||||
for day 25. Switching versions never resets the counter.
|
For each local calendar date D in the range
|
||||||
|
``[local_date(start), min(local_date(end), today_local)]``:
|
||||||
|
- D ≤ today_local (only elapsed / today days count as "whole days").
|
||||||
|
- The contract version whose effective_from local-date ≤ D is used.
|
||||||
|
- This is cross-version: if V1 is from June 1 and V2 from June 25,
|
||||||
|
querying June 1–30 uses V1 for days 1-24 and V2 for day 25.
|
||||||
|
|
||||||
|
The end-day (last_counted) is included when the end's local midnight falls
|
||||||
|
strictly before end_utc; combined with the always-counted start day this
|
||||||
|
makes the begin/end handling symmetric. A short same-day window therefore
|
||||||
|
counts its single local day. A window contributes 0 days only when the
|
||||||
|
counted range is empty (first_counted > last_counted) — e.g. a window lying
|
||||||
|
entirely in the future, since last_counted is capped at today_local.
|
||||||
|
|
||||||
|
Daily getters (``*_today``) use windows exactly aligned to local midnight,
|
||||||
|
so their ``first_counted`` is always today — unaffected by this fix.
|
||||||
|
|
||||||
|
Days that have not yet started in local time (D > today_local) are
|
||||||
|
never counted. Switching versions never resets the counter.
|
||||||
|
|
||||||
**Timezone note**: the ``days`` field in the returned dict still represents
|
**Timezone note**: the ``days`` field in the returned dict still represents
|
||||||
the window length in calendar days (total_seconds / 86400), for backward
|
the window length in calendar days (total_seconds / 86400), for backward
|
||||||
@@ -568,29 +768,46 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
|
||||||
|
|
||||||
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
|
# --- Fixed costs and credits: Principle C cross-version whole-day counting ---
|
||||||
today_local: _date = local_now().date()
|
_now_local = local_now()
|
||||||
|
today_local: _date = _now_local.date()
|
||||||
|
|
||||||
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
|
# --- Compute [first_counted, last_counted] local date range (inclusive) ---
|
||||||
#
|
#
|
||||||
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc)
|
# Both the window-start day and the window-end day are counted as complete
|
||||||
# AND D ≤ today_local (only elapsed / today days count as "whole days").
|
# local calendar days, regardless of whether the window starts/ends at midnight.
|
||||||
#
|
#
|
||||||
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today)
|
# Principle C (symmetric begin/end):
|
||||||
# AND the local midnight is within the billing window."
|
# • first_counted = local calendar date of start_utc (start day always counted)
|
||||||
|
# • last_counted = local calendar date of end_utc (end day counted if its
|
||||||
|
# local midnight is strictly before end_utc)
|
||||||
|
# • Both are then capped at today_local (only elapsed / today days count).
|
||||||
#
|
#
|
||||||
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any
|
# Why symmetric? Fixed charges (network_fee, management_fee) and energy-tax credits
|
||||||
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that
|
# (heffingskorting) are assessed on a "service-is-active" basis, not on how many
|
||||||
# contains CEST midnight (= 22:00 UTC) contributes 1 day.
|
# hours the service was actually running within that calendar day. If the meter
|
||||||
|
# anchor (started_at) falls at 09:18 on June 24, the full June 24 standing charge
|
||||||
|
# and credit still apply because the service was online for that day.
|
||||||
#
|
#
|
||||||
# Implementation: compute the first and last local date whose midnight is in range.
|
# Previous asymmetric behaviour: a start_utc that was *later* than the local
|
||||||
|
# midnight of local_start_date caused first_counted to be bumped to the *next*
|
||||||
|
# day, silently dropping the anchor day's charges/credits. This was incorrect
|
||||||
|
# for cumulative entities (import_cost_total / export_revenue_total) whose anchor
|
||||||
|
# is often an above-midnight started_at. The end-side had always been symmetric
|
||||||
|
# (counted if local midnight < end_utc), creating an inconsistency.
|
||||||
|
#
|
||||||
|
# Daily getters (window = [local today 00:00, local tomorrow 00:00)) are
|
||||||
|
# unaffected: local_date(local_midnight_utc(today)) == today, so first_counted
|
||||||
|
# is still today regardless of the fix.
|
||||||
|
#
|
||||||
|
# A short same-day window now counts its single local day (the start day is
|
||||||
|
# always counted, symmetric with the end side). A window contributes 0 days
|
||||||
|
# only when the counted range is empty (first_counted > last_counted) — e.g. a
|
||||||
|
# window lying entirely in the future, where last_counted is capped at
|
||||||
|
# today_local while first_counted is later.
|
||||||
from app.services.timezone import local_midnight_utc as _lmu
|
from app.services.timezone import local_midnight_utc as _lmu
|
||||||
|
|
||||||
local_start_date: _date = local_date(start_utc)
|
# Start side: always count the local calendar day in which start_utc falls.
|
||||||
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day.
|
first_counted: _date = local_date(start_utc)
|
||||||
if _lmu(local_start_date) >= start_utc:
|
|
||||||
first_counted: _date = local_start_date
|
|
||||||
else:
|
|
||||||
first_counted = local_start_date + _td(days=1)
|
|
||||||
|
|
||||||
local_end_date: _date = local_date(end_utc)
|
local_end_date: _date = local_date(end_utc)
|
||||||
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
|
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
|
||||||
@@ -599,8 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
|
|||||||
else:
|
else:
|
||||||
last_counted = local_end_date - _td(days=1)
|
last_counted = local_end_date - _td(days=1)
|
||||||
|
|
||||||
# Cap: only elapsed or today's days.
|
# Settlement cap: "today" is only counted once the local clock has passed the
|
||||||
last_counted = min(last_counted, today_local)
|
# settlement offset since midnight (01:05). This defers the daily standing-charge
|
||||||
|
# step from 00:00 to 01:05, ensuring the cumulative-cost adiabatic jump lands
|
||||||
|
# inside the new calendar day and avoids the HA 01:00 hourly-bucket boundary.
|
||||||
|
_today_midnight_utc = _lmu(today_local)
|
||||||
|
if _now_local >= _today_midnight_utc + _SETTLEMENT_OFFSET:
|
||||||
|
settled_cap = today_local
|
||||||
|
else:
|
||||||
|
settled_cap = today_local - _td(days=1)
|
||||||
|
last_counted = min(last_counted, settled_cap)
|
||||||
|
|
||||||
# If the range is empty (first_counted > last_counted), no days are counted.
|
# If the range is empty (first_counted > last_counted), no days are counted.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -64,6 +64,7 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
|
|||||||
device.port,
|
device.port,
|
||||||
device.unit_id,
|
device.unit_id,
|
||||||
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
[{"start": b.start, "count": b.count} for b in profile.blocks],
|
||||||
|
function_code=profile.function_code,
|
||||||
)
|
)
|
||||||
payload = profiles.decode(profile, registers)
|
payload = profiles.decode(profile, registers)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP)**先做**
|
- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP)**先做**
|
||||||
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
|
- [`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 反哺
|
- [`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)
|
||||||
|
|
||||||
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
|
本文件定义**所有任务共用的格式与协作规则**,各个里程碑文档不再重复这些约定。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# M7 — 电表生命周期 / 换表归档(Meter epochs)
|
||||||
|
|
||||||
|
> 协作格式、任务卡结构、校验闸门、数据安全红线见 [`README.md`](./README.md),本文不再重复。
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
让计费系统正确处理**电表更换**这一**必然事件**:
|
||||||
|
|
||||||
|
- 荷兰 2G 智能电表退网后,电网公司**一定**会把表换成 4G 表(同址换表);搬家继承新表也是同类。
|
||||||
|
- 计费是**寄存器差值(delta)模型**:每个 15 分钟周期成本 =(周期末读数 − 周期初读数)× 单价。换表后新表寄存器基数与旧表**无关**(可能更高也可能更低),若跨表算 delta 会产出**负成本或巨额假成本**。
|
||||||
|
- 目标:引入显式的 **Meter(电表)** 概念,标记"某时刻起属于哪一块物理表",使引擎**永不跨表算 delta**,并让累计量按表归零、历史可追溯可查。
|
||||||
|
|
||||||
|
非目标(本里程碑不做,§9 留痕):gas / 区域供暖的**计费**;"家庭(home)"分组实体;多合同时间线积分;自动识别换表(DSMR 帧无电表序列号,无法自动识别,靠用户显式声明)。
|
||||||
|
|
||||||
|
## 2. 现状(实现者可据此工作,不必通读全仓库)
|
||||||
|
|
||||||
|
- 计费数据模型(M6):
|
||||||
|
- `dsmr_reading`:整帧 JSON,`recorded_at`(UNIQUE) 去重;含 4 个累计寄存器 `electricity_delivered_1/2`、`electricity_returned_1/2`。**帧内无电表序列号字段**。
|
||||||
|
- `energy_cost_period`:每 15 分钟一行,存 `d1/d2/r1/r2_kwh`(delta)、`import_cost/export_revenue/net_cost`、`pricing` 快照、`contract_version_id`、`degraded`、`computed_at`、`period_start`(unique)。
|
||||||
|
- 计费引擎 `app/services/energy_cost.py`:
|
||||||
|
- `register_at(session, boundary)`:取 `recorded_at ≤ boundary` 的最近一条;有 15 分钟 staleness 护栏(超过则返回 None → 周期降级)。
|
||||||
|
- `compute_period(session, t0)`:取 t0/t1 两端寄存器,算 delta,按 `contract` 的 strategy 计价;缺读数→降级行(成本 0)。
|
||||||
|
- `compute_closed_periods` / `recompute_range`:遍历刻钟边界批量算。
|
||||||
|
- `summarize(session, start, end)`:聚合非降级周期电费 + 固定费/抵扣(FU10 已按本地日跨版本积分)。
|
||||||
|
- 计价策略 `app/integrations/pricing/strategies.py`:`import_cost = Δd1×buy_dal + Δd2×buy_normal` 等,**对 delta 无任何 clamp/sanity 检查**(负 delta → 负成本,超大 delta → 巨额成本)。
|
||||||
|
- 暴露 `app/integrations/expose.py`:累计 `import_cost_total`/`export_revenue_total` 锚点(FU11)= `max(合同最早 effective_from, 最早非降级 period_start)`;`*_today` 日归零实体(FU11)。
|
||||||
|
- 合同 `app/services/contracts.py`:`active` 互斥 + 版本时间线 + `active_contract_version_at(ts)`(半开 `[from,to)`)。合同**不引用电表**。
|
||||||
|
- 配置/迁移:单库 app,Alembic 链 `alembic_app/`;`PRAGMA foreign_keys=ON` 已开(`app/db.py`)。
|
||||||
|
- 时区:FU10 的 `app/services/timezone.py`(`local_*`,可 monkeypatch)。
|
||||||
|
|
||||||
|
## 3. 目标架构
|
||||||
|
|
||||||
|
### 3.1 核心概念:Meter(= epoch)
|
||||||
|
|
||||||
|
一条 `meter` 记录 = **一块物理电表的一段安装期** `[started_at, ended_at)`。换表 = 关闭当前 active 表(`ended_at=T`)+ 新建一条 `started_at=T` 的表。计费**永不跨 meter 算 delta**:跨表的那个周期按设计降级。
|
||||||
|
|
||||||
|
- **每种 `commodity` 至多一个 active 表**(`ended_at IS NULL`)。本里程碑只 `electricity` 参与计费,`commodity` 字段为未来 gas/heating 预留。
|
||||||
|
- `meter_at(session, ts, commodity="electricity")`:返回 `started_at ≤ ts AND (ended_at IS NULL OR ts < ended_at)` 的那块表(半开区间,仿 `active_contract_version_at`)。
|
||||||
|
- 计费基准:某表窗口内**最早的一条 `dsmr_reading`**(`recorded_at ≥ started_at`)即基准;用户**只需填 `started_at` 日期,无需知道当前读数**。追溯(`started_at` 在过去)时,有效起点自然 = `max(started_at, 数据起点)`。
|
||||||
|
- **隔离的前提 = 显式声明**:系统无法自动识别换表(DSMR 帧无序列号),搬家后新旧读数同落 `dsmr_reading`,靠用户**显式新建那块表**才隔离。万一忘了声明:搬家必有的数据空档(staleness)+ delta 护栏(§3.3)保证**不会产出垃圾成本**,只是新数据暂混在旧表 epoch、累计未归零;**事后补声明 + 追溯 `recompute_range`** 即可纠正。
|
||||||
|
|
||||||
|
### 3.2 数据模型(新增 1 张表 + 1 列)
|
||||||
|
|
||||||
|
`meter` 表(`app/models/energy.py`,单库 app 链):
|
||||||
|
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | int PK | 内置自增 ID |
|
||||||
|
| `label` | str | 人读标签,编址/标识(如 "旧 2G 表 @ Dorpsstraat 1") |
|
||||||
|
| `commodity` | str | 默认 `"electricity"`;预留 `gas`/`heating` |
|
||||||
|
| `started_at` | datetime(UTC) | 安装/继承/搬家起点(可在过去) |
|
||||||
|
| `ended_at` | datetime(UTC) \| null | null = 当前 active |
|
||||||
|
| `reason` | str | `initial` / `meter_swap` / `home_move` / `other` |
|
||||||
|
| `note` | str \| null | 备注 |
|
||||||
|
| `created_at` | datetime(UTC) | |
|
||||||
|
|
||||||
|
`energy_cost_period` 新增 `meter_id`(nullable FK → `meter.id`):记录该周期归属哪块表,便于审计/按表查询/归档。
|
||||||
|
|
||||||
|
### 3.3 计费引擎改造(`energy_cost.py` / `strategies.py` 调用处)
|
||||||
|
|
||||||
|
1. `register_at(session, boundary, meter)`:**仅在该 meter 窗口内**取读数(`started_at ≤ recorded_at < ended_at` 且 `≤ boundary` 且过 staleness 护栏)。绝不把旧表读数拉进新表周期。
|
||||||
|
2. `compute_period(session, t0)`:
|
||||||
|
- 查 `m0 = meter_at(t0)`、`m1 = meter_at(t1)`。
|
||||||
|
- `m0 is None`(无表覆盖)→ **降级**(不计费)。
|
||||||
|
- `m0.id != m1.id`(周期跨表边界)→ **降级**(D5:丢这一个周期可接受)。
|
||||||
|
- 否则在 `m0` 窗口内 `register_at` 两端、算 delta、计价;新增写 `period.meter_id = m0.id`。
|
||||||
|
3. **delta sanity 护栏(D6)**:算出 delta 后,若 `d1/d2/r1/r2` 任一 `< 0`,或任一 `> _MAX_DELTA_KWH`(常量,给一个远超住宅的宽松上限,如每 15 分钟 100 kWh)→ **降级**(兜底表重置/DSMR 回绕/数据毛刺,与换表无关也防住)。
|
||||||
|
4. `compute_closed_periods` / `recompute_range`:逐周期按 `meter_at` 判定,无需额外结构。
|
||||||
|
|
||||||
|
### 3.4 累计语义(per-meter 归零,D2)
|
||||||
|
|
||||||
|
- `expose.py` 累计 `import_cost_total`/`export_revenue_total` 锚点改为**当前 active electricity 表的起点**:`anchor = active electricity meter.started_at`(窗口内首条读数自然 ≥ 它)。换表后累计**从零起一段新序列**,不维护偏移、不跨表累加。
|
||||||
|
- `*_today` 日归零实体不变(其本地今天窗口必落在当前表内)。
|
||||||
|
- (可选,非核心)暴露"当前电表 label"作为一个文本 sensor,便于 HA 侧识别当前表。
|
||||||
|
- **已知行为(可接受)**:累计 `_total` 是 `state_class: total`,换表归零时其值会下台阶,HA 长期统计可能在换表那一刻记一次性负 blip。HA 侧自己存旧值做 down-sample——已与用户确认**先这样、观察后按需处理**(`last_reset` 信号见 §9 杠杆,本里程碑不做)。
|
||||||
|
|
||||||
|
### 3.5 API(`app/api/routes/api/` + schemas)
|
||||||
|
|
||||||
|
- `GET /api/energy/meters` — 列出全部表(时间线,active 在前/或按 started_at)。
|
||||||
|
- `POST /api/energy/meters` — 声明换表/继承:`{label, started_at, reason, note?}`;服务层关闭当前 active 同 commodity 表(`ended_at=started_at`)+ 建新表。`started_at` 必须 `≥` 当前 active 表的 `started_at`(拒绝倒挂,仿合同版本"strictly after")。
|
||||||
|
- `PATCH /api/energy/meters/{id}` — 改 `label`/`note`,或修正 `started_at`(追溯)。
|
||||||
|
- `POST /api/energy/meters/{id}/recompute`(或复用现有 recompute)— 追溯变更后对受影响窗口 `recompute_range`,重判跨表周期与归属。
|
||||||
|
- 改了路由/schema → 重导出 OpenAPI 并提交。
|
||||||
|
|
||||||
|
### 3.6 前端(并入 Energy 视图)
|
||||||
|
|
||||||
|
- 一个"电表(Meters)"管理区:表的时间线(label / 区间 / active 标记 / reason),"我换了表 / 继承了新表"表单(只填 label + 日期 + reason),编辑 label/日期。
|
||||||
|
- 追溯保存后给出"已重算受影响周期"的反馈(或提供 recompute 按钮)。
|
||||||
|
- 前端闸门见 §8 引用。
|
||||||
|
|
||||||
|
### 3.7 迁移与回填(数据安全 runbook)
|
||||||
|
|
||||||
|
- Alembic(`alembic_app/`):建 `meter` 表 + 给 `energy_cost_period` 加 `meter_id` 列(nullable)。
|
||||||
|
- **回填(只增不删、幂等、对账)**:
|
||||||
|
1. 若库内已有任何 `dsmr_reading`/`energy_cost_period`,创建一条**初始表** `meter(label="Initial meter", commodity="electricity", started_at = 最早 dsmr_reading.recorded_at(无则最早 period_start,再无则迁移时刻), ended_at=NULL, reason="initial")`。
|
||||||
|
2. 把现有 `energy_cost_period.meter_id` 全部回填为这条初始表的 id。
|
||||||
|
3. **对账**:回填后 `meter_id IS NULL` 的非降级周期数必须为 0;对不上立即中止、非零退出。
|
||||||
|
- **红线**:迁移**不删除/不覆盖**任何 `dsmr_reading`/`energy_cost_period`/旧 `.db`/备份;纯新增表+列+回填。先在备份副本演练再对真实库执行。
|
||||||
|
|
||||||
|
## 4. 已锁定决策(讨论后拍板)
|
||||||
|
|
||||||
|
- **D1 粒度**:Meter 为一等公民,**不绑定 home**;`label` 编址 + `commodity` 区分品类(每 commodity 一个 active)。
|
||||||
|
- **D2 累计**:换表后累计量**归零**(锚当前表起点),直接吃 DSMR 累计 delta,不维护偏移。
|
||||||
|
- **D3 合同耦合**:**不引入 home 实体**。合同与电表是两条独立时间线,合同不引用电表 → 同址换表时合同自动照常套用,无需上层抽象。搬家若换合同:用"给现有合同加 version"保持历史连续(多合同时间线积分留作后续杠杆)。
|
||||||
|
- **D4 追溯**:`started_at` 可在过去;有效计费起点 = `max(started_at, 数据起点)`;追溯变更触发 `recompute_range`。
|
||||||
|
- **D5 边界周期**:跨表周期判 **degraded**(可接受,换表常伴随长时间无数据)。
|
||||||
|
- **D6 delta 护栏**:加(负/异常大 delta → degraded),通用兜底。
|
||||||
|
- **D7 扩展性**:gas/heating **计费**不在 scope;`commodity` 字段 + 每品类 active 表使未来加表低成本。
|
||||||
|
|
||||||
|
## 5. 任务依赖图
|
||||||
|
|
||||||
|
```
|
||||||
|
M7-T01 (meter 表+模型+列+迁移回填)
|
||||||
|
├──► M7-T02 (meter 服务层: swap/close/edit, meter_at, 互斥/校验)
|
||||||
|
│ ├──► M7-T03 (计费引擎 meter-aware + delta 护栏 + period.meter_id)
|
||||||
|
│ │ └──► M7-T04 (累计 per-meter 归零, expose 锚点)
|
||||||
|
│ └──► M7-T05 (meter API + 追溯 recompute + OpenAPI)
|
||||||
|
│ └──► M7-T06 (前端电表管理 UI)
|
||||||
|
└──────────────────────────────► M7-T07 (文档/roadmap/收尾)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 原子任务(任务卡)
|
||||||
|
|
||||||
|
### M7-T01 — `meter` 表 + 模型 + `energy_cost_period.meter_id` + 迁移回填 `[schema]`
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: none
|
||||||
|
- **Context**: 引入 Meter 实体的数据地基;为现有数据回填初始表。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `modify app/models/energy.py`(新增 `Meter`;`EnergyCostPeriod` 加 `meter_id`)
|
||||||
|
- `create alembic_app/versions/<rev>_meter_table.py`
|
||||||
|
- `modify tests/test_energy_models.py`
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. 加 `Meter` 模型(字段见 §3.2);`EnergyCostPeriod` 加 nullable `meter_id` FK。
|
||||||
|
2. 写迁移:建表 + 加列;按 §3.7 回填初始表并回填 `meter_id`;带对账(不一致则 raise)。
|
||||||
|
3. 测试:模型字段、active-per-commodity 约束的服务层留给 T02;这里测建表/列/回填幂等。
|
||||||
|
|
||||||
|
**Out of scope / 不要碰**:计费引擎、expose、API(后续任务)。
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [ ] `meter` 表与 `energy_cost_period.meter_id` 建出;迁移可升降级。
|
||||||
|
- [ ] 有历史数据时回填出恰好一条 `reason="initial"` 的 active 表,且非降级周期 `meter_id` 全部非空。
|
||||||
|
- [ ] 迁移**不删除/覆盖**任何既有数据;回填幂等。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
|
||||||
|
**Reviewer checklist**:回填对账逻辑;空库/有数据两种路径;数据安全红线(无删/覆盖)。
|
||||||
|
|
||||||
|
### M7-T02 — Meter 服务层(swap/close/edit + `meter_at` + 互斥/校验)
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: M7-T01
|
||||||
|
- **Context**: 换表/继承的业务逻辑与按时刻查表。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `create app/services/meters.py`
|
||||||
|
- `create tests/test_meters.py`
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. `meter_at(session, ts, commodity="electricity")`(半开区间)。
|
||||||
|
2. `declare_meter(session, *, label, started_at, reason, commodity="electricity", note=None)`:校验 `started_at ≥ 当前 active 同 commodity 表的 started_at`(拒绝倒挂);关闭旧 active(`ended_at=started_at`)+ 建新 active。
|
||||||
|
3. `list_meters` / `update_meter`(label/note/started_at);改 started_at 时维持区间自洽。
|
||||||
|
4. 测试:互斥(每 commodity 单 active)、倒挂拒绝、追溯、`meter_at` 边界。
|
||||||
|
|
||||||
|
**Out of scope**:API、引擎、前端。
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [ ] 每 commodity 至多一个 active;swap 正确续接区间。
|
||||||
|
- [ ] `meter_at` 半开区间正确(含边界)。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
|
||||||
|
**Reviewer checklist**:互斥与续接的事务正确性;追溯改 started_at 的区间一致性。
|
||||||
|
|
||||||
|
### M7-T03 — 计费引擎 meter-aware + delta 护栏 + `period.meter_id`
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: M7-T02
|
||||||
|
- **Context**: 让计费永不跨表算 delta,并兜底异常 delta。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `modify app/services/energy_cost.py`
|
||||||
|
- `modify tests/test_energy_cost.py`
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. `register_at` 限定在传入 meter 的窗口内取读数。
|
||||||
|
2. `compute_period`:按 §3.3 判定 `m0/m1`;无表/跨表 → 降级;否则算 delta、写 `meter_id`。
|
||||||
|
3. 加 `_MAX_DELTA_KWH` 常量与护栏:任一寄存器 delta `<0` 或 `>上限` → 降级。
|
||||||
|
4. `compute_closed_periods`/`recompute_range` 逐周期 meter 判定。
|
||||||
|
5. 测试:同表内正确;跨表周期降级;负/超大 delta 降级;无 active 表降级。
|
||||||
|
|
||||||
|
**Out of scope**:expose 锚点(T04)、API(T05)。
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [ ] 跨 meter 边界周期 = degraded;无表覆盖 = degraded。
|
||||||
|
- [ ] 负 delta / 超 `_MAX_DELTA_KWH` = degraded,绝不产负/巨额成本。
|
||||||
|
- [ ] 同表内 delta 计费与归属 `meter_id` 正确。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
|
||||||
|
**Reviewer checklist**:staleness × meter 窗口的交互;护栏阈值是否远超住宅、不会误伤真实用量;recompute 重判归属。
|
||||||
|
|
||||||
|
### M7-T04 — 累计 per-meter 归零(expose 锚点)
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: M7-T03
|
||||||
|
- **Context**: 换表后 MQTT 累计量从零起算。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `modify app/integrations/expose.py`
|
||||||
|
- `modify tests/test_energy_expose.py`
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. 累计 `import_cost_total`/`export_revenue_total` 锚点改为**当前 active electricity 表的 `started_at`**(替换 FU11 的 `max(合同, 首周期)`)。
|
||||||
|
2. `*_today` 不变;确认无非降级周期/无 active 表时仍 `None` 保护。
|
||||||
|
3.(可选)暴露当前电表 label 文本实体。
|
||||||
|
4. 测试:换表后累计仅含当前表;anchor 取当前表起点。
|
||||||
|
|
||||||
|
**Out of scope**:API/前端。
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [ ] 累计量锚 = 当前 active electricity meter 起点;换表归零。
|
||||||
|
- [ ] None 保护分支保留;daily 实体不受影响。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
|
||||||
|
**Reviewer checklist**:与 FU11 行为差异是否符合 D2;无表/空数据分支。
|
||||||
|
|
||||||
|
### M7-T05 — Meter API + 追溯 recompute + OpenAPI
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: M7-T02
|
||||||
|
- **Context**: 暴露电表 CRUD 与换表声明;追溯后重算。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `create app/api/routes/api/meters.py`
|
||||||
|
- `create app/schemas/meter.py`
|
||||||
|
- `modify app/api/routes/...`(注册路由)
|
||||||
|
- `modify openapi/openapi.json`, `openapi/openapi.yaml`
|
||||||
|
- `create tests/test_api_meters.py`
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. `GET/POST/PATCH /api/energy/meters`(+ 追溯触发 `recompute_range`)。
|
||||||
|
2. 鉴权沿用 `require_session`。
|
||||||
|
3. `python scripts/export_openapi.py` 并提交 `openapi/`。
|
||||||
|
4. 测试:列出/声明换表/编辑/倒挂拒绝/追溯重算。
|
||||||
|
|
||||||
|
**Out of scope**:前端。
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [ ] 端点行为符合 §3.5;追溯变更触发受影响窗口重算。
|
||||||
|
- [ ] `git diff --exit-code openapi/` 干净(已重导出提交)。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
|
||||||
|
**Reviewer checklist**:错误码(倒挂/不存在);recompute 范围是否覆盖受影响周期。
|
||||||
|
|
||||||
|
### M7-T06 — 前端电表管理 UI
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: M7-T05
|
||||||
|
- **Context**: 让用户在 Energy 页声明换表、查看电表时间线。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `create frontend/src/energy/MeterManager.tsx`
|
||||||
|
- `modify frontend/src/energy/EnergyPage.tsx`, `frontend/src/energy/hooks.ts`
|
||||||
|
- `modify frontend/src/energy/*.test.tsx`
|
||||||
|
- `modify frontend/src/api/schema.d.ts`(`npm run codegen`)
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. `npm run codegen` 拉新端点类型。
|
||||||
|
2. 电表时间线 + "换表/继承"表单(label + 日期 + reason)+ 编辑。
|
||||||
|
3. 追溯保存后反馈"已重算"。
|
||||||
|
4. 前端闸门(§8)。
|
||||||
|
|
||||||
|
**Out of scope**:后端。
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [ ] 能列出/声明换表/编辑;UI 合理。
|
||||||
|
- [ ] 前端 lint/typecheck/test/build 全绿;`schema.d.ts` 已 codegen。
|
||||||
|
|
||||||
|
**Reviewer checklist**:日期→后端时区语义(沿用 FU10 本地午夜 naive 约定);空状态/加载/错误。
|
||||||
|
|
||||||
|
### M7-T07 — 文档 / OpenAPI / roadmap 收尾
|
||||||
|
|
||||||
|
- **Status**: `done`
|
||||||
|
- **Depends**: M7-T01..T06
|
||||||
|
- **Context**: 把 M7 落档、更新 roadmap 与索引。
|
||||||
|
|
||||||
|
**Files**
|
||||||
|
- `modify docs/roadmap.md`(新增 M7 条目,标完成)
|
||||||
|
- `modify docs/design/README.md`(索引加 m7)
|
||||||
|
- `modify docs/design/m7-meter-epochs-archival.md`(Status 收尾)
|
||||||
|
- `modify docs/*`(如新增 meter 模块说明,按需)
|
||||||
|
|
||||||
|
**Acceptance criteria**
|
||||||
|
- [ ] roadmap/README/本文 Status 一致;OpenAPI 已是最新并提交。
|
||||||
|
- [ ] 校验闸门全绿。
|
||||||
|
|
||||||
|
## 7. 构建上下文完整性(M1 教训)
|
||||||
|
|
||||||
|
- 本里程碑只**新增** `app/services/meters.py`、`app/api/routes/api/meters.py`、`app/schemas/meter.py`、`alembic_app/versions/<rev>_*.py` 与前端文件;均落在现有 `Dockerfile` 的 `COPY app ./app` / `COPY alembic_app ./alembic_app` / 前端构建阶段覆盖范围内,**无新增顶层目录**。
|
||||||
|
- `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 应仍绿;删/移文件时按 README 规则 grep 构建清单(本里程碑预期无删除)。
|
||||||
|
|
||||||
|
## 8. 前端校验闸门
|
||||||
|
|
||||||
|
`frontend/` 下:`npm run lint && npm run typecheck && npm run test && npm run build`;改了端点/schema 还需 `npm run codegen` 并提交 `schema.d.ts`。
|
||||||
|
|
||||||
|
## 9. 后续杠杆(本里程碑不做,留痕)
|
||||||
|
|
||||||
|
- **Home/Site 分组**:若将来要"一个地址下多品类表合并出账 + 整屋归档",再加薄分组层(可仅 `site_label` 字段,不必新实体)。
|
||||||
|
- **多合同时间线积分**:让 `summarize` 跨多个非重叠合同积分固定费/抵扣,免去搬家"加 version"的折中。
|
||||||
|
- **换表时给累计实体发 `last_reset` 信号**:消除 HA 长期统计在归零时刻的一次性负 blip(需给 expose/discovery 加 `last_reset` 管线,FU11 当时为省事未做)。已与用户确认本里程碑**先不做**,先观察、需要时再加。
|
||||||
|
- **Gas / 区域供暖计费**:`commodity!="electricity"` 的 strategy 与寄存器映射。
|
||||||
|
- **当前电表 label 暴露给 HA**、按表/按地址的成本报表分段。
|
||||||
|
|
||||||
|
## 10. 里程碑完成定义(DoD)
|
||||||
|
|
||||||
|
✅ **已完成**(M7-T01..T07 全部 done)
|
||||||
|
|
||||||
|
- `meter` 数据地基 + 回填上线;计费引擎永不跨表算 delta,跨表/无表/异常 delta 一律降级。
|
||||||
|
- 累计量按当前表归零;追溯换表可重算。
|
||||||
|
- Meter CRUD API + 前端管理 UI 可用;OpenAPI/schema 已同步。
|
||||||
|
- 全程数据安全红线无违反;pytest/ruff/前端闸门全绿。
|
||||||
|
- 人工 walkthrough:声明一次(含追溯)换表 → 旧表周期保留、跨表周期降级、新表从零累计、HA 累计实体不出现负/巨额跳变。
|
||||||
@@ -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)
|
||||||
Binary file not shown.
@@ -0,0 +1,198 @@
|
|||||||
|
# DDSU666 Modbus 协议(从官方 PDF 提取)
|
||||||
|
|
||||||
|
> 来源:`docs/references/DDSU666 Single phase Smart Meter.pdf`
|
||||||
|
> (CHINT / 正泰仪表 **DDSU666 Single phase Smart Meter — Operation Manual**,文档号 `ZTY0.464.1224`,版本 **V2**,2020 年 8 月;厂商 Zhejiang Chint Instrument & Meter Co., Ltd.)
|
||||||
|
> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。
|
||||||
|
> 同类文档见 SDM120 的 `SDM120-Modbus-Protocol.md`;两表差异较大,见下方 §6「与 SDM120 的关键差异」。
|
||||||
|
|
||||||
|
## 设备速览(来自手册 Table 1 / Table 5)
|
||||||
|
|
||||||
|
| 项 | DDSU666(直接接入) | DDSU666-CT(经互感器) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 精度等级 | Active Class B | Active Class C |
|
||||||
|
| 参考电压 | 230 V | 230 V |
|
||||||
|
| 电流规格 | 0.25–5(80) A | 0.015–1.5(6) A |
|
||||||
|
| 表常数 | 800 imp/kWh | 6400 imp/kWh |
|
||||||
|
| 接入方式 | 直接接入 | 经电流互感器 |
|
||||||
|
|
||||||
|
- 单相电子式电能表,DIN35mm 导轨安装;测量电压、电流、有功/无功功率、频率、功率因数、正/反向有功电能。
|
||||||
|
- 电能测量范围 `0~999999.99 kWh`(LCD 只显示 6 位,自动移动小数点)。
|
||||||
|
- 通信:RS485,**Modbus-RTU**(也支持 DL/T 645-2007,可切换,见 §5 `0005H ChangeProtocol`)。
|
||||||
|
- 手册 Table 1 标注 Frequency Reference = 60Hz,但 LCD 示例又写 `F=50.00Hz`(手册自身不一致);**实际频率以寄存器 `200EH` 读数为准**,不要把 50/60 写死。
|
||||||
|
|
||||||
|
## 0. 本项目的接入方式(重要)
|
||||||
|
|
||||||
|
DDSU666 物理层是 **Modbus RTU(RS-485 串口)**,半双工。和 SDM120 一样,本项目通过一个 **Modbus-TCP 网关**接入:
|
||||||
|
|
||||||
|
- 后端用 **Modbus TCP**(`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。
|
||||||
|
- TCP 帧用 MBAP header、**无 CRC**(CRC 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `CRC (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。
|
||||||
|
- **Slave Address / Unit ID = 电表的通信地址 Addr**(范围 1–247;面板按键只能设 1–99;见 §5 `0006H`),在 TCP 请求里作为 unit id 传入。
|
||||||
|
- 若以后直连串口(RTU),才需要管波特率 / 数据格式 / CRC:**默认串口格式是 8 数据位、无校验、2 停止位(8N2)**,与 SDM120 的 8N1 不同——直连时务必对齐。
|
||||||
|
- 电表必须处于 **Modbus 协议模式**(而非 DL/T 645)才能用本协议;可经面板长按切换,或写 `0005H = 2`(见 §5)。
|
||||||
|
|
||||||
|
## 1. 协议帧格式(Appendix A)
|
||||||
|
|
||||||
|
异步传输,按字节为单位。一帧 10 位字符 = **1 起始位(0) + 8 数据位(无校验) + 2 停止位(1)**(其它格式可定制)。
|
||||||
|
|
||||||
|
### 信息帧结构(Table A.1)
|
||||||
|
|
||||||
|
| 字段 | 长度 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Start(起始) | >3.5 字符静默 | 帧间至少 3.5 字符空闲时间作为分隔 |
|
||||||
|
| Address code(地址码) | 1 字节 | 目标从机地址 1–247;每个从机在总线上地址唯一 |
|
||||||
|
| Function code(功能码) | 1 字节 | 仅支持 **03H / 10H**(见 §2) |
|
||||||
|
| Data(数据域) | n 字节 | 随功能码不同而不同(起始地址、寄存器数、寄存器数据等) |
|
||||||
|
| CRC check code(CRC 校验) | 2 字节 | 16-bit CRC(**低字节在前、高字节在后**;多项式 `A001`) |
|
||||||
|
| End(结束) | >3.5 字符静默 | 帧间静默 |
|
||||||
|
|
||||||
|
> TCP 网关模式下 Start/End 静默与 CRC 由网关处理,本项目不关心。
|
||||||
|
|
||||||
|
### 功能码 03H 示例(读寄存器,Table A.3/A.4)
|
||||||
|
|
||||||
|
读从机 `01H`、起始地址 `0CH`、2 个寄存器:
|
||||||
|
|
||||||
|
- 主机发送:`01 03 00 0C 00 02 04 08`(最后 `04 08` 是 CRC,低字节 `04` 在前)。
|
||||||
|
- 从机返回(设 `0CH/0DH` 内容为 `0000H`、`1388H`):`01 03 04 00 00 13 88 F7 65`
|
||||||
|
- `04` = 字节数;`00 00` = `0CH` 数据;`13 88` = `0DH` 数据;`F7 65` = CRC(低字节 `F7` 在前)。
|
||||||
|
|
||||||
|
> **注意**:单个 16-bit 寄存器内是「高字节在前、低字节在后」(Table A.4 里 `0DH` 数据返回 `13 88` = `0x1388`)。这一点对解码浮点的字节序很关键,见 §3。
|
||||||
|
|
||||||
|
### 功能码 10H 示例(写多个寄存器,Table A.5/A.6)
|
||||||
|
|
||||||
|
向从机 `01H`、起始地址 `00H` 连续写 3 个寄存器 `0002H,1388H,000AH`:
|
||||||
|
|
||||||
|
- 主机发送:`01 10 00 00 00 03 06 00 02 13 88 00 0A 9B E9`
|
||||||
|
- `06` = 写入字节数(3 寄存器 × 2 字节);随后是 3 个寄存器的数据;末尾 `9B E9` CRC。
|
||||||
|
- 从机返回:`01 10 00 00 00 03 80 08`(回显起始地址 + 寄存器数 + CRC `80 08`)。
|
||||||
|
|
||||||
|
### 异常响应(Table A.7/A.8)
|
||||||
|
|
||||||
|
- 异常时返回的 Function Code = **原功能码 + 128**(即最高位置 1,`03H→83H`、`10H→90H`)。
|
||||||
|
- 数据为单字节 Error Code:
|
||||||
|
|
||||||
|
| Error Code | 含义 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `01H` | Illegal function code | 收到的功能码本表不支持 |
|
||||||
|
| `02H` | Illegal register address | 寄存器地址超出有效范围 |
|
||||||
|
| `03H` | Illegal data value | 数据值超出对应地址的取值范围 |
|
||||||
|
|
||||||
|
## 2. 功能码
|
||||||
|
|
||||||
|
DDSU666 **只支持两个功能码**(Table A.2):
|
||||||
|
|
||||||
|
| 功能码 | 作用 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **03H** | Read register(读寄存器) | 读一个或多个寄存器——**测量值、电量、配置全部走它** |
|
||||||
|
| **10H** | Write multiple registers(写多个寄存器) | 向 n 个连续寄存器写 n 个 16-bit 数据(改配置 / 清电量) |
|
||||||
|
|
||||||
|
> ⚠️ **DDSU666 没有「输入寄存器 / FC04」概念**——所有数据(包括电压电流功率)都用 **FC 03H** 读保持寄存器。这是它和 SDM120(测量值走 FC04)最大的踩坑差异,见 §6。
|
||||||
|
|
||||||
|
## 3. 数据编码
|
||||||
|
|
||||||
|
DDSU666 有两类数据:
|
||||||
|
|
||||||
|
1. **配置 / 参数寄存器(§5,`0000H`–`0010H`)**:每个 1 个寄存器、`16-bit with symbols`(**16 位有符号整数**)。
|
||||||
|
2. **测量 / 电量寄存器(§4,`2000H`+ / `4000H`+)**:每个参数 = **32-bit IEEE-754 单精度浮点**(手册写 “single precision floating decimal”),占 **2 个相邻寄存器**(Length = 2 Word)。
|
||||||
|
|
||||||
|
**浮点字节序 / 字序**
|
||||||
|
|
||||||
|
- **字节序(byte order)= 大端:寄存器内高字节在前** —— 由 Table A.4 的 `0x1388` 返回为 `13 88` 确认。
|
||||||
|
- **字序(word order,两个寄存器谁是高 16 位)**:手册**没有给出浮点解码的实例**,未明确标注。按标准 Modbus 浮点惯例应为**大端字序(高寄存器在前,`ABCD`)**,与本项目 SDM120 驱动一致(`registers_to_float` 用 `>f`,高寄存器在前)。
|
||||||
|
- ⚠️ **需上机实测确认**:读 `2000H`(电压)应解出 ~230V 这样的合理值;若解出乱数,多半是字序相反,改成「低寄存器在前」再试。CHINT 同系列(DTSU/DDSU666)现场固件偶有字序差异,**首次接入务必用一个已知量(电压)校验**,不要凭手册想当然。
|
||||||
|
|
||||||
|
> Python 解码(大端、高寄存器在前):`struct.unpack('>f', struct.pack('>HH', hi_reg, lo_reg))[0]`。
|
||||||
|
> pymodbus:`BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`。
|
||||||
|
|
||||||
|
## 4. 测量 / 电量寄存器表(FC 03H 读)
|
||||||
|
|
||||||
|
全部为只读、`Float`(32-bit),每项占 **2 个寄存器**。地址为 Modbus 协议原始地址(即帧里的 Start Register Address Hi/Lo),手册用十六进制。
|
||||||
|
|
||||||
|
### 4.1 瞬时量(“Electric quantity of the secondary side”,`2000H` 段)
|
||||||
|
|
||||||
|
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `2000H` | 电压 Voltage | U | V | |
|
||||||
|
| `2002H` | 电流 Current | I | A | |
|
||||||
|
| `2004H` | 有功功率 Active power | P | **kW** | 手册标注 “the unit is KW”——**不是 W** |
|
||||||
|
| `2006H` | 无功功率 Reactive power | Q | **kvar** | |
|
||||||
|
| `2008H` | (保留 RESERVED) | — | — | 占 2 寄存器,跳过 |
|
||||||
|
| `200AH` | 功率因数 Power factor | PF | — | 无量纲 |
|
||||||
|
| `200CH` | (保留 RESERVED) | — | — | 占 2 寄存器,跳过 |
|
||||||
|
| `200EH` | 频率 Frequency | Freq | Hz | |
|
||||||
|
|
||||||
|
### 4.2 电量(“Electrical data of the secondary side”,`4000H` 段)
|
||||||
|
|
||||||
|
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `4000H` | 正向(导入)有功电能 Active in electricity | Ep | kWh | 正向 / forward active energy |
|
||||||
|
| `400AH` | 反向(导出)有功电能 Reverse in electricity | -Ep | kWh | 反向 / reverse active energy |
|
||||||
|
|
||||||
|
> 手册里 `4000H` 与 `400AH` 之间(`4002H`–`4009H`)未列出,视为保留/未文档化。
|
||||||
|
|
||||||
|
**读取分块建议**
|
||||||
|
|
||||||
|
- 瞬时量:`2000H`–`200FH` 共 **16 个寄存器连续**,一次块读即可覆盖 U…Freq(含两段 RESERVED,解码时跳过)。
|
||||||
|
- 电量:`4000H`(2 寄存器)与 `400AH`(2 寄存器)相距较远,分两小块读,或读 `4000H`–`400BH`(12 寄存器)一次取出后挑用——以网关 / 电表是否允许跨保留地址块读为准,谨慎起见分开读更稳。
|
||||||
|
|
||||||
|
### 常用核心子集(日常监控够用)
|
||||||
|
|
||||||
|
电压 `2000H`、电流 `2002H`、有功功率 `2004H`(kW)、功率因数 `200AH`、频率 `200EH`、正向有功电能 `4000H`、反向有功电能 `400AH`。
|
||||||
|
|
||||||
|
## 5. 配置 / 参数寄存器表(FC 03H 读 / FC 10H 写)(Table 9)
|
||||||
|
|
||||||
|
每个 1 个寄存器、**16-bit 有符号整数**。`R/W` 列来自手册。
|
||||||
|
|
||||||
|
| 地址(hex) | 代号 | 含义 | R/W | 取值 / 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `0000H` | UCode | 编程密码 Programming password code | R/W | 写配置前的密码字 |
|
||||||
|
| `0001H` | REV. | 保留;**实际读出的是版本号** | R | |
|
||||||
|
| `0002H` | ClrE | 电能清零 CLr.E | R/W | **写 `1` 清除总电量**(不可逆,慎用) |
|
||||||
|
| `0003H`–`0004H` | RESERVED | 保留 | — | |
|
||||||
|
| `0005H` | ChangeProtocol | 协议切换 | R/W | **`2` = Modbus-RTU**,`1` = DL/T 645-2007 |
|
||||||
|
| `0006H` | Addr | 通信地址 | R/W | 1–247(面板按键仅 1–99) |
|
||||||
|
| `0007H`–`000AH` | RESERVED | 保留 | — | |
|
||||||
|
| `000BH` | Meter type | 表型 Meter type | R | 只读设备类型标识 |
|
||||||
|
| `000CH` | BAud | 通信波特率 | R/W | **`1`=2400bps,`2`=4800bps,`3`=9600bps**(手册寄存器仅列这三档;通信章另提到也支持 1200bps) |
|
||||||
|
| `000DH`–`0010H` | RESERVED | 保留 | — | |
|
||||||
|
|
||||||
|
> ⚠️ 写 `0002H`(清电量)、`0006H`(改地址)、`000CH`(改波特率)、`0005H`(切协议)都会改变电表状态或通信参数,配错可能**清空累计电量**或**导致通信中断**。本项目默认**只读采集**,不在自动化链路里写电表配置寄存器。
|
||||||
|
> 写配置通常需先经 `0000H UCode` 密码字校验;具体密码值手册正文未给出,需向厂商确认或经面板操作。
|
||||||
|
|
||||||
|
## 6. 与 SDM120 的关键差异(迁移 / 复用驱动时必看)
|
||||||
|
|
||||||
|
本项目已有 SDM120 profile(`app/integrations/modbus/profiles/sdm120.yaml`)。DDSU666 **不能照搬**,主要差异:
|
||||||
|
|
||||||
|
| 维度 | SDM120 (Eastron) | DDSU666 (CHINT) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 读测量值功能码 | **FC 04**(输入寄存器 3X) | **FC 03**(保持寄存器,无 FC04) |
|
||||||
|
| 测量值起始地址 | `0x0000` 起(30001) | 瞬时量 `0x2000` 起;电量 `0x4000`/`0x400A` |
|
||||||
|
| 有功功率单位 | **W**(瓦) | **kW(千瓦)** —— 入库前注意换算 / 单位标注 |
|
||||||
|
| 无功功率单位 | VAr | kvar |
|
||||||
|
| 配置寄存器格式 | Float(FC03/16) | **16-bit 有符号整数**(FC03/10) |
|
||||||
|
| 写功能码 | 16 / 0x10 | 10H(同 0x10) |
|
||||||
|
| 串口默认格式 | 8N1(1 停止位) | **8N2(2 停止位)** |
|
||||||
|
| 多协议 | 仅 Modbus | Modbus **与 DL/T 645-2007 可切换**(需确保在 Modbus 模式) |
|
||||||
|
| 浮点字序 | 大端、高寄存器在前(手册有实例佐证) | 字节序大端已确认;**字序手册无实例,需上机实测** |
|
||||||
|
|
||||||
|
## 7. 给本项目采集驱动的要点小结
|
||||||
|
|
||||||
|
1. 走 **Modbus TCP 网关**:`ModbusTcpClient(host, port)`,`slave=<Addr>`;电表须在 **Modbus 协议模式**。
|
||||||
|
2. 所有读取(测量 + 电量 + 配置)都用 **FC 03H**——**没有 FC04**。
|
||||||
|
3. 测量值在 `0x2000` 段、电量在 `0x4000`/`0x400A`,均为 **float32**;解码大端字节序,**字序默认高寄存器在前但务必用电压实测校验**。
|
||||||
|
4. **有功功率单位是 kW、无功是 kvar**——与 SDM120 的 W/VAr 不同,新建 profile / 入库映射时单位别抄错。
|
||||||
|
5. 配置寄存器(`0x0000`–`0x0010`)是 **16-bit 有符号整数**,不是 float。
|
||||||
|
6. 默认**只读**;`0002H` 写 1 会**清空累计电量**、`0006H/000CH/0005H` 会改通信参数,自动化链路里一律不写。
|
||||||
|
7. 新建 profile 时这是 `ddsu666` 这一个 register profile 的定义;建议 `function_code: 3`、`word_order: big`(先按大端字序,接入后用电压读数验证)、瞬时量与电量分块读。
|
||||||
|
|
||||||
|
## 8. 实测记录(真机验证,2026-06-30)
|
||||||
|
|
||||||
|
首次接入一台 **DDSU666 直接接入版(5(80)A)** 实测,确认以下几点:
|
||||||
|
|
||||||
|
- **字序大端,确认无误**:电压 / 电流 / 频率 / 电能用「大端、高寄存器在前」解码全部得到合理值(如 233.9 V / 0.055 A / 49.99 Hz),与 §3 的假设一致。`ddsu666.yaml` 的 `word_order: big` / `byte_order: big` **无需修改**,§3 里「字序需上机实测」一项可视为已关闭。
|
||||||
|
- **FC03 读通**:所有量走 FC03,profile `ddsu666` 在采集链路(CLI `read` / 设备 `/test` / 后台轮询)中工作正常。
|
||||||
|
- **低电流下「瞬时功率读 0、但电能照常累加」**:测试负载仅为一台 PoE 交换机(≈230 V / 0.05 A,真实有功仅几瓦),**远低于本表测量量程下限 Imin≈0.25 A**。此工况下:
|
||||||
|
- 瞬时 `active_power`(`0x2004`)与 `power_factor`(`0x200A`)寄存器返回**全零**(原始 hex `0x0000 0x0000`);电表 LCD 上功率在 0~3.3 W、PF 在 0~0.6 之间抖动。
|
||||||
|
- 抖动成因 = **低电流测量噪声 + 开关电源(SMPS,无 PFC)畸变电流**(电流为电压峰值附近的窄脉冲、谐波重 → 畸变功率因数天然偏低)。**不是**「采样率与开关频率拍频」:计量芯片 SH79F7019 采样在 kHz 量级,远低于开关电源 50–200 kHz 的开关频率,两者不在一个频段。
|
||||||
|
- 但累计电能 `import_energy`(`0x4000`)**正常累加**(实测 0 → 0.01 kWh)——电表内部积分器在防潜动起始电流(`0.004·Ib`≈0.02 A)之上照常计量。
|
||||||
|
- **结论**:低于量程下限时**瞬时功率 / PF 不可信,但电能计量不丢**;电流进入量程(正常负载)后瞬时量即稳定可信。这是 5(80)A 大量程表对极小负载的固有特性,**非缺陷、非解码问题**。
|
||||||
|
- **排错提示**:若日后看到 DDSU666「功率一直 0」,先确认负载电流是否在 Imin 以上——多半是负载太轻而非链路故障;可用 `scripts/modbus_cli probe --fc 3 --address 0x2000 --count 16` 看 `0x2004/0x2005` 原始寄存器是否真为全零佐证。
|
||||||
+70
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
|
本文档记录 `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)
|
## 当前基线(v1.0.3)
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) |
|
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) |
|
||||||
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings)+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
|
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集(YAML profile + JSON readings)+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
|
||||||
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
|
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层(manual/tibber profile + 合同版本)+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费(不可变快照)+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
|
||||||
|
| **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch,计费永不跨表算 delta,跨表/无表/异常 delta 一律降级,累计按当前表归零,追溯换表可重算,Meter CRUD API + 前端管理 UI |
|
||||||
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
||||||
|
|
||||||
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
|
排序原则:**先清地基,再在干净结构上盖楼。** 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 — 开放与移动端(远期试水)
|
## M3 — 开放与移动端(远期试水)
|
||||||
|
|
||||||
### 目标
|
### 目标
|
||||||
@@ -245,11 +288,36 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
|
|||||||
|
|
||||||
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
|
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
|
||||||
|
|
||||||
|
**本次明确的首要目标 = 给现在裸奔的 ingestion 端点上鉴权**(2026-06-27 与用户确认):
|
||||||
|
|
||||||
|
- `POST /location/record`(`app/api/routes/location.py:18`)——位置记录上报。**目前无任何鉴权**。当前数据经 Home Assistant 转发进来,上 token 后 **HA 侧需携带该 token**;也可由其他客户端直接上报。
|
||||||
|
- `POST /poo/record`(`app/api/routes/poo.py:21`)+ `GET /poo/latest`(`poo.py:57`)——小狗排便记录上报 / 最新查询。**目前无任何鉴权**。
|
||||||
|
- 这些是设备 / 脚本(非浏览器)端点,session cookie 不适用,正是 long-lived token 的用武之地。(浏览器 CRUD `/api/data/*` 已由 session 保护,不在此列。)
|
||||||
|
|
||||||
**范围(粗略,待细化)**:
|
**范围(粗略,待细化)**:
|
||||||
|
|
||||||
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
|
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
|
||||||
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。
|
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响);给上述 ingestion 端点加 token 鉴权依赖。
|
||||||
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
|
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
|
||||||
|
- 与下面第 3 条「Session 滑动续期」同属 Authentication 主题(一个是设备/脚本的长期凭据,一个是浏览器短会话体验),实现时鉴权层可一并梳理。
|
||||||
|
|
||||||
|
### 3. Session 滑动自动续期(Authentication)
|
||||||
|
|
||||||
|
**动机**(2026-06-27 与用户确认):当前 session 是**绝对过期**——登录即定死、活动不续期,满 TTL 必须重新登录,体验割裂。希望改成**滑动续期(sliding / rolling)**:只要用户还在活动就自动延长,提供"在用就不掉线"的体验。
|
||||||
|
|
||||||
|
**现状(实现起点,便于快速拾起)**:
|
||||||
|
|
||||||
|
- TTL 默认 **12 小时**:`auth_session_ttl_hours`(`app/config.py:38`;配置页 `app/services/config_page.py:45` 可运行时改)。
|
||||||
|
- 登录时**一次性写死**:`create_session` 设 `expires_at = now + ttl`(`app/services/auth.py:94`)+ cookie `max_age = ttl`(`app/api/routes/api/session.py:153`)。
|
||||||
|
- 每请求**只读校验、从不延长**:`get_authenticated_session`(`app/services/auth.py:103`)只判断 `expires_at <= now`,过期时仅顺手标 `revoked`。`set_cookie` 只在登录路由调用一次,**无 per-request 中间件**。→ 所以是绝对过期,不是滑动。
|
||||||
|
|
||||||
|
**设计要点(待写设计文档时展开)**:
|
||||||
|
|
||||||
|
- 校验通过时 bump `expires_at = now + ttl` 并**重发 cookie**(滑动窗口)。
|
||||||
|
- **写节流**:不要每个请求都写 DB——仅当剩余寿命已过半(或距上次续期 > N 分钟)才续期,避免高频写放大。
|
||||||
|
- **绝对寿命硬顶**:除滑动 TTL 外再设 `created_at + max_lifetime` 上限,防止"永不过期"的会话(安全考量)。
|
||||||
|
- 新增配置项:滑动 TTL、绝对寿命上限、续期节流阈值。
|
||||||
|
- 注意:改动只对**新逻辑生效**,已存在 session 的 `expires_at` 行为按新校验路径走即可;上线前过校验闸门。
|
||||||
|
|
||||||
## Future Ideas(暂不排期,想到先记下)
|
## Future Ideas(暂不排期,想到先记下)
|
||||||
|
|
||||||
|
|||||||
Vendored
+279
@@ -559,6 +559,85 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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 recompute is transparent — the
|
||||||
|
* response body is the created meter (``MeterResponse``) only and does **not**
|
||||||
|
* include a recompute count; callers should re-fetch costs if they need the
|
||||||
|
* updated totals.
|
||||||
|
*/
|
||||||
|
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": {
|
"/api/expose": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1569,6 +1648,114 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
sell_normal: number;
|
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
|
* MetricInfo
|
||||||
* @description Metadata for a single measurable quantity in a device's profile.
|
* @description Metadata for a single measurable quantity in a device's profile.
|
||||||
@@ -3007,6 +3194,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: {
|
get_expose_api_expose_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* 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 date initialisation', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => {
|
||||||
|
// ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight).
|
||||||
|
// The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so
|
||||||
|
// the local date is deterministically '2024-01-15' on any CI runner.
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
|
||||||
|
expect(dateInput.value).toBe('2024-01-15')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initialises date input from started_at (naive, no tz marker)', async () => {
|
||||||
|
// A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z'
|
||||||
|
// so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date
|
||||||
|
// equals the UTC date exactly.
|
||||||
|
const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' }
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
|
||||||
|
expect(dateInput.value).toBe('2024-03-20')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => {
|
||||||
|
// The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00'
|
||||||
|
// suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date.
|
||||||
|
// The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex
|
||||||
|
// and handles explicit offsets properly.
|
||||||
|
const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' }
|
||||||
|
// UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15'
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } })
|
||||||
|
|
||||||
|
renderWithProviders(<MeterManager />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument())
|
||||||
|
await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
|
||||||
|
// Must not be empty (which would indicate Invalid Date from the old buggy path)
|
||||||
|
expect(dateInput.value).not.toBe('')
|
||||||
|
expect(dateInput.value).toBe('2024-01-15')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => {
|
||||||
|
// Open the edit form and immediately submit without changing any fields except label.
|
||||||
|
// The date should be considered unchanged → no started_at in the PATCH body.
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||||
|
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } })
|
||||||
|
|
||||||
|
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 only label; leave date untouched
|
||||||
|
const labelInput = screen.getByTestId('edit-meter-label')
|
||||||
|
await user.clear(labelInput)
|
||||||
|
await user.type(labelInput, 'New label')
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('edit-meter-submit'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPatch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const patchBody = mockPatch.mock.calls[0][1].body
|
||||||
|
expect(patchBody).not.toHaveProperty('started_at')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
/**
|
||||||
|
* 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, parseBackendTimestamp } 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`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
|
||||||
|
* Used to populate <input type="date"> fields.
|
||||||
|
* Returns '' if the Date is invalid.
|
||||||
|
*/
|
||||||
|
function toLocalDateInputString(d: Date): string {
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 started_at to a local date string for the <input type="date">.
|
||||||
|
// parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC,
|
||||||
|
// matching the backend's storage convention), Z-suffixed strings, and strings with
|
||||||
|
// explicit offsets like +02:00. We then extract the local-timezone date components
|
||||||
|
// so the displayed date matches the local wall-clock date of the meter start.
|
||||||
|
const initialDateStr = toLocalDateInputString(parseBackendTimestamp(meter.started_at))
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -214,6 +214,12 @@ export function useMetrics(uuid: string) {
|
|||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
// Re-exported energy types for consumers
|
// 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 ContractResponse = components['schemas']['ContractResponse']
|
||||||
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
|
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
|
||||||
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
|
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)
|
// Query: time-range readings for a device (window + limit — never full-table)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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', () => {
|
describe('EnergyPage — auto-refresh switch', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMe
|
|||||||
import { DeviceForm } from '../energy/DeviceForm'
|
import { DeviceForm } from '../energy/DeviceForm'
|
||||||
import { EnergyCharts } from '../energy/EnergyCharts'
|
import { EnergyCharts } from '../energy/EnergyCharts'
|
||||||
import { ContractManager } from '../energy/ContractManager'
|
import { ContractManager } from '../energy/ContractManager'
|
||||||
|
import { MeterManager } from '../energy/MeterManager'
|
||||||
import { TibberPrices } from '../energy/TibberPrices'
|
import { TibberPrices } from '../energy/TibberPrices'
|
||||||
import { CostView } from '../energy/CostView'
|
import { CostView } from '../energy/CostView'
|
||||||
import { DsmrPanel } from '../energy/DsmrPanel'
|
import { DsmrPanel } from '../energy/DsmrPanel'
|
||||||
@@ -661,6 +662,9 @@ export function EnergyPage() {
|
|||||||
<Tabs.Tab value="devices" data-testid="tab-devices">
|
<Tabs.Tab value="devices" data-testid="tab-devices">
|
||||||
Devices
|
Devices
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="meters" data-testid="tab-meters">
|
||||||
|
Meters
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="contracts" data-testid="tab-contracts">
|
<Tabs.Tab value="contracts" data-testid="tab-contracts">
|
||||||
Contracts
|
Contracts
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -679,6 +683,10 @@ export function EnergyPage() {
|
|||||||
<DevicesTab />
|
<DevicesTab />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="meters" data-testid="panel-meters">
|
||||||
|
<MeterManager />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="contracts" data-testid="panel-contracts">
|
<Tabs.Panel value="contracts" data-testid="panel-contracts">
|
||||||
<ContractManager />
|
<ContractManager />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -20,5 +20,13 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./src/test-setup.ts'],
|
setupFiles: ['./src/test-setup.ts'],
|
||||||
|
env: {
|
||||||
|
// Lock the test timezone to UTC so that date-formatting assertions
|
||||||
|
// (which rely on toLocalDateInputString / getFullYear etc.) produce
|
||||||
|
// deterministic results regardless of the CI runner's local timezone.
|
||||||
|
// All fixture timestamps are UTC midnight, so the expected YYYY-MM-DD
|
||||||
|
// values in MeterManager.test.tsx are correct under UTC.
|
||||||
|
TZ: 'UTC',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 recompute is transparent — the\nresponse body is the created meter (``MeterResponse``) only and does **not**\ninclude a recompute count; callers should re-fetch costs if they need the\nupdated totals.",
|
||||||
|
"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": {
|
"/api/expose": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3334,6 +3483,198 @@
|
|||||||
"title": "ManualTariffSchema",
|
"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."
|
"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": {
|
"MetricInfo": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"key": {
|
"key": {
|
||||||
|
|||||||
@@ -1077,6 +1077,140 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
$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 recompute is transparent — the
|
||||||
|
|
||||||
|
response body is the created meter (``MeterResponse``) only and does **not**
|
||||||
|
|
||||||
|
include a recompute count; callers should re-fetch costs if they need the
|
||||||
|
|
||||||
|
updated totals.'
|
||||||
|
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:
|
/api/expose:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -2595,6 +2729,182 @@ components:
|
|||||||
Prices are the *effective* buy prices as used by the billing engine
|
Prices are the *effective* buy prices as used by the billing engine
|
||||||
|
|
||||||
(energy_buy_x + energy_tax + ode) and the raw sell prices.'
|
(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:
|
MetricInfo:
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
APP_BASELINE_REVISION = "20260624_12_dsmr_decouple_telegram_id"
|
APP_BASELINE_REVISION = "20260625_14_meter_uuid"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ def cmd_read(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
print(f"Profile : {profile.name} — {profile.description}")
|
print(f"Profile : {profile.name} — {profile.description}")
|
||||||
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
print(f"Gateway : {host}:{port} unit_id={unit_id}")
|
||||||
|
print(f"Function : FC{profile.function_code:02d}")
|
||||||
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ def cmd_read(args: argparse.Namespace) -> None:
|
|||||||
from app.integrations.modbus.driver import read_blocks
|
from app.integrations.modbus.driver import read_blocks
|
||||||
|
|
||||||
try:
|
try:
|
||||||
registers = read_blocks(host, port, unit_id, blocks)
|
registers = read_blocks(host, port, unit_id, blocks, function_code=profile.function_code)
|
||||||
except ModbusConnectionError as exc:
|
except ModbusConnectionError as exc:
|
||||||
print(f"Connection error: {exc}", file=sys.stderr)
|
print(f"Connection error: {exc}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -0,0 +1,755 @@
|
|||||||
|
"""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(autouse=True)
|
||||||
|
def mock_publish_discovery():
|
||||||
|
"""Auto-mock publish_discovery for all tests in this module.
|
||||||
|
|
||||||
|
The meters API now calls _trigger_discovery_republish (best-effort) after
|
||||||
|
every successful write. publish_discovery is lazy-imported inside that
|
||||||
|
helper, so we patch it at its canonical source path
|
||||||
|
(app.services.ha_discovery.publish_discovery). Tests that need to assert
|
||||||
|
the call receive this fixture explicitly; all others benefit from the
|
||||||
|
isolation it provides (no live MQTT broker required).
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"app.services.ha_discovery.publish_discovery",
|
||||||
|
return_value=None,
|
||||||
|
) as mock:
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@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 (recompute mocked to avoid slow computation over empty quarters)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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 & boundary update (recompute mocked) — 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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FUE-T06: HA discovery re-publish triggered after meter writes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
|
||||||
|
"""POST /api/energy/meters triggers publish_discovery after successful commit."""
|
||||||
|
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="Discovery Meter", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
# publish_discovery must have been called exactly once after the declare.
|
||||||
|
mock_publish_discovery.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
|
||||||
|
"""PATCH /api/energy/meters/{id} triggers publish_discovery after successful commit."""
|
||||||
|
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="Original Label", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
meter_id = resp.json()["id"]
|
||||||
|
# Reset call count: the POST above also called publish_discovery.
|
||||||
|
mock_publish_discovery.reset_mock()
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/energy/meters/{meter_id}",
|
||||||
|
json={"label": "Renamed Label"},
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# publish_discovery must have been called exactly once after the PATCH.
|
||||||
|
mock_publish_discovery.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_meter_succeeds_when_publish_discovery_raises(meters_client):
|
||||||
|
"""publish_discovery raising an exception must NOT cause POST declare to return 500.
|
||||||
|
|
||||||
|
The _trigger_discovery_republish helper is best-effort: it swallows all
|
||||||
|
exceptions so that a broken MQTT / discovery layer never breaks the API.
|
||||||
|
"""
|
||||||
|
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),
|
||||||
|
patch(
|
||||||
|
"app.services.ha_discovery.publish_discovery",
|
||||||
|
side_effect=RuntimeError("MQTT broker unreachable"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/energy/meters",
|
||||||
|
json=_declare_payload(label="Best Effort Meter", started_at=t0.isoformat()),
|
||||||
|
headers={"X-CSRF-Token": _CSRF},
|
||||||
|
)
|
||||||
|
# The meter must be created successfully despite the discovery failure.
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["label"] == "Best Effort Meter"
|
||||||
+1038
-57
File diff suppressed because it is too large
Load Diff
+1126
-136
File diff suppressed because it is too large
Load Diff
+460
-9
@@ -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:
|
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.
|
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.
|
JSON columns are present; unique and index constraints are correct.
|
||||||
3. Baseline constant: APP_BASELINE_REVISION matches the actual Alembic head.
|
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.
|
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.
|
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
|
from __future__ import annotations
|
||||||
@@ -27,6 +29,7 @@ from app.models.energy import (
|
|||||||
DsmrReading,
|
DsmrReading,
|
||||||
EnergyContract,
|
EnergyContract,
|
||||||
EnergyContractVersion,
|
EnergyContractVersion,
|
||||||
|
Meter,
|
||||||
TibberPrice,
|
TibberPrice,
|
||||||
EnergyCostPeriod,
|
EnergyCostPeriod,
|
||||||
)
|
)
|
||||||
@@ -92,10 +95,11 @@ def energy_db_with_fk(tmp_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
def test_energy_tables_exist_after_upgrade(energy_db):
|
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)
|
inspector = inspect(energy_db)
|
||||||
table_names = inspector.get_table_names()
|
table_names = inspector.get_table_names()
|
||||||
expected_tables = {
|
expected_tables = {
|
||||||
|
"meter",
|
||||||
"dsmr_reading",
|
"dsmr_reading",
|
||||||
"energy_contract",
|
"energy_contract",
|
||||||
"energy_contract_version",
|
"energy_contract_version",
|
||||||
@@ -200,7 +204,7 @@ def test_energy_cost_period_columns(energy_db):
|
|||||||
"import_cost", "export_revenue", "net_cost", "currency",
|
"import_cost", "export_revenue", "net_cost", "currency",
|
||||||
"pricing", "degraded", "computed_at",
|
"pricing", "degraded", "computed_at",
|
||||||
}
|
}
|
||||||
nullable = {"contract_version_id"}
|
nullable = {"contract_version_id", "meter_id"}
|
||||||
|
|
||||||
for col_name in non_nullable:
|
for col_name in non_nullable:
|
||||||
assert col_name in columns, f"Missing column: {col_name}"
|
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."""
|
"""energy_cost_period.contract_version_id must have a FK referencing energy_contract_version.id."""
|
||||||
inspector = inspect(energy_db)
|
inspector = inspect(energy_db)
|
||||||
fks = inspector.get_foreign_keys("energy_cost_period")
|
fks = inspector.get_foreign_keys("energy_cost_period")
|
||||||
assert len(fks) == 1, f"Expected 1 FK on energy_cost_period, got {len(fks)}"
|
# Two FKs: contract_version_id → energy_contract_version.id
|
||||||
fk = fks[0]
|
# 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 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"]
|
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():
|
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 = {
|
expected = {
|
||||||
|
"meter",
|
||||||
"dsmr_reading",
|
"dsmr_reading",
|
||||||
"energy_contract",
|
"energy_contract",
|
||||||
"energy_contract_version",
|
"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():
|
def test_dsmr_reading_recorded_at_unique_in_metadata():
|
||||||
"""DsmrReading.recorded_at must be the unique de-dup key in ORM 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)."""
|
and source_id must NOT be unique (decoupled from the telegram id)."""
|
||||||
@@ -739,3 +780,413 @@ def test_cost_period_restrict_prevents_version_deletion(tmp_path: Path):
|
|||||||
session.commit()
|
session.commit()
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
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", "uuid", "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_uuid_unique_constraint(energy_db):
|
||||||
|
"""meter.uuid must have a unique constraint."""
|
||||||
|
inspector = inspect(energy_db)
|
||||||
|
unique_constraints = inspector.get_unique_constraints("meter")
|
||||||
|
unique_cols = [col for uc in unique_constraints for col in uc["column_names"]]
|
||||||
|
assert "uuid" in unique_cols, "meter.uuid must have a unique constraint"
|
||||||
|
|
||||||
|
|
||||||
|
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 "uuid" 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_uuid_unique_in_metadata():
|
||||||
|
"""Meter.uuid must be declared unique and not nullable in ORM metadata."""
|
||||||
|
table = Base.metadata.tables["meter"]
|
||||||
|
col = table.columns["uuid"]
|
||||||
|
assert col.unique, "Meter.uuid must be declared unique in ORM metadata"
|
||||||
|
assert not col.nullable, "Meter.uuid must be NOT NULL in ORM metadata"
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -205,27 +205,58 @@ def test_register_provider_direct_call():
|
|||||||
def test_build_catalog_empty_with_no_devices(expose_db):
|
def test_build_catalog_empty_with_no_devices(expose_db):
|
||||||
"""build_catalog with no enabled modbus devices must contain no modbus entities.
|
"""build_catalog with no enabled modbus devices must contain no modbus entities.
|
||||||
|
|
||||||
The energy_cost provider is always registered and always produces its 6 entities
|
FUE-T05: the energy_cost provider now requires an active electricity meter.
|
||||||
(4 original + 2 daily) regardless of device state, so the catalog will not be empty.
|
Without one, it returns [] and the catalog contains no energy entities.
|
||||||
This test checks that no *modbus* entities are present when there are no enabled
|
With one, it produces 6 entities.
|
||||||
modbus devices.
|
|
||||||
|
This test verifies both cases:
|
||||||
|
1. No modbus devices → no modbus entities.
|
||||||
|
2. No active meter → no energy entities (provider returns []).
|
||||||
|
3. After inserting an active meter → 6 energy entities present.
|
||||||
"""
|
"""
|
||||||
from app.integrations.expose import build_catalog
|
from app.integrations.expose import build_catalog
|
||||||
|
|
||||||
|
# Case: no modbus devices, no active meter → catalog is empty.
|
||||||
with Session(expose_db) as session:
|
with Session(expose_db) as session:
|
||||||
catalog = build_catalog(session)
|
catalog = build_catalog(session)
|
||||||
|
|
||||||
# The modbus provider finds no enabled devices → no modbus entities.
|
|
||||||
# The energy_cost provider always produces 6 entities, so the catalog is non-empty.
|
|
||||||
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
|
modbus_entities = [e for e in catalog if e.entity.key.startswith("modbus.")]
|
||||||
assert modbus_entities == [], (
|
assert modbus_entities == [], (
|
||||||
"Expected no modbus entities when no modbus devices are enabled"
|
"Expected no modbus entities when no modbus devices are enabled"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The 6 energy_cost entities should always be present (4 original + 2 daily).
|
# No active electricity meter → energy-cost provider returns [] → no energy entities.
|
||||||
energy_keys = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
energy_keys_no_meter = {e.entity.key for e in catalog if e.entity.key.startswith("energy.")}
|
||||||
assert len(energy_keys) == 6, (
|
assert len(energy_keys_no_meter) == 0, (
|
||||||
f"Expected exactly 6 energy_cost entities (4 original + 2 daily), got {energy_keys!r}"
|
f"Expected 0 energy_cost entities (no active meter), got {energy_keys_no_meter!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert an active electricity meter → provider should now produce 6 entities.
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
from app.models.energy import Meter
|
||||||
|
m = Meter(
|
||||||
|
label="Test Meter for catalog",
|
||||||
|
commodity="electricity",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=None,
|
||||||
|
reason="initial",
|
||||||
|
note=None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(m)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(expose_db) as session:
|
||||||
|
catalog_with_meter = build_catalog(session)
|
||||||
|
|
||||||
|
energy_keys_with_meter = {
|
||||||
|
e.entity.key for e in catalog_with_meter if e.entity.key.startswith("energy.")
|
||||||
|
}
|
||||||
|
assert len(energy_keys_with_meter) == 6, (
|
||||||
|
f"Expected exactly 6 energy_cost entities (4 original + 2 daily) with active meter, "
|
||||||
|
f"got {energy_keys_with_meter!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,639 @@
|
|||||||
|
"""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"
|
||||||
|
|
||||||
|
def test_declare_meter_generates_uuid(self, session: Session):
|
||||||
|
"""declare_meter must auto-generate a non-empty uuid via ORM default."""
|
||||||
|
import re
|
||||||
|
UUID4_RE = re.compile(
|
||||||
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
m = declare_meter(
|
||||||
|
session,
|
||||||
|
label="Meter with UUID",
|
||||||
|
started_at=_T0,
|
||||||
|
reason="initial",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
fetched = session.get(Meter, m.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.uuid is not None, "uuid must not be None after declare_meter"
|
||||||
|
assert fetched.uuid != "", "uuid must not be empty"
|
||||||
|
assert UUID4_RE.match(fetched.uuid), (
|
||||||
|
f"uuid {fetched.uuid!r} does not look like a valid UUID v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_declare_meter_each_gets_distinct_uuid(self, session: Session):
|
||||||
|
"""Each declared meter must receive a distinct UUID (not duplicated)."""
|
||||||
|
m1 = declare_meter(session, label="M1", started_at=_T0, reason="initial")
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
t1 = _T0 + timedelta(days=10)
|
||||||
|
m2 = declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert m1.uuid != m2.uuid, (
|
||||||
|
f"Two declared meters must have distinct UUIDs; both got {m1.uuid!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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)
|
||||||
@@ -217,6 +217,57 @@ class TestReadBlocks:
|
|||||||
|
|
||||||
mock_client.close.assert_called_once()
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_default_function_code_uses_fc04_input_registers(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""With no function_code given, read_blocks uses FC04 (read_input_registers)."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_input_registers.return_value = _make_ok_response([0x4366, 0x3334])
|
||||||
|
|
||||||
|
read_blocks("127.0.0.1", 502, 1, [{"start": 0x0000, "count": 2}])
|
||||||
|
|
||||||
|
mock_client.read_input_registers.assert_called_once_with(0x0000, count=2, device_id=1)
|
||||||
|
mock_client.read_holding_registers.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_function_code_3_uses_fc03_holding_registers(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""function_code=3 dispatches FC03 (read_holding_registers), e.g. DDSU666."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
mock_client.connect.return_value = True
|
||||||
|
mock_client.read_holding_registers.return_value = _make_ok_response(
|
||||||
|
[0x4366, 0x3334, 0x3F80, 0x0000]
|
||||||
|
)
|
||||||
|
|
||||||
|
blocks = [{"start": 0x2000, "count": 4}]
|
||||||
|
result = read_blocks("127.0.0.1", 502, 1, blocks, function_code=3)
|
||||||
|
|
||||||
|
assert result == {0x2000: 0x4366, 0x2001: 0x3334, 0x2002: 0x3F80, 0x2003: 0x0000}
|
||||||
|
mock_client.read_holding_registers.assert_called_once_with(
|
||||||
|
0x2000, count=4, device_id=1
|
||||||
|
)
|
||||||
|
mock_client.read_input_registers.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
|
def test_invalid_function_code_raises_before_connecting(
|
||||||
|
self, mock_client_cls: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""An unsupported function code is rejected without opening a connection."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
with pytest.raises(ModbusDriverError, match="Unsupported read function code"):
|
||||||
|
read_blocks("127.0.0.1", 502, 1, [{"start": 0, "count": 2}], function_code=16)
|
||||||
|
|
||||||
|
# No client should have been constructed or connected for a bad FC.
|
||||||
|
mock_client_cls.assert_not_called()
|
||||||
|
mock_client.connect.assert_not_called()
|
||||||
|
|
||||||
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
@patch("app.integrations.modbus.driver.ModbusTcpClient")
|
||||||
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
|
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
|
||||||
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
|
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
|
||||||
|
|||||||
+80
-25
@@ -69,22 +69,28 @@ _THREE_NODES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
_PRICE_RANGE_RESPONSE = {
|
def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict:
|
||||||
|
"""Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response."""
|
||||||
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"homes": [
|
"homes": [
|
||||||
{
|
{
|
||||||
"id": "home-id-1",
|
"id": "home-id-1",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": _THREE_NODES,
|
"today": today,
|
||||||
|
"tomorrow": tomorrow if tomorrow is not None else [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES)
|
||||||
|
|
||||||
_CURRENT_PRICE_RESPONSE = {
|
_CURRENT_PRICE_RESPONSE = {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -173,23 +179,8 @@ def test_fetch_price_range_parses_nodes(monkeypatch):
|
|||||||
|
|
||||||
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
||||||
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
|
||||||
# Build a response with a single node only.
|
# Build a response with a single today node and no tomorrow yet.
|
||||||
one_node_response = {
|
one_node_response = _price_info_response([_THREE_NODES[0]])
|
||||||
"data": {
|
|
||||||
"viewer": {
|
|
||||||
"homes": [
|
|
||||||
{
|
|
||||||
"id": "home-id-1",
|
|
||||||
"currentSubscription": {
|
|
||||||
"priceInfoRange": {
|
|
||||||
"nodes": [_THREE_NODES[0]],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transport = _make_transport(200, one_node_response)
|
transport = _make_transport(200, one_node_response)
|
||||||
|
|
||||||
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
@@ -202,6 +193,68 @@ def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
|
|||||||
assert len(points) == 1
|
assert len(points) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_concatenates_today_and_tomorrow(monkeypatch):
|
||||||
|
"""today and tomorrow node lists are both parsed (today first, then tomorrow)."""
|
||||||
|
tomorrow_nodes = [
|
||||||
|
{
|
||||||
|
"startsAt": "2026-06-24T00:00:00.000+02:00",
|
||||||
|
"total": 0.40,
|
||||||
|
"energy": 0.32,
|
||||||
|
"tax": 0.08,
|
||||||
|
"currency": "EUR",
|
||||||
|
"level": "EXPENSIVE",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
response = _price_info_response(_THREE_NODES, tomorrow_nodes)
|
||||||
|
transport = _make_transport(200, response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
# 3 today + 1 tomorrow, in order.
|
||||||
|
assert len(points) == 4
|
||||||
|
# First node is today's first; last node is tomorrow's.
|
||||||
|
assert points[0].starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC)
|
||||||
|
# 2026-06-24T00:00:00+02:00 → 2026-06-23T22:00:00Z
|
||||||
|
assert points[-1].starts_at == datetime(2026, 6, 23, 22, 0, 0, tzinfo=UTC)
|
||||||
|
assert points[-1].total == pytest.approx(0.40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_price_range_tomorrow_null_returns_today_only(monkeypatch):
|
||||||
|
"""A null tomorrow (before day-ahead publication) yields today's nodes only."""
|
||||||
|
response = {
|
||||||
|
"data": {
|
||||||
|
"viewer": {
|
||||||
|
"homes": [
|
||||||
|
{
|
||||||
|
"id": "home-id-1",
|
||||||
|
"currentSubscription": {
|
||||||
|
"priceInfo": {
|
||||||
|
"today": _THREE_NODES,
|
||||||
|
"tomorrow": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport = _make_transport(200, response)
|
||||||
|
|
||||||
|
def _patched_post(url, *, json, headers, timeout): # noqa: A002
|
||||||
|
client = httpx.Client(transport=transport)
|
||||||
|
return client.post(url, json=json, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
|
||||||
|
|
||||||
|
points = fetch_price_range(_FAKE_TOKEN)
|
||||||
|
assert len(points) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_price_range_home_id_selection(monkeypatch):
|
def test_fetch_price_range_home_id_selection(monkeypatch):
|
||||||
"""When home_id is specified, the matching home is selected."""
|
"""When home_id is specified, the matching home is selected."""
|
||||||
two_homes_response = {
|
two_homes_response = {
|
||||||
@@ -211,16 +264,18 @@ def test_fetch_price_range_home_id_selection(monkeypatch):
|
|||||||
{
|
{
|
||||||
"id": "home-id-first",
|
"id": "home-id-first",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": [_THREE_NODES[0]],
|
"today": [_THREE_NODES[0]],
|
||||||
|
"tomorrow": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "home-id-second",
|
"id": "home-id-second",
|
||||||
"currentSubscription": {
|
"currentSubscription": {
|
||||||
"priceInfoRange": {
|
"priceInfo": {
|
||||||
"nodes": [_THREE_NODES[1], _THREE_NODES[2]],
|
"today": [_THREE_NODES[1], _THREE_NODES[2]],
|
||||||
|
"tomorrow": [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user