3 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
5 changed files with 612 additions and 44 deletions
+13 -3
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,6 +370,10 @@ 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.
@@ -490,7 +495,7 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
# (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", active_meter.uuid), identifiers=("energy-cost", active_meter.uuid),
name=f"Energy Cost ({active_meter.label})", name=active_meter.label,
provides_availability=False, provides_availability=False,
) )
@@ -735,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)
@@ -772,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
+16 -3
View File
@@ -123,6 +123,10 @@ _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# degraded to prevent negative costs or grossly inflated charges. # degraded to prevent negative costs or grossly inflated charges.
_MAX_DELTA_KWH = Decimal("100") _MAX_DELTA_KWH = Decimal("100")
# 每日固定费/税补在"本地午夜后多久"才结算入账。延后到 01:05 是为了让累计成本的
# 整天阶跃落在新一天、且避开 01:00 整点(HA 长期统计的小时桶边界)。
_SETTLEMENT_OFFSET = timedelta(hours=1, minutes=5)
# DSMR payload register keys (cumulative kWh, JSON string values). # DSMR payload register keys (cumulative kWh, JSON string values).
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1) _KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2) _KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
@@ -764,7 +768,8 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
days = _to_decimal(str(total_seconds)) / _to_decimal("86400") days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
# --- Fixed costs and credits: Principle C cross-version whole-day counting --- # --- Fixed costs and credits: Principle C cross-version whole-day counting ---
today_local: _date = local_now().date() _now_local = local_now()
today_local: _date = _now_local.date()
# --- Compute [first_counted, last_counted] local date range (inclusive) --- # --- Compute [first_counted, last_counted] local date range (inclusive) ---
# #
@@ -811,8 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
else: else:
last_counted = local_end_date - _td(days=1) last_counted = local_end_date - _td(days=1)
# Cap: only elapsed or today's days. # Settlement cap: "today" is only counted once the local clock has passed the
last_counted = min(last_counted, today_local) # settlement offset since midnight (01:05). This defers the daily standing-charge
# step from 00:00 to 01:05, ensuring the cumulative-cost adiabatic jump lands
# inside the new calendar day and avoids the HA 01:00 hourly-bucket boundary.
_today_midnight_utc = _lmu(today_local)
if _now_local >= _today_midnight_utc + _SETTLEMENT_OFFSET:
settled_cap = today_local
else:
settled_cap = today_local - _td(days=1)
last_counted = min(last_counted, settled_cap)
# If the range is empty (first_counted > last_counted), no days are counted. # If the range is empty (first_counted > last_counted), no days are counted.
+315 -14
View File
@@ -1199,10 +1199,16 @@ def _ams_midnight(year: int, month: int, day: int) -> datetime:
class TestSummarizePrincipleC: class TestSummarizePrincipleC:
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone.""" """Principle C whole-day counting with pinned Europe/Amsterdam timezone.
Tests that assert a specific "today" (e.g. June 25) must also pin
``local_now`` via *pinned_now* to remain deterministic as wall-clock
time advances. Tests that only care about windows entirely in the past
or entirely in the future do not need to pin ``local_now``.
"""
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC. # Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
# Today local = June 25 CEST (since today is 2026-06-25). # Reference "today" pinned in individual tests = June 25 CEST.
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None: def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
"""Create an active manual contract with a single version.""" """Create an active manual contract with a single version."""
@@ -1210,10 +1216,27 @@ class TestSummarizePrincipleC:
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc) _make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
session.commit() session.commit()
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict: def _run_summarize_ams(
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.""" self,
session: Session,
start_utc: datetime,
end_utc: datetime,
*,
pinned_now: datetime | None = None,
) -> dict:
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.
If *pinned_now* is given, also patches ``app.services.energy_cost.local_now``
to that fixed value, making settlement-offset logic deterministic
regardless of wall-clock time.
"""
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
if pinned_now is not None:
with patch.object(_ec, "local_now", return_value=pinned_now):
return summarize(session, start_utc, end_utc)
return summarize(session, start_utc, end_utc) return summarize(session, start_utc, end_utc)
def test_today_window_counts_1_day(self, energy_db: Session) -> None: def test_today_window_counts_1_day(self, energy_db: Session) -> None:
@@ -1259,7 +1282,10 @@ class TestSummarizePrincipleC:
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None: def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25). """This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
Today = June 25 local, so June 2630 are not yet elapsed. Pins ``local_now`` to June 25 noon AMS so the test is deterministic
regardless of the actual wall-clock date. June 25 is well past the
01:05 settlement offset, so ``settled_cap = June 25``.
Today = June 25 local, so June 2630 are not yet elapsed → 25 days.
Matches table row: 6/1→7/1 (This Month) → 25 days. Matches table row: 6/1→7/1 (This Month) → 25 days.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1267,7 +1293,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 1) start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
result = self._run_summarize_ams(energy_db, start, end) # Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -1304,8 +1332,9 @@ class TestSummarizePrincipleC:
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None: def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
"""A window partially in the future caps at today (only elapsed days counted). """A window partially in the future caps at today (only elapsed days counted).
Pins ``local_now`` to June 25 noon AMS so the test is deterministic.
Window: June 25 → June 28 local (4 dates, but June 26/27 are future). Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
Today = June 25 → only 1 day elapsed (June 25). Today = June 25 → settled_cap = June 25 → only 1 day elapsed (June 25).
Matches table row: 6/25→6/28 → 1 day. Matches table row: 6/25→6/28 → 1 day.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1313,7 +1342,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 25) start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 6, 28) end = _ams_midnight(2026, 6, 28)
result = self._run_summarize_ams(energy_db, start, end) # Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -1366,7 +1397,11 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 1) start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
# Pin local_now to June 25 2026 noon AMS: today=June 25, settled_cap=June 25.
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
with patch.object(_ec, "local_now", return_value=pinned_now):
result = summarize(energy_db, start, end) result = summarize(energy_db, start, end)
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30 # V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
@@ -1408,11 +1443,15 @@ class TestSummarizePrincipleC:
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from) _make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
energy_db.commit() energy_db.commit()
# Window = June 25 only (today, 1 day elapsed at V2 rate) # Window = June 25 only (today, 1 day elapsed at V2 rate).
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
start = _ams_midnight(2026, 6, 25) start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
with patch.object(_ec, "local_now", return_value=pinned_now):
result = summarize(energy_db, start, end) result = summarize(energy_db, start, end)
# Only 1 day at V2 rate # Only 1 day at V2 rate
@@ -1434,13 +1473,11 @@ class TestSummarizePrincipleC:
within the day start_utc falls. June 24 is the anchor day → its charge is counted. within the day start_utc falls. June 24 is the anchor day → its charge is counted.
Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)). Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)).
Today local = June 25 (2026-06-25). Pins local_now to June 25 noon AMS → today=June 25, settled_cap=June 25.
first_counted = June 24 (anchor day, previously dropped). first_counted = June 24 (anchor day, previously dropped).
last_counted = min(June 26, June 25) = June 25. last_counted = min(June 26, June 25) = June 25.
n_days = June 25 - June 24 + 1 = 2. n_days = June 25 - June 24 + 1 = 2.
""" """
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc) self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
@@ -1449,8 +1486,9 @@ class TestSummarizePrincipleC:
# end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped) # end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped)
end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC) end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
with patch.object(tz_module, "local_tz", return_value=_ams()): # Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc) pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -2409,3 +2447,266 @@ class TestMeterAwareComputePeriod:
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard" assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
assert row.import_cost == 0.0 assert row.import_cost == 0.0
assert row.net_cost == 0.0 assert row.net_cost == 0.0
# ---------------------------------------------------------------------------
# FUE-T08. summarize — settlement offset (local 01:05)
# ---------------------------------------------------------------------------
class TestSummarizeSettlementOffset:
"""FUE-T08: daily fixed-fee/heffingskorting settled after local 01:05.
Reference date: June 26, 2026 AMS (CEST = UTC+2).
- AMS midnight June 26 = June 25 22:00 UTC
- Settlement threshold = June 25 22:00 + 01:05 = June 25 23:05 UTC
= June 26 01:05 AMS
- "before offset" now = June 26 00:30 AMS = June 25 22:30 UTC
- "after offset" now = June 26 01:10 AMS = June 25 23:10 UTC
All tests monkeypatch both local_tz (Europe/Amsterdam) and
``app.services.energy_cost.local_now`` to be fully deterministic.
"""
# UTC instants used as "now":
# June 25 22:30 UTC = June 26 00:30 AMS (before settlement threshold 23:05 UTC)
_NOW_BEFORE = datetime(2026, 6, 25, 22, 30, 0, tzinfo=UTC)
# June 25 23:10 UTC = June 26 01:10 AMS (after settlement threshold 23:05 UTC)
_NOW_AFTER = datetime(2026, 6, 25, 23, 10, 0, tzinfo=UTC)
def _setup_single_version_contract(self, session: Session) -> None:
"""Insert a manual contract effective Jan 1 2026 (covers all test dates)."""
c = _make_contract(session, kind="manual", active=True)
_make_version(session, c, _MANUAL_VALUES, effective_from=_ams_midnight(2026, 1, 1))
session.commit()
def _run(
self,
session: Session,
start_utc: datetime,
end_utc: datetime,
*,
now_utc: datetime,
) -> dict:
"""Run summarize with AMS timezone and pinned local_now.
*now_utc* is converted to AMS before being used as the ``local_now``
return value, so ``.date()`` yields the correct AMS local date.
"""
from unittest.mock import patch
import app.services.energy_cost as _ec
now_ams = now_utc.astimezone(_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()):
with patch.object(_ec, "local_now", return_value=now_ams):
return summarize(session, start_utc, end_utc)
# --- AC1: before 01:05 → today not settled → fixed_costs/credits == 0 ---
def test_today_window_before_offset_yields_zero(self, energy_db: Session) -> None:
"""AC1: At local 00:30 (before 01:05), today's window → credits == 0 and fixed_costs == 0.
Window: [June 26 AMS midnight, June 27 AMS midnight).
now = June 26 00:30 AMS (before 01:05) → settled_cap = June 25.
first_counted = June 26 > last_counted = June 25 → 0 days.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
end = _ams_midnight(2026, 6, 27) # June 26 22:00 UTC
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
assert result["fixed_costs"] == 0.0, (
"AC1: before settlement offset, today's fixed_costs must be 0; "
f"got {result['fixed_costs']}"
)
assert result["credits"] == 0.0, (
"AC1: before settlement offset, today's credits must be 0; "
f"got {result['credits']}"
)
# --- AC2: >= 01:05 → today settled → 1 day fixed/credits ---
def test_today_window_after_offset_yields_one_day(self, energy_db: Session) -> None:
"""AC2: At local 01:10 (>= 01:05), today's window → 1 day of fixed/credits.
Window: [June 26 AMS midnight, June 27 AMS midnight).
now = June 26 01:10 AMS (after 01:05) → settled_cap = June 26.
first_counted = June 26 = last_counted → 1 day.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26)
end = _ams_midnight(2026, 6, 27)
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed) < 1e-9, (
"AC2: after settlement offset, today's fixed_costs must equal 1 day; "
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit) < 1e-9, (
"AC2: after settlement offset, today's credits must equal 1 day; "
f"expected {daily_credit:.6f}, got {result['credits']}"
)
# --- AC3a: cumulative window, before offset → today (June 26) not counted ---
def test_cumulative_before_offset_excludes_today(self, energy_db: Session) -> None:
"""AC3: Cumulative window at 00:30 (before offset) excludes today from count.
Window: [June 24 AMS midnight, June 26 00:30 AMS).
first_counted = June 24.
last_counted (from window) = June 26 (lmu(June 26) = June 25 22:00 < 22:30).
settled_cap = June 25 (before offset) → min(June 26, June 25) = June 25.
Counted: June 24 + June 25 = 2 days (today June 26 excluded).
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_BEFORE # June 25 22:30 UTC = June 26 00:30 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
"AC3 before offset: today (June 26) must not be counted; expected 2 days; "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
"AC3 before offset: credits must be 2 days; "
f"got {result['credits']}"
)
# --- AC3b: cumulative window, after offset → today (June 26) counted ---
def test_cumulative_after_offset_includes_today(self, energy_db: Session) -> None:
"""AC3: Cumulative window at 01:10 (after offset) includes today.
Window: [June 24 AMS midnight, June 26 01:10 AMS).
first_counted = June 24.
last_counted (from window) = June 26 (lmu(June 26) < 23:10 UTC).
settled_cap = June 26 (after offset) → last_counted = June 26.
Counted: June 24 + June 25 + June 26 = 3 days.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 3) < 1e-9, (
"AC3 after offset: today (June 26) must be counted; expected 3 days; "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 3) < 1e-9, (
"AC3 after offset: credits must be 3 days; "
f"got {result['credits']}"
)
# --- AC4: past days always fully counted regardless of offset ---
def test_past_days_always_counted_regardless_of_offset(self, energy_db: Session) -> None:
"""AC4: Past days (all before today) are fully counted even before 01:05.
Window: [June 20 AMS midnight, June 26 AMS midnight) — all before today.
now = June 26 00:30 AMS (before offset) → settled_cap = June 25.
last_counted (from window) = June 25 (lmu(June 26) = end_utc, not <).
min(June 25, June 25) = June 25 → 6 days (June 20-25), all past.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 20) # June 19 22:00 UTC
end = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
# June 20-25 = 6 days, all past — unaffected by settlement offset.
assert abs(result["fixed_costs"] - daily_fixed * 6) < 1e-9, (
"AC4: past days must be fully counted regardless of settlement offset; "
f"expected 6 days = {daily_fixed * 6:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 6) < 1e-9, (
"AC4: past credits must be 6 days; "
f"got {result['credits']}"
)
# --- AC5: cross-version segments still correct with settlement offset ---
def test_cross_version_with_settlement_offset(self, energy_db: Session) -> None:
"""AC5: Cross-version day split is correct when settlement offset is active.
V1 covers June 24; V2 covers June 25+.
Window: [June 24 AMS midnight, June 26 01:10 AMS).
After offset (01:10) → settled_cap = June 26 → 3 days: V1=1, V2=2.
V1: network_fee=6, management_fee=6 → daily_fixed = 12/30
heffingskorting=300 → daily_credit = 300/365
V2: network_fee=12, management_fee=12 → daily_fixed = 24/30
heffingskorting=600 → daily_credit = 600/365
Expected:
fixed_costs = 1×(12/30) + 2×(24/30) = 0.4 + 1.6 = 2.0
credits = 1×(300/365) + 2×(600/365) = 1500/365
"""
_VALUES_V1 = {
"energy": {"buy": {"normal": 0.10, "dal": 0.10},
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 6.0, "management_fee": 6.0},
"credits": {"heffingskorting": 300.0},
}
_VALUES_V2 = {
"energy": {"buy": {"normal": 0.20, "dal": 0.20},
"sell": {"normal": 0.08, "dal": 0.08},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 12.0, "management_fee": 12.0},
"credits": {"heffingskorting": 600.0},
}
v1_from = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
v2_from = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
c = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, c, _VALUES_V1, effective_from=v1_from, effective_to=v2_from)
_make_version(energy_db, c, _VALUES_V2, effective_from=v2_from)
energy_db.commit()
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
expected_fixed = 1 * 12 / 30 + 2 * 24 / 30 # V1: 1 day, V2: 2 days
expected_credits = 1 * 300 / 365 + 2 * 600 / 365
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
f"AC5: cross-version fixed_costs wrong; expected {expected_fixed}, "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - expected_credits) < 1e-9, (
f"AC5: cross-version credits wrong; expected {expected_credits}, "
f"got {result['credits']}"
)
# --- AC6: return dict key-set unchanged ---
def test_return_dict_keys_unchanged(self, energy_db: Session) -> None:
"""AC6: summarize() return dict keys are unchanged by the settlement offset."""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26)
end = _ams_midnight(2026, 6, 27)
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
expected_keys = {
"currency", "metered_import", "metered_export", "metered_net",
"fixed_costs", "credits", "total_payable", "period_count",
"degraded_count", "days",
}
assert set(result.keys()) == expected_keys, (
f"AC6: summarize() key set changed; expected {expected_keys}, "
f"got {set(result.keys())}"
)
+239 -22
View File
@@ -869,7 +869,7 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None:
# identifiers[1] = meter uuid — this is what FUE-T05 sets. # identifiers[1] = meter uuid — this is what FUE-T05 sets.
device = DeviceInfo( device = DeviceInfo(
identifiers=("energy-cost", meter_uuid), identifiers=("energy-cost", meter_uuid),
name="Energy Cost (Test Meter)", name="Test Meter",
provides_availability=False, provides_availability=False,
) )
entity = ExposableEntity( entity = ExposableEntity(
@@ -1729,10 +1729,13 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
"""FU11: import_cost_today getter returns value for today's local window. """FU11: import_cost_today getter returns value for today's local window.
Setup: period at local 'today' (within today's UTC window), active contract. Setup: period at local 'today' (within today's UTC window), active contract.
Timezone pinned to UTC. Verifies: value = metered_import + fixed_costs for 1 day Timezone pinned to UTC, local_now pinned to 2026-06-26 12:00:00 UTC (deterministic midday).
Verifies: value = metered_import + fixed_costs for 1 day
(Principle C: today counts as elapsed if we're past local midnight — which UTC is). (Principle C: today counts as elapsed if we're past local midnight — which UTC is).
FUE-T05: provider requires an active electricity meter. FUE-T05: provider requires an active electricity meter.
FUE-T09: local_now pinned (not real time) to eliminate the ~5s flaky window introduced
by the grace period: (local_now()-5s).date() is unpredictable near UTC 00:00:0000:00:04.
""" """
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
@@ -1740,17 +1743,16 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) # Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always.
# Place a period in today's window (1 hour ago, well within today UTC midnight→tomorrow) fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
# When TZ=UTC, local today = UTC today, so a period 1h ago is in today's window. today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0)
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
# Ensure t0 is still today UTC (handle edge case where now is < 1h past UTC midnight) # Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight).
if t0.date() < now_utc.date(): t0 = fake_now.replace(hour=11, minute=0, second=0, microsecond=0)
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
# Contract starting well before today # Contract starting well before today
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) effective_from = today_midnight - timedelta(days=30)
meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) meter_start = today_midnight - timedelta(days=30)
import_cost_today = 1.23 import_cost_today = 1.23
@@ -1766,8 +1768,12 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
session.commit() session.commit()
with Session(energy_db) as session: with Session(energy_db) as session:
# Pin timezone to UTC so today_local = UTC date (deterministic on any CI host). # Pin both local_tz (UTC) and local_now (deterministic midday) so the today window
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): # is always [2026-06-26 00:00:00 UTC, 2026-06-27 00:00:00 UTC), regardless of CI clock.
with (
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
patch.object(_tz_mod, "local_now", return_value=fake_now),
):
catalog = build_catalog(session) catalog = build_catalog(session)
today_entry = next( today_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_today" e for e in catalog if e.entity.key == "energy.import_cost_today"
@@ -1789,6 +1795,8 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None:
"""FU11: export_revenue_today getter returns value for today's local window. """FU11: export_revenue_today getter returns value for today's local window.
FUE-T05: provider requires an active electricity meter. FUE-T05: provider requires an active electricity meter.
FUE-T09: local_now pinned (not real time) to eliminate the ~5s flaky window introduced
by the grace period: (local_now()-5s).date() is unpredictable near UTC 00:00:0000:00:04.
""" """
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
@@ -1796,13 +1804,15 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None:
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) # Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always.
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
if t0.date() < now_utc.date(): today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0)
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) # Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight).
meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) t0 = fake_now.replace(hour=11, minute=0, second=0, microsecond=0)
effective_from = today_midnight - timedelta(days=30)
meter_start = today_midnight - timedelta(days=30)
export_today = 0.75 export_today = 0.75
with Session(energy_db) as session: with Session(energy_db) as session:
@@ -1817,7 +1827,12 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None:
session.commit() session.commit()
with Session(energy_db) as session: with Session(energy_db) as session:
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): # Pin both local_tz (UTC) and local_now (deterministic midday) so the today window
# is always [2026-06-26 00:00:00 UTC, 2026-06-27 00:00:00 UTC), regardless of CI clock.
with (
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
patch.object(_tz_mod, "local_now", return_value=fake_now),
):
catalog = build_catalog(session) catalog = build_catalog(session)
today_entry = next( today_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_today" e for e in catalog if e.entity.key == "energy.export_revenue_today"
@@ -2288,8 +2303,8 @@ def test_energy_cost_provider_identifiers_match_meter_uuid(energy_db) -> None:
f"expected ('energy-cost', {meter_uuid!r}), " f"expected ('energy-cost', {meter_uuid!r}), "
f"got {entity.device.identifiers!r}" f"got {entity.device.identifiers!r}"
) )
assert meter_label in entity.device.name, ( assert entity.device.name == meter_label, (
f"device.name must contain meter label {meter_label!r}, " f"device.name must be exactly the meter label {meter_label!r}, "
f"got {entity.device.name!r}" f"got {entity.device.name!r}"
) )
@@ -2468,3 +2483,205 @@ def test_energy_cost_identifiers_change_after_meter_swap(energy_db) -> None:
assert entity.device.identifiers[1] != old_uuid, ( assert entity.device.identifiers[1] != old_uuid, (
f"After swap, old uuid {old_uuid!r} must not appear in identifiers" f"After swap, old uuid {old_uuid!r} must not appear in identifiers"
) )
# ---------------------------------------------------------------------------
# FUE-T09: *_today grace — window selection at / around local midnight
# ---------------------------------------------------------------------------
#
# Strategy: patch local_now + local_tz (to UTC) and spy on summarize to capture
# the start/end arguments. Verifies that the 5-second grace means:
# - 00:00:03 → today_local = yesterday (grace not yet elapsed)
# - 00:00:12 → today_local = today (grace elapsed)
# - 12:00:00 → today_local = today (midday, unaffected)
def _spy_summarize_factory(captured: dict):
"""Return a summarize spy that records start/end and returns a zero-value dict."""
def _spy(sess, start, end):
captured["start"] = start
captured["end"] = end
return {"metered_import": 0.0, "fixed_costs": 0.0, "metered_export": 0.0, "credits": 0.0}
return _spy
def _setup_grace_db(energy_db) -> None:
"""Insert active meter + active contract + one non-degraded period for grace tests."""
anchor = datetime(2026, 6, 25, 0, 0, 0, tzinfo=timezone.utc)
with Session(energy_db) as session:
_make_active_meter(session, started_at=anchor, label="Grace Test Meter")
_make_contract_with_version(
session,
values=_STANDING_VALUES,
effective_from=anchor,
active=True,
)
_make_period(session, period_start=anchor, import_cost=1.0, export_revenue=0.5, degraded=False)
session.commit()
def test_today_getter_grace_uses_yesterday_window_at_00_00_03(energy_db) -> None:
"""FUE-T09 AC1a: at local 00:00:03 (< grace=5s), both today getters use YESTERDAY's window.
With grace=5s, (local_now() - 5s) = 2026-06-25 23:59:58, so today_local = 2026-06-25
(yesterday). summarize must be called with start = 2026-06-25 00:00:00 UTC.
Verifies both import and export today getters.
"""
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
_setup_grace_db(energy_db)
# 2026-06-26 00:00:03 UTC — 3 seconds past midnight, still within 5-second grace.
fake_now = datetime(2026, 6, 26, 0, 0, 3, tzinfo=timezone.utc)
captured_import: dict = {}
captured_export: dict = {}
with Session(energy_db) as session:
with (
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
patch.object(_tz_mod, "local_now", return_value=fake_now),
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_import)),
):
catalog = build_catalog(session)
import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today")
import_entry.entity.value_getter(session)
# Reset and test export getter (separate patch context so we get a fresh spy dict)
captured_export = {}
with Session(energy_db) as session:
with (
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
patch.object(_tz_mod, "local_now", return_value=fake_now),
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_export)),
):
catalog = build_catalog(session)
export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_today")
export_entry.entity.value_getter(session)
# Grace not elapsed: today_local = 2026-06-25 (yesterday), so window starts there.
expected_start = datetime(2026, 6, 25, 0, 0, 0, tzinfo=timezone.utc)
assert captured_import.get("start") == expected_start, (
f"import_cost_today at 00:00:03: window start must be yesterday ({expected_start}), "
f"got {captured_import.get('start')}"
)
assert captured_export.get("start") == expected_start, (
f"export_revenue_today at 00:00:03: window start must be yesterday ({expected_start}), "
f"got {captured_export.get('start')}"
)
def test_today_getter_grace_uses_today_window_at_00_00_12(energy_db) -> None:
"""FUE-T09 AC1b: at local 00:00:12 (> grace=5s), both today getters use TODAY's window.
With grace=5s, (local_now() - 5s) = 2026-06-26 00:00:07, so today_local = 2026-06-26
(today). summarize must be called with start = 2026-06-26 00:00:00 UTC.
"""
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
_setup_grace_db(energy_db)
# 2026-06-26 00:00:12 UTC — 12 seconds past midnight, beyond the 5-second grace.
fake_now = datetime(2026, 6, 26, 0, 0, 12, tzinfo=timezone.utc)
captured_import: dict = {}
captured_export: dict = {}
with Session(energy_db) as session:
with (
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
patch.object(_tz_mod, "local_now", return_value=fake_now),
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_import)),
):
catalog = build_catalog(session)
import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today")
import_entry.entity.value_getter(session)
captured_export = {}
with Session(energy_db) as session:
with (
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
patch.object(_tz_mod, "local_now", return_value=fake_now),
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_export)),
):
catalog = build_catalog(session)
export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_today")
export_entry.entity.value_getter(session)
# Grace elapsed: today_local = 2026-06-26 (today), window starts there.
expected_start = datetime(2026, 6, 26, 0, 0, 0, tzinfo=timezone.utc)
assert captured_import.get("start") == expected_start, (
f"import_cost_today at 00:00:12: window start must be today ({expected_start}), "
f"got {captured_import.get('start')}"
)
assert captured_export.get("start") == expected_start, (
f"export_revenue_today at 00:00:12: window start must be today ({expected_start}), "
f"got {captured_export.get('start')}"
)
def test_today_getter_grace_unaffected_at_midday(energy_db) -> None:
"""FUE-T09 AC2: at 12:00, grace doesn't shift the window — today_local still = today.
Well past midnight, (local_now() - 5s).date() == local_now().date() always.
"""
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
_setup_grace_db(energy_db)
# 2026-06-26 12:00:00 UTC — midday.
fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
captured: dict = {}
with Session(energy_db) as session:
with (
patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")),
patch.object(_tz_mod, "local_now", return_value=fake_now),
patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured)),
):
catalog = build_catalog(session)
import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today")
import_entry.entity.value_getter(session)
# At 12:00 the grace (5s) does not push the date back: today_local = 2026-06-26.
expected_start = datetime(2026, 6, 26, 0, 0, 0, tzinfo=timezone.utc)
assert captured.get("start") == expected_start, (
f"import_cost_today at 12:00: window start must be today ({expected_start}), "
f"got {captured.get('start')}"
)
def test_midnight_state_publish_no_raise_when_mqtt_disabled() -> None:
"""FUE-T09 AC3: _run_midnight_state_publish is safe when MQTT is disabled.
_run_midnight_state_publish delegates to publish_states, which is internally
guarded (_should_publish returns False when MQTT is disabled or not connected).
This test verifies publish_states itself doesn't raise under those conditions —
the job's try/except swallows any other exception as well.
"""
settings = _make_settings(mqtt_enabled=False)
mock_mgr = _make_mock_manager(is_connected=False)
with (
patch("app.services.ha_discovery.build_runtime_settings", return_value=settings),
patch("app.services.ha_discovery.mqtt_manager", mock_mgr),
):
from app.services.ha_discovery import publish_states
from sqlalchemy import create_engine as _ce
from sqlalchemy.orm import Session as _S
eng = _ce("sqlite:///:memory:")
with _S(eng) as sess:
publish_states(sess) # must not raise
mock_mgr.publish.assert_not_called()