Compare commits
6
Commits
da05fd2f09
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a03e7fd6 | ||
|
|
d3fc90b320 | ||
|
|
d4acfc438a | ||
|
|
c26160b10b | ||
|
|
efbe36d7c0 | ||
|
|
f663981cdb |
@@ -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")
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+317
-16
@@ -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 26–30 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 26–30 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,8 +1397,12 @@ 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()):
|
||||||
result = summarize(energy_db, start, end)
|
with patch.object(_ec, "local_now", return_value=pinned_now):
|
||||||
|
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
|
||||||
expected_fixed = 24 * 12 / 30 + 1 * 24 / 30
|
expected_fixed = 24 * 12 / 30 + 1 * 24 / 30
|
||||||
@@ -1408,12 +1443,16 @@ 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()):
|
||||||
result = summarize(energy_db, start, end)
|
with patch.object(_ec, "local_now", return_value=pinned_now):
|
||||||
|
result = summarize(energy_db, start, end)
|
||||||
|
|
||||||
# Only 1 day at V2 rate
|
# Only 1 day at V2 rate
|
||||||
expected_fixed = 1 * 24 / 30
|
expected_fixed = 1 * 24 / 30
|
||||||
@@ -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())}"
|
||||||
|
)
|
||||||
|
|||||||
+775
-74
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user