9 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
tliu93 da05fd2f09 FUE-T03: correct meters POST docstring (no recomputed_periods field) and inaccurate test comments
frontend / frontend (push) Successful in 2m11s
pytest / test (push) Successful in 8m51s
2026-06-25 18:36:46 +02:00
tliu93 188168b16a FUE-T02: fix EditMeterForm timestamp parsing to use shared parseBackendTimestamp (handles offset form) 2026-06-25 18:33:42 +02:00
tliu93 f1e7ce3133 FUE-T01: count billing window start day in full for fixed-fee/credit accrual (symmetric with end day) 2026-06-25 18:33:42 +02:00
19 changed files with 1900 additions and 193 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")
+32 -3
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)
@@ -185,9 +203,10 @@ def declare_energy_meter(
**Retroactive recompute**: if ``started_at`` is in the past, billing **Retroactive recompute**: if ``started_at`` is in the past, billing
records from that point forward are re-judged via ``recompute_range`` to records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The response includes the count of reflect the new meter attribution. The recompute is transparent — the
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse`` response body is the created meter (``MeterResponse``) only and does **not**
— the recompute is transparent; callers should re-fetch costs if needed). include a recompute count; callers should re-fetch costs if they need the
updated totals.
""" """
started_at_utc = _localize_started_at(body.started_at) started_at_utc = _localize_started_at(body.started_at)
@@ -216,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,
@@ -293,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)
+73 -26
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)
@@ -678,15 +682,33 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
+ fixed_costs -- per-day standing charges, cross-version + fixed_costs -- per-day standing charges, cross-version
- credits -- per-day heffingskorting, cross-version - credits -- per-day heffingskorting, cross-version
**Fixed-cost / credit counting — Principle C**: **Fixed-cost / credit counting — Principle C (symmetric begin/end)**:
Only *already-elapsed* whole local calendar days are counted. For each Both the local calendar day in which *start* falls and the local calendar
local calendar date D in the window ``[start_local_date, min(end_local_date, day in which *end* falls are counted as full days. Fixed charges
tomorrow_local))``, the contract version whose rate covers D (the one whose (network_fee, management_fee) and the energy-tax credit (heffingskorting)
effective_from local-date ≤ D < next version's effective_from local-date) is are assessed on a "service-is-active" basis — if the meter was online on a
used. Days that have not yet started in local time (D > today_local) are given calendar day, the full day's charge/credit applies, regardless of
never counted. This is cross-version: if the active contract has V1 from whether the window starts at midnight or mid-morning.
June 1 and V2 from June 25, querying June 130 uses V1 for days 1-24 and V2
for day 25. Switching versions never resets the counter. For each local calendar date D in the range
``[local_date(start), min(local_date(end), today_local)]``:
- D ≤ today_local (only elapsed / today days count as "whole days").
- The contract version whose effective_from local-date ≤ D is used.
- This is cross-version: if V1 is from June 1 and V2 from June 25,
querying June 130 uses V1 for days 1-24 and V2 for day 25.
The end-day (last_counted) is included when the end's local midnight falls
strictly before end_utc; combined with the always-counted start day this
makes the begin/end handling symmetric. A short same-day window therefore
counts its single local day. A window contributes 0 days only when the
counted range is empty (first_counted > last_counted) — e.g. a window lying
entirely in the future, since last_counted is capped at today_local.
Daily getters (``*_today``) use windows exactly aligned to local midnight,
so their ``first_counted`` is always today — unaffected by this fix.
Days that have not yet started in local time (D > today_local) are
never counted. Switching versions never resets the counter.
**Timezone note**: the ``days`` field in the returned dict still represents **Timezone note**: the ``days`` field in the returned dict still represents
the window length in calendar days (total_seconds / 86400), for backward the window length in calendar days (total_seconds / 86400), for backward
@@ -746,29 +768,46 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
days = _to_decimal(str(total_seconds)) / _to_decimal("86400") days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
# --- Fixed costs and credits: Principle C cross-version whole-day counting --- # --- Fixed costs and credits: Principle C cross-version whole-day counting ---
today_local: _date = local_now().date() _now_local = local_now()
today_local: _date = _now_local.date()
# --- Compute [first_counted, last_counted] local date range (inclusive) --- # --- Compute [first_counted, last_counted] local date range (inclusive) ---
# #
# We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc) # Both the window-start day and the window-end day are counted as complete
# AND D ≤ today_local (only elapsed / today days count as "whole days"). # local calendar days, regardless of whether the window starts/ends at midnight.
# #
# Semantics: "A day D is counted when its local midnight has arrived (D ≤ today) # Principle C (symmetric begin/end):
# AND the local midnight is within the billing window." # • first_counted = local calendar date of start_utc (start day always counted)
# • last_counted = local calendar date of end_utc (end day counted if its
# local midnight is strictly before end_utc)
# • Both are then capped at today_local (only elapsed / today days count).
# #
# This means a sub-day window [10:00, 10:30) UTC that doesn't contain any # Why symmetric? Fixed charges (network_fee, management_fee) and energy-tax credits
# local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that # (heffingskorting) are assessed on a "service-is-active" basis, not on how many
# contains CEST midnight (= 22:00 UTC) contributes 1 day. # hours the service was actually running within that calendar day. If the meter
# anchor (started_at) falls at 09:18 on June 24, the full June 24 standing charge
# and credit still apply because the service was online for that day.
# #
# Implementation: compute the first and last local date whose midnight is in range. # Previous asymmetric behaviour: a start_utc that was *later* than the local
# midnight of local_start_date caused first_counted to be bumped to the *next*
# day, silently dropping the anchor day's charges/credits. This was incorrect
# for cumulative entities (import_cost_total / export_revenue_total) whose anchor
# is often an above-midnight started_at. The end-side had always been symmetric
# (counted if local midnight < end_utc), creating an inconsistency.
#
# Daily getters (window = [local today 00:00, local tomorrow 00:00)) are
# unaffected: local_date(local_midnight_utc(today)) == today, so first_counted
# is still today regardless of the fix.
#
# A short same-day window now counts its single local day (the start day is
# always counted, symmetric with the end side). A window contributes 0 days
# only when the counted range is empty (first_counted > last_counted) — e.g. a
# window lying entirely in the future, where last_counted is capped at
# today_local while first_counted is later.
from app.services.timezone import local_midnight_utc as _lmu from app.services.timezone import local_midnight_utc as _lmu
local_start_date: _date = local_date(start_utc) # Start side: always count the local calendar day in which start_utc falls.
# Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day. first_counted: _date = local_date(start_utc)
if _lmu(local_start_date) >= start_utc:
first_counted: _date = local_start_date
else:
first_counted = local_start_date + _td(days=1)
local_end_date: _date = local_date(end_utc) local_end_date: _date = local_date(end_utc)
# Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range. # Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range.
@@ -777,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.
+4 -3
View File
@@ -591,9 +591,10 @@ export interface paths {
* *
* **Retroactive recompute**: if ``started_at`` is in the past, billing * **Retroactive recompute**: if ``started_at`` is in the past, billing
* records from that point forward are re-judged via ``recompute_range`` to * records from that point forward are re-judged via ``recompute_range`` to
* reflect the new meter attribution. The response includes the count of * reflect the new meter attribution. The recompute is transparent — the
* recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse`` * response body is the created meter (``MeterResponse``) only and does **not**
* — the recompute is transparent; callers should re-fetch costs if needed). * include a recompute count; callers should re-fetch costs if they need the
* updated totals.
*/ */
post: operations["declare_energy_meter_api_energy_meters_post"]; post: operations["declare_energy_meter_api_energy_meters_post"];
delete?: never; delete?: never;
+89
View File
@@ -258,6 +258,95 @@ describe('MeterManager — declare new meter', () => {
}) })
}) })
describe('MeterManager — edit meter date initialisation', () => {
beforeEach(() => vi.clearAllMocks())
it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => {
// ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight).
// The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so
// the local date is deterministically '2024-01-15' on any CI runner.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-01-15')
})
it('initialises date input from started_at (naive, no tz marker)', async () => {
// A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z'
// so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date
// equals the UTC date exactly.
const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' }
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-03-20')
})
it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => {
// The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00'
// suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date.
// The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex
// and handles explicit offsets properly.
const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' }
// UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15'
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
// Must not be empty (which would indicate Invalid Date from the old buggy path)
expect(dateInput.value).not.toBe('')
expect(dateInput.value).toBe('2024-01-15')
})
it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => {
// Open the edit form and immediately submit without changing any fields except label.
// The date should be considered unchanged → no started_at in the PATCH body.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change only label; leave date untouched
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'New label')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchBody = mockPatch.mock.calls[0][1].body
expect(patchBody).not.toHaveProperty('started_at')
})
})
describe('MeterManager — edit meter', () => { describe('MeterManager — edit meter', () => {
beforeEach(() => vi.clearAllMocks()) beforeEach(() => vi.clearAllMocks())
+20 -14
View File
@@ -38,7 +38,7 @@ import {
type MeterReason, type MeterReason,
} from './hooks' } from './hooks'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { formatLocalDate } from '../utils/datetime' import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -60,6 +60,19 @@ function toLocalMidnightNaive(dateStr: string): string {
return `${dateStr}T00:00:00` return `${dateStr}T00:00:00`
} }
/**
* Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone.
* Used to populate <input type="date"> fields.
* Returns '' if the Date is invalid.
*/
function toLocalDateInputString(d: Date): string {
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Declare meter form (modal) // Declare meter form (modal)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -204,19 +217,12 @@ interface EditMeterFormProps {
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) { function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
const [label, setLabel] = useState(meter.label) const [label, setLabel] = useState(meter.label)
const [note, setNote] = useState(meter.note ?? '') const [note, setNote] = useState(meter.note ?? '')
// Convert UTC started_at to a local date string for the <input type="date">. // Convert started_at to a local date string for the <input type="date">.
// We parse as UTC (appending Z if needed) and format to "YYYY-MM-DD" in local tz. // parseBackendTimestamp correctly handles naive strings (no tz marker → treated as UTC,
const initialDateStr = (() => { // matching the backend's storage convention), Z-suffixed strings, and strings with
const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/) // explicit offsets like +02:00. We then extract the local-timezone date components
? meter.started_at + 'Z' // so the displayed date matches the local wall-clock date of the meter start.
: meter.started_at const initialDateStr = toLocalDateInputString(parseBackendTimestamp(meter.started_at))
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
})()
const [dateStr, setDateStr] = useState(initialDateStr) const [dateStr, setDateStr] = useState(initialDateStr)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
+8
View File
@@ -20,5 +20,13 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: ['./src/test-setup.ts'], setupFiles: ['./src/test-setup.ts'],
env: {
// Lock the test timezone to UTC so that date-formatting assertions
// (which rely on toLocalDateInputString / getFullYear etc.) produce
// deterministic results regardless of the CI runner's local timezone.
// All fixture timestamps are UTC midnight, so the expected YYYY-MM-DD
// values in MeterManager.test.tsx are correct under UTC.
TZ: 'UTC',
},
}, },
}) })
+1 -1
View File
@@ -1405,7 +1405,7 @@
"api-energy-meters" "api-energy-meters"
], ],
"summary": "Declare Energy Meter", "summary": "Declare Energy Meter",
"description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The response includes the count of\nrecomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``\n— the recompute is transparent; callers should re-fetch costs if needed).", "description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The recompute is transparent — the\nresponse body is the created meter (``MeterResponse``) only and does **not**\ninclude a recompute count; callers should re-fetch costs if they need the\nupdated totals.",
"operationId": "declare_energy_meter_api_energy_meters_post", "operationId": "declare_energy_meter_api_energy_meters_post",
"parameters": [ "parameters": [
{ {
+5 -3
View File
@@ -1125,11 +1125,13 @@ paths:
records from that point forward are re-judged via ``recompute_range`` to records from that point forward are re-judged via ``recompute_range`` to
reflect the new meter attribution. The response includes the count of reflect the new meter attribution. The recompute is transparent — the
recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse`` response body is the created meter (``MeterResponse``) only and does **not**
— the recompute is transparent; callers should re-fetch costs if needed).' include a recompute count; callers should re-fetch costs if they need the
updated totals.'
operationId: declare_energy_meter_api_energy_meters_post operationId: declare_energy_meter_api_energy_meters_post
parameters: parameters:
- name: X-CSRF-Token - name: X-CSRF-Token
+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):
+95 -2
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."""
@@ -543,7 +561,7 @@ def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Timeline continuity (integration: no recompute mock) # Timeline continuity (recompute mocked to avoid slow computation over empty quarters)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -615,7 +633,7 @@ def test_declare_meter_invalid_reason_returns_422(meters_client):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Retroactive recompute: integration (no mock) — window coverage check # Retroactive recompute & boundary update (recompute mocked) — window coverage check
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -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"
+485 -38
View File
@@ -996,35 +996,52 @@ class TestSummarize:
assert result["period_count"] == 2 assert result["period_count"] == 2
assert result["degraded_count"] == 0 assert result["degraded_count"] == 0
def test_fixed_costs_zero_for_sub_day_window(self, energy_db: Session) -> None: def test_fixed_costs_one_day_for_sub_day_window(self, energy_db: Session) -> None:
"""Principle C: a 30-minute window within the same local day counts 0 whole days. """FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day.
Fixed costs are only added for elapsed whole local calendar days. Under the symmetric-begin/end fix, fixed charges are assessed on a
A window [10:00, 10:30) UTC stays within the same local calendar date "service-is-active" basis: if the window falls within a local calendar day,
regardless of timezone offset (any UTC-11..UTC+14 range), so no whole that entire day's charge applies. The begin side no longer requires a local
day is covered fixed_costs = 0. midnight to fall *inside* the window the local date of start_utc itself is
always counted as the first day.
Window [10:00, 10:30) UTC in Europe/Amsterdam (CEST = UTC+2):
local date of start: June 23 (12:00 CEST)
local date of end: June 23 (12:30 CEST)
first_counted = last_counted = June 23 1 day
fixed_costs = (9.87 + 9.87) / 30 × 1
""" """
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from unittest.mock import patch
self._setup_two_periods(energy_db) self._setup_two_periods(energy_db)
# Pin the local timezone so the test is deterministic on any CI host. # Pin the local timezone so the test is deterministic on any CI host.
with __import__("unittest.mock", fromlist=["patch"]).patch.object( with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
):
result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert result["fixed_costs"] == 0.0, (
"Sub-day window should contribute 0 whole-day fixed costs under Principle C" expected_fixed = (9.87 + 9.87) / 30 # 1 day
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
f"FUE-T01: sub-day window in one local day should count 1 day of fixed costs; "
f"expected {expected_fixed:.6f}, got {result['fixed_costs']}"
) )
def test_credits_zero_for_sub_day_window(self, energy_db: Session) -> None: def test_credits_one_day_for_sub_day_window(self, energy_db: Session) -> None:
"""Principle C: a 30-minute window within the same local day counts 0 whole days for credits.""" """FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day of credits.
Same rationale as test_fixed_costs_one_day_for_sub_day_window.
credits = 600.0 / 365 × 1 day.
"""
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from unittest.mock import patch
self._setup_two_periods(energy_db) self._setup_two_periods(energy_db)
with __import__("unittest.mock", fromlist=["patch"]).patch.object( with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
):
result = summarize(energy_db, _ts(10, 0), _ts(10, 30)) result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert result["credits"] == 0.0, (
"Sub-day window should contribute 0 whole-day credits under Principle C" expected_credits = 600.0 / 365 # 1 day
assert abs(result["credits"] - expected_credits) < 1e-9, (
f"FUE-T01: sub-day window in one local day should count 1 day of credits; "
f"expected {expected_credits:.6f}, got {result['credits']}"
) )
def test_total_payable_formula(self, energy_db: Session) -> None: def test_total_payable_formula(self, energy_db: Session) -> None:
@@ -1068,12 +1085,20 @@ class TestSummarize:
assert result["period_count"] == 0 assert result["period_count"] == 0
def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None: def test_one_day_summarize_hand_calc(self, energy_db: Session) -> None:
"""Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day interval. """Full 1-day hand-calculation: fixed_costs/30 and credits/365 with 1-day UTC interval.
Principle C: the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC) spans FUE-T01 (symmetric begin/end): the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC)
exactly one local calendar day in Europe/Amsterdam (CEST=UTC+2: 02:0002:00). in Europe/Amsterdam (CEST=UTC+2):
effective_from = June 23 00:00 UTC = June 23 02:00 CEST = local date June 23. - local date of start (June 23 00:00 UTC = June 23 02:00 CEST) = June 23
Today (2026-06-25) June 23, so the day is elapsed 1 day counted. first_counted = June 23 (anchor day always counted)
- local midnight of June 24 = June 23 22:00 UTC, which is < end_utc (June 24 00:00 UTC)
last_counted = June 24
- today (2026-06-25) June 24 cap doesn't apply
2 local calendar days counted (June 23 + June 24)
Note: a 1-day UTC window centered at non-midnight UTC spans **two** local days
in Amsterdam (CEST=UTC+2) because local midnight (June 23 22:00 UTC) falls
inside the window. This is correct under the symmetric begin/end principle.
""" """
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from unittest.mock import patch from unittest.mock import patch
@@ -1091,12 +1116,18 @@ class TestSummarize:
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")): with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
result = summarize(energy_db, start, end) result = summarize(energy_db, start, end)
# fixed_costs = (9.87 + 9.87) / 30 × 1.0 = 0.658 # 2 local days (June 23 start day + June 24 because local midnight of June 24
assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30) < 1e-9 # = June 23 22:00 UTC falls within [start, end)).
# credits = 600 / 365 n_days = 2
assert abs(result["credits"] - 600.0 / 365) < 1e-9 assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30 * n_days) < 1e-9, (
# total = 0 + 0.658 (600/365) f"FUE-T01: 1-day UTC window = 2 local days in Amsterdam; "
expected_total = (9.87 + 9.87) / 30 - 600.0 / 365 f"expected fixed={((9.87 + 9.87) / 30 * n_days):.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - 600.0 / 365 * n_days) < 1e-9, (
f"FUE-T01: expected credits={600.0 / 365 * n_days:.6f}, got {result['credits']}"
)
# total = 0 + fixed credits
expected_total = (9.87 + 9.87) / 30 * n_days - 600.0 / 365 * n_days
assert abs(result["total_payable"] - expected_total) < 1e-9 assert abs(result["total_payable"] - expected_total) < 1e-9
@@ -1168,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."""
@@ -1179,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:
@@ -1228,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/17/1 (This Month) 25 days. Matches table row: 6/17/1 (This Month) 25 days.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1236,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
@@ -1273,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/256/28 1 day. Matches table row: 6/256/28 1 day.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1282,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
@@ -1335,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
@@ -1377,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
@@ -1390,6 +1460,120 @@ class TestSummarizePrincipleC:
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9 assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
assert abs(result["credits"] - expected_credits) < 1e-9 assert abs(result["credits"] - expected_credits) < 1e-9
def test_morning_anchor_first_day_counted(self, energy_db: Session) -> None:
"""FUE-T01: anchor falling above local midnight counts its local day as day 1.
Bug scenario: meter.started_at = June 24 09:18 local time (07:18 UTC, CEST=UTC+2).
Old code: local_date(07:18 UTC) = June 24; _lmu(June 24) = June 23 22:00 UTC;
June 23 22:00 UTC < June 24 07:18 UTC False (22:00 < 07:18? No 22:00 UTC June 23 is
*before* 07:18 UTC June 24; so the condition >= start_utc was False);
first_counted bumped to June 25. June 24's charges were silently dropped.
Fix: first_counted = local_date(start_utc) = June 24 always, regardless of where
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)).
Pins local_now to June 25 noon AMS today=June 25, settled_cap=June 25.
first_counted = June 24 (anchor day, previously dropped).
last_counted = min(June 26, June 25) = June 25.
n_days = June 25 - June 24 + 1 = 2.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
# anchor = June 24 07:18 UTC = June 24 09:18 CEST (above local midnight)
anchor_utc = datetime(2026, 6, 24, 7, 18, 0, tzinfo=_UTC)
# 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)
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
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_credit = 600.0 / 365
# June 24 (anchor day) + June 25 (today) = 2 days
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
f"FUE-T01: morning anchor should count anchor day + today = 2 days of fixed costs; "
f"expected {daily_fixed * 2:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
f"FUE-T01: morning anchor should count anchor day + today = 2 days of credits; "
f"expected {daily_credit * 2:.6f}, got {result['credits']}"
)
def test_daily_window_midnight_aligned_unaffected(self, energy_db: Session) -> None:
"""FUE-T01: the daily getter window [today midnight, tomorrow midnight) is not affected.
The *_today getters use windows exactly aligned to local midnight:
start = local_midnight_utc(today) = June 24 22:00 UTC (for June 25 local)
end = local_midnight_utc(tomorrow) = June 25 22:00 UTC
With either old or new logic, first_counted = June 25 (today):
- Old: local_date(June 24 22:00 UTC in Amsterdam) = June 25;
_lmu(June 25) = June 24 22:00 UTC >= June 24 22:00 UTC True first = June 25.
- New: first_counted = local_date(June 24 22:00 UTC) = June 25.
Both give the same result: 1 day counted.
This test explicitly verifies daily window behaviour is unchanged.
"""
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
# Today's window aligned to local midnight (June 25 CEST = June 24 22:00 UTC).
today_start_utc = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
tomorrow_start_utc = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = self._run_summarize_ams(energy_db, today_start_utc, tomorrow_start_utc)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
# Exactly 1 day counted (June 25 only; June 26 is future, capped).
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
f"FUE-T01: daily window should count exactly 1 day; "
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
f"FUE-T01: daily window should count exactly 1 day of credits; "
f"expected {daily_credit:.6f}, got {result['credits']}"
)
def test_single_day_morning_anchor_counts_one_day(self, energy_db: Session) -> None:
"""FUE-T01: a window starting mid-morning on a single local day counts that 1 day.
Window: [June 24 08:00 UTC (= 10:00 CEST), June 24 22:00 UTC (= June 25 00:00 CEST)).
Both endpoints resolve to June 24 local (end is exactly midnight of June 25,
which is June 24 22:00 UTC, so _lmu(June 25) = June 24 22:00 UTC is NOT < end_utc
but equal last_counted = June 24).
first_counted = June 24.
n_days = 1. Today (June 25) is past June 24 elapsed.
"""
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start_utc = datetime(2026, 6, 24, 8, 0, 0, tzinfo=_UTC) # 10:00 CEST = mid-morning June 24
end_utc = datetime(2026, 6, 24, 22, 0, 0, tzinfo=_UTC) # = June 25 00:00 CEST midnight
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = self._run_summarize_ams(energy_db, start_utc, end_utc)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
# Exactly 1 day: June 24 (start day, counted as full day; elapsed since today is June 25).
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
f"FUE-T01: mid-morning to midnight window on one day should count 1 day; "
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
f"FUE-T01: mid-morning to midnight window on one day should count 1 day of credits; "
f"expected {daily_credit:.6f}, got {result['credits']}"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 8. compute_closed_periods # 8. compute_closed_periods
@@ -2263,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