6 Commits
Author SHA1 Message Date
tliu93 90a03e7fd6 FUE-T09: grace-shift *_today daily reset past local midnight + dedicated 00:00:10 publish
docker-image / build-and-push (push) Successful in 4m29s
frontend / frontend (push) Successful in 2m17s
pytest / test (push) Successful in 11m2s
2026-06-26 12:21:51 +02:00
tliu93 d3fc90b320 FUE-T08: defer daily fixed-fee/heffingskorting settlement to local 01:05 in summarize() 2026-06-26 11:44:34 +02:00
tliu93 d4acfc438a FUE-T07: use meter label directly as energy-cost HA device name (drop 'Energy Cost' prefix)
frontend / frontend (push) Successful in 2m14s
pytest / test (push) Successful in 10m49s
2026-06-25 21:28:13 +02:00
tliu93 c26160b10b FUE-T06: trigger HA discovery republish after meter declare/update
frontend / frontend (push) Successful in 2m11s
pytest / test (push) Successful in 10m36s
2026-06-25 20:54:42 +02:00
tliu93 efbe36d7c0 FUE-T05: anchor energy-cost HA device identity to active meter (uuid id, label name, empty when none) 2026-06-25 20:43:07 +02:00
tliu93 f663981cdb FUE-T04: add Meter.uuid (stable HA identity anchor) + backfill migration 2026-06-25 20:20:01 +02:00
13 changed files with 1540 additions and 120 deletions
@@ -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")
+28
View File
@@ -79,6 +79,24 @@ router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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: def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
"""Return the meter with the given id or raise 404.""" """Return the meter with the given id or raise 404."""
meter: Optional[Meter] = db.get(Meter, meter_id) meter: Optional[Meter] = db.get(Meter, meter_id)
@@ -217,6 +235,11 @@ def declare_energy_meter(
db.commit() db.commit()
db.refresh(new_meter) 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( logger.info(
"POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s", "POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
body.commodity, body.commodity,
@@ -294,6 +317,11 @@ def patch_energy_meter(
db.commit() db.commit()
db.refresh(meter) 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( logger.info(
"PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s", "PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
meter_id, meter_id,
+73 -15
View File
@@ -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,
) )
@@ -687,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)
@@ -724,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)
+27
View File
@@ -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
@@ -36,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__)
@@ -159,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()
@@ -233,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
+10
View File
@@ -11,6 +11,7 @@ Six 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
@@ -20,6 +21,10 @@ 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): class Meter(Base):
"""One physical electricity meter's installation epoch. """One physical electricity meter's installation epoch.
@@ -47,6 +52,11 @@ class Meter(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Stable internal identity — used as HA Discovery unique_id anchor.
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). # Human-readable label for this physical meter (e.g. address, serial, tariff zone).
label: Mapped[str] = mapped_column(String(255), nullable=False) label: Mapped[str] = mapped_column(String(255), nullable=False)
+16 -3
View File
@@ -123,6 +123,10 @@ _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# degraded to prevent negative costs or grossly inflated charges. # degraded to prevent negative costs or grossly inflated charges.
_MAX_DELTA_KWH = Decimal("100") _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)
@@ -764,7 +768,8 @@ 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) ---
# #
@@ -811,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.
+1 -1
View File
@@ -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 = "20260625_13_meter_table" APP_BASELINE_REVISION = "20260625_14_meter_uuid"
class AppDatabaseAdoptionError(RuntimeError): class AppDatabaseAdoptionError(RuntimeError):
+93
View File
@@ -72,6 +72,24 @@ def _declare_payload(**overrides) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@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() @pytest.fixture()
def meters_client(auth_database): def meters_client(auth_database):
"""TestClient + SQLAlchemy engine for Meter API tests.""" """TestClient + SQLAlchemy engine for Meter API tests."""
@@ -660,3 +678,78 @@ def test_patch_started_at_earlier_updates_boundary(meters_client):
if ended is not None and ended.tzinfo is None: if ended is not None and ended.tzinfo is None:
ended = ended.replace(tzinfo=UTC) ended = ended.replace(tzinfo=UTC)
assert ended == t1_earlier 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"
+315 -14
View File
@@ -1199,10 +1199,16 @@ def _ams_midnight(year: int, month: int, day: int) -> datetime:
class TestSummarizePrincipleC: class TestSummarizePrincipleC:
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone.""" """Principle C whole-day counting with pinned Europe/Amsterdam timezone.
Tests that assert a specific "today" (e.g. June 25) must also pin
``local_now`` via *pinned_now* to remain deterministic as wall-clock
time advances. Tests that only care about windows entirely in the past
or entirely in the future do not need to pin ``local_now``.
"""
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC. # Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
# Today local = June 25 CEST (since today is 2026-06-25). # Reference "today" pinned in individual tests = June 25 CEST.
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None: def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
"""Create an active manual contract with a single version.""" """Create an active manual contract with a single version."""
@@ -1210,10 +1216,27 @@ class TestSummarizePrincipleC:
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc) _make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
session.commit() session.commit()
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict: def _run_summarize_ams(
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.""" self,
session: Session,
start_utc: datetime,
end_utc: datetime,
*,
pinned_now: datetime | None = None,
) -> dict:
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.
If *pinned_now* is given, also patches ``app.services.energy_cost.local_now``
to that fixed value, making settlement-offset logic deterministic
regardless of wall-clock time.
"""
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
if pinned_now is not None:
with patch.object(_ec, "local_now", return_value=pinned_now):
return summarize(session, start_utc, end_utc)
return summarize(session, start_utc, end_utc) return summarize(session, start_utc, end_utc)
def test_today_window_counts_1_day(self, energy_db: Session) -> None: def test_today_window_counts_1_day(self, energy_db: Session) -> None:
@@ -1259,7 +1282,10 @@ class TestSummarizePrincipleC:
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None: def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25). """This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
Today = June 25 local, so June 2630 are not yet elapsed. Pins ``local_now`` to June 25 noon AMS so the test is deterministic
regardless of the actual wall-clock date. June 25 is well past the
01:05 settlement offset, so ``settled_cap = June 25``.
Today = June 25 local, so June 2630 are not yet elapsed → 25 days.
Matches table row: 6/1→7/1 (This Month) → 25 days. Matches table row: 6/1→7/1 (This Month) → 25 days.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1267,7 +1293,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 1) start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
result = self._run_summarize_ams(energy_db, start, end) # Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -1304,8 +1332,9 @@ class TestSummarizePrincipleC:
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None: def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
"""A window partially in the future caps at today (only elapsed days counted). """A window partially in the future caps at today (only elapsed days counted).
Pins ``local_now`` to June 25 noon AMS so the test is deterministic.
Window: June 25 → June 28 local (4 dates, but June 26/27 are future). Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
Today = June 25 → only 1 day elapsed (June 25). Today = June 25 → settled_cap = June 25 → only 1 day elapsed (June 25).
Matches table row: 6/25→6/28 → 1 day. Matches table row: 6/25→6/28 → 1 day.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1313,7 +1342,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 25) start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 6, 28) end = _ams_midnight(2026, 6, 28)
result = self._run_summarize_ams(energy_db, start, end) # Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -1366,7 +1397,11 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 1) start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
# Pin local_now to June 25 2026 noon AMS: today=June 25, settled_cap=June 25.
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
with patch.object(_ec, "local_now", return_value=pinned_now):
result = summarize(energy_db, start, end) result = summarize(energy_db, start, end)
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30 # V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
@@ -1408,11 +1443,15 @@ class TestSummarizePrincipleC:
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from) _make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
energy_db.commit() energy_db.commit()
# Window = June 25 only (today, 1 day elapsed at V2 rate) # Window = June 25 only (today, 1 day elapsed at V2 rate).
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
start = _ams_midnight(2026, 6, 25) start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
with patch.object(_ec, "local_now", return_value=pinned_now):
result = summarize(energy_db, start, end) result = summarize(energy_db, start, end)
# Only 1 day at V2 rate # Only 1 day at V2 rate
@@ -1434,13 +1473,11 @@ class TestSummarizePrincipleC:
within the day start_utc falls. June 24 is the anchor day → its charge is counted. within the day start_utc falls. June 24 is the anchor day → its charge is counted.
Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)). Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)).
Today local = June 25 (2026-06-25). Pins local_now to June 25 noon AMS → today=June 25, settled_cap=June 25.
first_counted = June 24 (anchor day, previously dropped). first_counted = June 24 (anchor day, previously dropped).
last_counted = min(June 26, June 25) = June 25. last_counted = min(June 26, June 25) = June 25.
n_days = June 25 - June 24 + 1 = 2. n_days = June 25 - June 24 + 1 = 2.
""" """
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc) self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
@@ -1449,8 +1486,9 @@ class TestSummarizePrincipleC:
# end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped) # end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped)
end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC) end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
with patch.object(tz_module, "local_tz", return_value=_ams()): # Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc) pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -2409,3 +2447,266 @@ class TestMeterAwareComputePeriod:
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard" assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
assert row.import_cost == 0.0 assert row.import_cost == 0.0
assert row.net_cost == 0.0 assert row.net_cost == 0.0
# ---------------------------------------------------------------------------
# FUE-T08. summarize — settlement offset (local 01:05)
# ---------------------------------------------------------------------------
class TestSummarizeSettlementOffset:
"""FUE-T08: daily fixed-fee/heffingskorting settled after local 01:05.
Reference date: June 26, 2026 AMS (CEST = UTC+2).
- AMS midnight June 26 = June 25 22:00 UTC
- Settlement threshold = June 25 22:00 + 01:05 = June 25 23:05 UTC
= June 26 01:05 AMS
- "before offset" now = June 26 00:30 AMS = June 25 22:30 UTC
- "after offset" now = June 26 01:10 AMS = June 25 23:10 UTC
All tests monkeypatch both local_tz (Europe/Amsterdam) and
``app.services.energy_cost.local_now`` to be fully deterministic.
"""
# UTC instants used as "now":
# June 25 22:30 UTC = June 26 00:30 AMS (before settlement threshold 23:05 UTC)
_NOW_BEFORE = datetime(2026, 6, 25, 22, 30, 0, tzinfo=UTC)
# June 25 23:10 UTC = June 26 01:10 AMS (after settlement threshold 23:05 UTC)
_NOW_AFTER = datetime(2026, 6, 25, 23, 10, 0, tzinfo=UTC)
def _setup_single_version_contract(self, session: Session) -> None:
"""Insert a manual contract effective Jan 1 2026 (covers all test dates)."""
c = _make_contract(session, kind="manual", active=True)
_make_version(session, c, _MANUAL_VALUES, effective_from=_ams_midnight(2026, 1, 1))
session.commit()
def _run(
self,
session: Session,
start_utc: datetime,
end_utc: datetime,
*,
now_utc: datetime,
) -> dict:
"""Run summarize with AMS timezone and pinned local_now.
*now_utc* is converted to AMS before being used as the ``local_now``
return value, so ``.date()`` yields the correct AMS local date.
"""
from unittest.mock import patch
import app.services.energy_cost as _ec
now_ams = now_utc.astimezone(_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()):
with patch.object(_ec, "local_now", return_value=now_ams):
return summarize(session, start_utc, end_utc)
# --- AC1: before 01:05 → today not settled → fixed_costs/credits == 0 ---
def test_today_window_before_offset_yields_zero(self, energy_db: Session) -> None:
"""AC1: At local 00:30 (before 01:05), today's window → credits == 0 and fixed_costs == 0.
Window: [June 26 AMS midnight, June 27 AMS midnight).
now = June 26 00:30 AMS (before 01:05) → settled_cap = June 25.
first_counted = June 26 > last_counted = June 25 → 0 days.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
end = _ams_midnight(2026, 6, 27) # June 26 22:00 UTC
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
assert result["fixed_costs"] == 0.0, (
"AC1: before settlement offset, today's fixed_costs must be 0; "
f"got {result['fixed_costs']}"
)
assert result["credits"] == 0.0, (
"AC1: before settlement offset, today's credits must be 0; "
f"got {result['credits']}"
)
# --- AC2: >= 01:05 → today settled → 1 day fixed/credits ---
def test_today_window_after_offset_yields_one_day(self, energy_db: Session) -> None:
"""AC2: At local 01:10 (>= 01:05), today's window → 1 day of fixed/credits.
Window: [June 26 AMS midnight, June 27 AMS midnight).
now = June 26 01:10 AMS (after 01:05) → settled_cap = June 26.
first_counted = June 26 = last_counted → 1 day.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26)
end = _ams_midnight(2026, 6, 27)
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed) < 1e-9, (
"AC2: after settlement offset, today's fixed_costs must equal 1 day; "
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit) < 1e-9, (
"AC2: after settlement offset, today's credits must equal 1 day; "
f"expected {daily_credit:.6f}, got {result['credits']}"
)
# --- AC3a: cumulative window, before offset → today (June 26) not counted ---
def test_cumulative_before_offset_excludes_today(self, energy_db: Session) -> None:
"""AC3: Cumulative window at 00:30 (before offset) excludes today from count.
Window: [June 24 AMS midnight, June 26 00:30 AMS).
first_counted = June 24.
last_counted (from window) = June 26 (lmu(June 26) = June 25 22:00 < 22:30).
settled_cap = June 25 (before offset) → min(June 26, June 25) = June 25.
Counted: June 24 + June 25 = 2 days (today June 26 excluded).
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_BEFORE # June 25 22:30 UTC = June 26 00:30 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
"AC3 before offset: today (June 26) must not be counted; expected 2 days; "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
"AC3 before offset: credits must be 2 days; "
f"got {result['credits']}"
)
# --- AC3b: cumulative window, after offset → today (June 26) counted ---
def test_cumulative_after_offset_includes_today(self, energy_db: Session) -> None:
"""AC3: Cumulative window at 01:10 (after offset) includes today.
Window: [June 24 AMS midnight, June 26 01:10 AMS).
first_counted = June 24.
last_counted (from window) = June 26 (lmu(June 26) < 23:10 UTC).
settled_cap = June 26 (after offset) → last_counted = June 26.
Counted: June 24 + June 25 + June 26 = 3 days.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 3) < 1e-9, (
"AC3 after offset: today (June 26) must be counted; expected 3 days; "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 3) < 1e-9, (
"AC3 after offset: credits must be 3 days; "
f"got {result['credits']}"
)
# --- AC4: past days always fully counted regardless of offset ---
def test_past_days_always_counted_regardless_of_offset(self, energy_db: Session) -> None:
"""AC4: Past days (all before today) are fully counted even before 01:05.
Window: [June 20 AMS midnight, June 26 AMS midnight) — all before today.
now = June 26 00:30 AMS (before offset) → settled_cap = June 25.
last_counted (from window) = June 25 (lmu(June 26) = end_utc, not <).
min(June 25, June 25) = June 25 → 6 days (June 20-25), all past.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 20) # June 19 22:00 UTC
end = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
# June 20-25 = 6 days, all past — unaffected by settlement offset.
assert abs(result["fixed_costs"] - daily_fixed * 6) < 1e-9, (
"AC4: past days must be fully counted regardless of settlement offset; "
f"expected 6 days = {daily_fixed * 6:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 6) < 1e-9, (
"AC4: past credits must be 6 days; "
f"got {result['credits']}"
)
# --- AC5: cross-version segments still correct with settlement offset ---
def test_cross_version_with_settlement_offset(self, energy_db: Session) -> None:
"""AC5: Cross-version day split is correct when settlement offset is active.
V1 covers June 24; V2 covers June 25+.
Window: [June 24 AMS midnight, June 26 01:10 AMS).
After offset (01:10) → settled_cap = June 26 → 3 days: V1=1, V2=2.
V1: network_fee=6, management_fee=6 → daily_fixed = 12/30
heffingskorting=300 → daily_credit = 300/365
V2: network_fee=12, management_fee=12 → daily_fixed = 24/30
heffingskorting=600 → daily_credit = 600/365
Expected:
fixed_costs = 1×(12/30) + 2×(24/30) = 0.4 + 1.6 = 2.0
credits = 1×(300/365) + 2×(600/365) = 1500/365
"""
_VALUES_V1 = {
"energy": {"buy": {"normal": 0.10, "dal": 0.10},
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 6.0, "management_fee": 6.0},
"credits": {"heffingskorting": 300.0},
}
_VALUES_V2 = {
"energy": {"buy": {"normal": 0.20, "dal": 0.20},
"sell": {"normal": 0.08, "dal": 0.08},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 12.0, "management_fee": 12.0},
"credits": {"heffingskorting": 600.0},
}
v1_from = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
v2_from = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
c = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, c, _VALUES_V1, effective_from=v1_from, effective_to=v2_from)
_make_version(energy_db, c, _VALUES_V2, effective_from=v2_from)
energy_db.commit()
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
expected_fixed = 1 * 12 / 30 + 2 * 24 / 30 # V1: 1 day, V2: 2 days
expected_credits = 1 * 300 / 365 + 2 * 600 / 365
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
f"AC5: cross-version fixed_costs wrong; expected {expected_fixed}, "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - expected_credits) < 1e-9, (
f"AC5: cross-version credits wrong; expected {expected_credits}, "
f"got {result['credits']}"
)
# --- AC6: return dict key-set unchanged ---
def test_return_dict_keys_unchanged(self, energy_db: Session) -> None:
"""AC6: summarize() return dict keys are unchanged by the settlement offset."""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26)
end = _ams_midnight(2026, 6, 27)
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
expected_keys = {
"currency", "metered_import", "metered_export", "metered_net",
"fixed_costs", "credits", "total_payable", "period_count",
"degraded_count", "days",
}
assert set(result.keys()) == expected_keys, (
f"AC6: summarize() key set changed; expected {expected_keys}, "
f"got {set(result.keys())}"
)
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -798,7 +798,7 @@ def test_meter_columns(energy_db):
inspector = inspect(energy_db) inspector = inspect(energy_db)
columns = {col["name"]: col for col in inspector.get_columns("meter")} columns = {col["name"]: col for col in inspector.get_columns("meter")}
non_nullable = {"id", "label", "commodity", "started_at", "reason", "created_at"} non_nullable = {"id", "uuid", "label", "commodity", "started_at", "reason", "created_at"}
nullable = {"ended_at", "note"} nullable = {"ended_at", "note"}
for col_name in non_nullable: for col_name in non_nullable:
@@ -810,11 +810,20 @@ def test_meter_columns(energy_db):
assert columns[col_name]["nullable"], f"{col_name} should be nullable" 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(): def test_meter_orm_metadata():
"""Meter must be registered in Base.metadata with correct field types.""" """Meter must be registered in Base.metadata with correct field types."""
assert "meter" in Base.metadata.tables, "meter not in Base.metadata" assert "meter" in Base.metadata.tables, "meter not in Base.metadata"
table = Base.metadata.tables["meter"] table = Base.metadata.tables["meter"]
assert "id" in table.columns assert "id" in table.columns
assert "uuid" in table.columns
assert "label" in table.columns assert "label" in table.columns
assert "commodity" in table.columns assert "commodity" in table.columns
assert "started_at" in table.columns assert "started_at" in table.columns
@@ -824,6 +833,14 @@ def test_meter_orm_metadata():
assert "created_at" 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): def test_meter_insert_and_retrieve(energy_db):
"""A Meter row can be inserted and retrieved with all fields intact.""" """A Meter row can be inserted and retrieved with all fields intact."""
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
+41 -10
View File
@@ -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}"
) )
+37
View File
@@ -381,6 +381,43 @@ class TestDeclareMeter:
assert fetched.commodity == "gas" assert fetched.commodity == "gas"
assert fetched.note == "Rotameter serial XYZ" 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 # 5. Different commodities are independent