FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
frontend / frontend (push) Successful in 2m5s
pytest / test (push) Successful in 7m34s

- Store UTC, compute day boundaries in server local timezone (new app/services/timezone.py).
- summarize: fixed fee / tax credit accrue only for elapsed whole local days,
  integrated across contract versions — no future-day inflation, no version-switch collapse.
- MQTT import_cost_total / export_revenue_total getters delegate to summarize (no inline
  apportionment), anchored at the active contract's earliest version effective_from so the
  total sensors stay monotonic and never jump backward.
- effective_from: naive datetimes interpreted as server-local wall-clock, then stored UTC.
- frontend ContractForm sends local-midnight naive datetime (was UTC midnight).
- get_costs_summary default window uses server-local "today".
This commit is contained in:
2026-06-25 11:13:30 +02:00
parent 682e06d256
commit 8b91146c29
10 changed files with 941 additions and 158 deletions
+339 -20
View File
@@ -1,4 +1,4 @@
"""Tests for M6-T07: billing engine (app/services/energy_cost.py).
"""Tests for M6-T07 + FU10: billing engine (app/services/energy_cost.py).
Acceptance criteria covered
----------------------------
@@ -13,8 +13,9 @@ Acceptance criteria covered
5. Missing readings (no DsmrReading covering a boundary) → degraded row.
6. Cross-version selection: two contract versions with different effective
dates; each t0 picks the correct version (asserts contract_version_id).
7. ``summarize``: Σnet + standing charges (month/30 × days) heffingskorting
(year/365 × days) — hand-calculated against known values.
7. ``summarize`` (Principle C): fixed costs & credits count only elapsed whole
*local* calendar days, cross-version. Sub-day windows → 0 fixed/credit.
One whole local day → full rate. Future dates → 0. Cross-version → sum.
8. ``compute_closed_periods``: only processes closed and uncalculated periods;
does not touch already-computed (non-degraded) rows.
9. ``recompute_range``: explicitly overwrites all periods in [start, end)
@@ -50,6 +51,7 @@ from app.services.energy_cost import (
recompute_range,
summarize,
)
from app.services import timezone as tz_module
# ---------------------------------------------------------------------------
@@ -892,22 +894,36 @@ class TestSummarize:
assert result["period_count"] == 2
assert result["degraded_count"] == 0
def test_fixed_costs_formula(self, energy_db: Session) -> None:
"""Fixed costs = (network_fee + management_fee) / 30 × days."""
self._setup_two_periods(energy_db)
# Interval is 30 minutes = 0.5/48 day = 0.020833... days
days = Decimal("1800") / Decimal("86400")
expected_fixed = (Decimal("9.87") + Decimal("9.87")) / Decimal("30") * days
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert abs(Decimal(str(result["fixed_costs"])) - expected_fixed) < Decimal("1e-9")
def test_fixed_costs_zero_for_sub_day_window(self, energy_db: Session) -> None:
"""Principle C: a 30-minute window within the same local day counts 0 whole days.
def test_credits_formula(self, energy_db: Session) -> None:
"""Credits = heffingskorting / 365 × days."""
Fixed costs are only added for elapsed whole local calendar days.
A window [10:00, 10:30) UTC stays within the same local calendar date
regardless of timezone offset (any UTC-11..UTC+14 range), so no whole
day is covered → fixed_costs = 0.
"""
from zoneinfo import ZoneInfo
self._setup_two_periods(energy_db)
days = Decimal("1800") / Decimal("86400")
expected_credits = Decimal("600.0") / Decimal("365") * days
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
assert abs(Decimal(str(result["credits"])) - expected_credits) < Decimal("1e-9")
# Pin the local timezone so the test is deterministic on any CI host.
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
):
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"
)
def test_credits_zero_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."""
from zoneinfo import ZoneInfo
self._setup_two_periods(energy_db)
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
):
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"
)
def test_total_payable_formula(self, energy_db: Session) -> None:
"""total_payable = metered_net + fixed_costs credits."""
@@ -949,15 +965,27 @@ class TestSummarize:
assert result["period_count"] == 0
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 interval.
Principle C: the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC) spans
exactly one local calendar day in Europe/Amsterdam (CEST=UTC+2: 02:00→02:00).
effective_from = June 23 00:00 UTC = June 23 02:00 CEST = local date June 23.
Today (2026-06-25) ≥ June 23, so the day is elapsed → 1 day counted.
"""
from zoneinfo import ZoneInfo
from unittest.mock import patch
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _MANUAL_VALUES, effective_from=_ts(0, 0))
energy_db.commit()
# Summarize over exactly 1 day (no periods in DB — only standing/credits).
# Summarize over exactly 1 UTC day (no periods in DB — only standing/credits).
# Pin timezone to Europe/Amsterdam for deterministic local-date mapping.
start = datetime(2026, 6, 23, 0, 0, 0, tzinfo=_UTC)
end = datetime(2026, 6, 24, 0, 0, 0, tzinfo=_UTC)
result = summarize(energy_db, start, end)
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
result = summarize(energy_db, start, end)
# fixed_costs = (9.87 + 9.87) / 30 × 1.0 = 0.658
assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30) < 1e-9
@@ -968,6 +996,297 @@ class TestSummarize:
assert abs(result["total_payable"] - expected_total) < 1e-9
# ---------------------------------------------------------------------------
# 7b. summarize — Principle C (whole-local-day counting, cross-version)
# ---------------------------------------------------------------------------
#
# All tests in this section pin the local timezone to Europe/Amsterdam via
# monkeypatch so assertions are deterministic regardless of CI host timezone.
# "Today" in these tests is 2026-06-25 (CEST = UTC+2).
#
# Reference rates:
# network_fee = 9.87 EUR/month, management_fee = 9.87 EUR/month
# daily_fixed = (9.87 + 9.87) / 30 = 0.658 EUR/day
# heffingskorting = 600.0 EUR/year
# daily_credit = 600.0 / 365 ≈ 1.6438... EUR/day
#
# Window semantics:
# start_local_date = local_date(start_utc) (inclusive)
# end_local_date = local_date(end_utc) (exclusive upper bound)
# cap = min(end_local_date, today_local + 1 day)
# counted_days = max(0, (cap - start_local_date).days)
# restricted to the version's [v_start, v_end) range
#
# CEST = UTC+2; local midnight June D = UTC June (D-1) 22:00.
# So:
# start "This Month" (June 1 local 00:00) = May 31 22:00 UTC
# end "Next Month" (July 1 local 00:00) = June 30 22:00 UTC
# In UTC we feed actual UTC boundaries.
#
# Table (today = June 25 local, effective_from = June 1 local 00:00 = May 31 22:00 UTC):
#
# | Window (UTC) | Local dates | Elapsed | Expected |
# |----------------------------------|-------------------------|---------|----------|
# | June 25 UTC+2 today (1 local day)| [June 25, June 26) | 1 day | 1 day |
# | June 25 UTC+2 → June 28 UTC+2 | [June 25, June 28) | 1 day* | 1 day |
# | June 1 CEST → July 1 CEST (mon) | [June 1, July 1) | 25 days*| 25 days |
# | May 1 → June 1 (all past) | [May 1, June 1) | 31 days | 31 days |
# | July 1 → Aug 1 (all future) | [July 1, Aug 1) | 0 days | 0 days |
#
# *today local = June 25: "today" counts but June 26+ does not.
_AMS = None # ZoneInfo resolved lazily
def _ams():
global _AMS
if _AMS is None:
from zoneinfo import ZoneInfo
_AMS = ZoneInfo("Europe/Amsterdam")
return _AMS
def _local_midnight_utc_ams(year: int, month: int, day: int) -> datetime:
"""Return UTC instant for Europe/Amsterdam local midnight on the given date."""
from zoneinfo import ZoneInfo
ams = ZoneInfo("Europe/Amsterdam")
return datetime(year, month, day, 0, 0, 0, tzinfo=ams).replace(tzinfo=None).replace(
tzinfo=ams
).astimezone(_UTC)
# Simpler helper using ZoneInfo directly:
def _ams_midnight(year: int, month: int, day: int) -> datetime:
"""UTC datetime corresponding to Europe/Amsterdam local midnight on year/month/day."""
from zoneinfo import ZoneInfo
ams = ZoneInfo("Europe/Amsterdam")
return datetime(year, month, day, 0, 0, 0, tzinfo=ams).astimezone(_UTC)
class TestSummarizePrincipleC:
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone."""
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
# Today local = June 25 CEST (since today is 2026-06-25).
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
"""Create an active manual contract with a single version."""
contract = _make_contract(session, kind="manual", active=True)
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
session.commit()
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict:
"""Run summarize with Europe/Amsterdam local_tz monkeypatched."""
from unittest.mock import patch
with patch.object(tz_module, "local_tz", return_value=_ams()):
return summarize(session, start_utc, end_utc)
def test_today_window_counts_1_day(self, energy_db: Session) -> None:
"""Today's window (local today 00:00 → local tomorrow 00:00) counts 1 day.
Matches table row: Today 6/25→6/26 → 1 day.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 6, 26)
result = self._run_summarize_ams(energy_db, start, end)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
f"Expected 1 day of fixed costs, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
f"Expected 1 day of credits, got {result['credits']}"
)
def test_future_window_counts_0_days(self, energy_db: Session) -> None:
"""A fully future window (all local dates > today) counts 0 days.
Matches table row: 7/1→8/1 (all future) → 0 days.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 7, 1)
end = _ams_midnight(2026, 8, 1)
result = self._run_summarize_ams(energy_db, start, end)
assert result["fixed_costs"] == 0.0, (
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
)
assert result["credits"] == 0.0, (
f"All-future window must count 0 days; got credits={result['credits']}"
)
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).
Today = June 25 local, so June 2630 are not yet elapsed.
Matches table row: 6/1→7/1 (This Month) → 25 days.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1)
result = self._run_summarize_ams(energy_db, start, end)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 25) < 1e-9, (
f"Expected 25 days of fixed costs (June 1-25 elapsed), got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 25) < 1e-9, (
f"Expected 25 days of credits, got {result['credits']}"
)
def test_past_month_window_counts_all_31_days(self, energy_db: Session) -> None:
"""Last month (May 1 → June 1 local) counts all 31 days (fully past).
Matches table row: 5/1→6/1 (all past) → 31 days.
effective_from is June 1 so May is before the contract — 0 days from contract.
Use an earlier effective_from to cover May.
"""
eff_utc = _ams_midnight(2026, 1, 1) # contract starts Jan 1 → covers May
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 5, 1)
end = _ams_midnight(2026, 6, 1)
result = self._run_summarize_ams(energy_db, start, end)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 31) < 1e-9, (
f"Expected 31 days of fixed costs (May 1-31 all past), got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 31) < 1e-9, (
f"Expected 31 days of credits, got {result['credits']}"
)
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).
Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
Today = June 25 → only 1 day elapsed (June 25).
Matches table row: 6/25→6/28 → 1 day.
"""
eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 6, 28)
result = self._run_summarize_ams(energy_db, start, end)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
f"Expected 1 day of fixed costs (only June 25 elapsed), got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
f"Expected 1 day of credits, got {result['credits']}"
)
def test_cross_version_integration(self, energy_db: Session) -> None:
"""Cross-version integration: V1 June 1-24, V2 June 25+ (today's boundary).
Window: This Month (June 1 → July 1 local) = 25 days total.
- V1: June 1-24 (24 days) at rate1
- V2: June 25-25 (1 day, today) at rate2
V1 rates: network_fee=6.0, management_fee=6.0 → daily = 12/30 = 0.4
heffingskorting=300 → daily = 300/365
V2 rates: network_fee=12.0, management_fee=12.0 → daily = 24/30 = 0.8
heffingskorting=600 → daily = 600/365
Expected:
fixed_costs = 24 × 0.4 + 1 × 0.8 = 9.6 + 0.8 = 10.4
credits = 24 × (300/365) + 1 × (600/365) = (7200+600)/365 = 7800/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: effective June 1 CEST local. V2: effective June 25 CEST local.
v1_from = _ams_midnight(2026, 6, 1)
v2_from = _ams_midnight(2026, 6, 25)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from,
effective_to=v2_from)
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
energy_db.commit()
start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = summarize(energy_db, start, end)
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
expected_fixed = 24 * 12 / 30 + 1 * 24 / 30
expected_credits = 24 * 300 / 365 + 1 * 600 / 365
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
f"Cross-version fixed_costs wrong: expected {expected_fixed}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - expected_credits) < 1e-9, (
f"Cross-version credits wrong: expected {expected_credits}, got {result['credits']}"
)
def test_version_switch_does_not_reset_counter(self, energy_db: Session) -> None:
"""Adding a new version does not reset the cumulative counter.
With V1 from June 1 and V2 from June 25, querying June 25 → July 1
still returns V2's 1-day rate without losing the historical V1 days
(the MQTT getter anchors at V1.effective_from to accumulate everything).
This test just checks that V2-only window returns V2 rate × 1 day.
"""
_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, 1)
v2_from = _ams_midnight(2026, 6, 25)
contract = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, contract, _VALUES_V1, effective_from=v1_from,
effective_to=v2_from)
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
energy_db.commit()
# Window = June 25 only (today, 1 day elapsed at V2 rate)
start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = summarize(energy_db, start, end)
# Only 1 day at V2 rate
expected_fixed = 1 * 24 / 30
expected_credits = 1 * 600 / 365
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
assert abs(result["credits"] - expected_credits) < 1e-9
# ---------------------------------------------------------------------------
# 8. compute_closed_periods
# ---------------------------------------------------------------------------
+101 -55
View File
@@ -871,27 +871,39 @@ _STANDING_VALUES = {
def test_import_cost_total_includes_standing_charges(energy_db) -> None:
"""import_cost_total getter must add prorated standing charges from active contract.
Principle B/D: the getter delegates to summarize(anchor → now).
anchor = earliest version's effective_from.
With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day.
Elapsed days = (now.date() - effective_from.date()).days + 1.
The getter uses summarize, which counts whole *local* elapsed days under Principle C.
We pin the timezone to UTC for simplicity: local days = UTC days.
Setup:
- effective_from = 10 full UTC days ago (exact UTC midnight), so today is day 11
but Principle C counts only days D ≤ today, and today is included as 1 day.
With anchor = 10 days ago UTC midnight and now near end of day 11:
local (UTC) dates: [10-days-ago, today_inclusive] = 11 days.
We place period rows within [anchor, now] so summarize sees them.
"""
from decimal import Decimal
from app.integrations.expose import build_catalog
# Set effective_from to 10 days ago (UTC) to get a known elapsed day count.
now_utc = datetime.now(timezone.utc)
effective_from = datetime(
now_utc.year, now_utc.month, now_utc.day, 0, 0, 0, tzinfo=timezone.utc
).replace(day=1)
# Use a date we know: 10 days before today (simulate by computing days ourselves)
from datetime import timedelta
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=9)
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
# Expected elapsed days = 9 + 1 = 10 (from effective_from date to today, inclusive)
expected_days = (now_utc.date() - effective_from.date()).days + 1
now_utc = datetime.now(timezone.utc)
# anchor = exactly 10 UTC days ago at midnight
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=10)
# Under UTC timezone (pinned), Principle C counts whole days from effective_from.date()
# to today (inclusive) = 11 days total (10 + today).
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11
import_cost_sum = 5.00 # EUR
t0 = datetime(2025, 10, 1, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2025, 10, 1, 6, 15, tzinfo=timezone.utc)
# Place period rows AFTER effective_from so summarize(anchor, now) picks them up.
t0 = effective_from + timedelta(hours=1)
t1 = t0 + timedelta(minutes=15)
with Session(energy_db) as session:
_make_contract_with_version(
@@ -905,17 +917,19 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
session.commit()
with Session(energy_db) as session:
catalog = build_catalog(session)
import_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_total"
)
value = import_entry.entity.value_getter(session)
# Pin to UTC so local days = UTC days (deterministic on any CI host).
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session)
import_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_total"
)
value = import_entry.entity.value_getter(session)
# daily_standing = (30 + 60) / 30 = 3.0 EUR/day
daily_standing = Decimal("90") / Decimal("30")
expected = float(Decimal(str(import_cost_sum)) + daily_standing * expected_days)
assert value == pytest.approx(expected, rel=1e-9), (
assert value == pytest.approx(expected, rel=1e-6), (
f"Expected import_cost_total={expected} (sum={import_cost_sum} + "
f"standing={float(daily_standing * expected_days)} over {expected_days} days), "
f"got {value!r}"
@@ -930,20 +944,26 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
"""export_revenue_total getter must add prorated heffingskorting from active contract.
With heffingskorting=365 EUR/year, daily_credit = 1.0 EUR/day.
Principle B/D: delegates to summarize(anchor → now).
With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
Timezone pinned to UTC for deterministic local-day counting.
"""
from decimal import Decimal
from datetime import timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc)
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=4)
# Expected elapsed days = 4 + 1 = 5
expected_days = (now_utc.date() - effective_from.date()).days + 1
# Under UTC pinned: expected_days = days from effective_from.date() to today inclusive.
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5
t0 = datetime(2025, 11, 1, 6, 0, tzinfo=timezone.utc)
t1 = datetime(2025, 11, 1, 6, 15, tzinfo=timezone.utc)
# Place period rows AFTER effective_from.
t0 = effective_from + timedelta(hours=1)
t1 = t0 + timedelta(minutes=15)
export_sum = 2.50 # EUR
with Session(energy_db) as session:
@@ -958,17 +978,18 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
session.commit()
with Session(energy_db) as session:
catalog = build_catalog(session)
export_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_total"
)
value = export_entry.entity.value_getter(session)
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session)
export_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_total"
)
value = export_entry.entity.value_getter(session)
# daily_credit = 365 / 365 = 1.0 EUR/day
daily_credit = Decimal("365") / Decimal("365")
expected = float(Decimal(str(export_sum)) + daily_credit * expected_days)
assert value == pytest.approx(expected, rel=1e-9), (
assert value == pytest.approx(expected, rel=1e-6), (
f"Expected export_revenue_total={expected} (sum={export_sum} + "
f"credit={float(daily_credit * expected_days)} over {expected_days} days), "
f"got {value!r}"
@@ -981,14 +1002,32 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> None:
"""When contract version effective_from is in the future, standing cumulative must be 0."""
"""When contract version effective_from is in the future, standing cumulative must be 0.
Principle D: anchor = effective_from (future).
summarize(anchor → now) has a negative/empty window → 0 days, 0 fixed_costs.
The global `has_data` check passes (there are non-degraded rows from the past),
but no periods fall within [anchor, now] window → metered = 0, fixed = 0.
The getter returns 0 + 0 = 0.
Note: under the new design, if a contract has a future effective_from, the
getter returns the pure metered sum from the summarize window (which is 0
since no periods exist after the future anchor). The old test expected to
return the raw global SUM, but with Principle D the anchor is the future
date → window is empty → metered = 0.
"""
from datetime import timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc)
future_effective = now_utc + timedelta(days=30)
t0 = datetime(2025, 12, 1, 6, 0, tzinfo=timezone.utc)
# Place a period BEFORE the future effective_from (global sum = 4.00)
# but it won't be in [anchor, now] window.
t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
import_cost_sum = 4.00
with Session(energy_db) as session:
@@ -1002,30 +1041,37 @@ def test_import_cost_standing_zero_when_effective_from_in_future(energy_db) -> N
session.commit()
with Session(energy_db) as session:
catalog = build_catalog(session)
import_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_total"
)
# Note: active_contract_version_at uses ts=now, but effective_from > now,
# so the version does not cover now → version lookup returns None → standing = 0.
value = import_entry.entity.value_getter(session)
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session)
import_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_total"
)
value = import_entry.entity.value_getter(session)
# Standing cumulative must be 0; only the raw SUM is returned.
assert value == pytest.approx(import_cost_sum, rel=1e-9), (
f"When effective_from is in future, standing must be 0; "
f"expected {import_cost_sum}, got {value!r}"
# anchor is in future → summarize([future, now]) is empty → metered=0, fixed_costs=0
# Principle C: no future local days counted → 0 fixed costs.
# Result = 0 (no periods in window) + 0 (0 standing days) = 0.
assert value == pytest.approx(0.0, abs=1e-9), (
f"When effective_from is in future, summarize window is empty → value must be 0.0; "
f"got {value!r}"
)
def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) -> None:
"""When contract version effective_from is in the future, credit cumulative must be 0."""
"""When contract version effective_from is in the future, credit cumulative must be 0.
Same logic as import: anchor is future → summarize window empty → 0 credits.
"""
from datetime import timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
from app.integrations.expose import build_catalog
from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc)
future_effective = now_utc + timedelta(days=60)
t0 = datetime(2025, 12, 2, 6, 0, tzinfo=timezone.utc)
t0 = now_utc.replace(hour=6, minute=0, second=0, microsecond=0) - timedelta(days=1)
export_sum = 3.00
with Session(energy_db) as session:
@@ -1039,16 +1085,16 @@ def test_export_revenue_credit_zero_when_effective_from_in_future(energy_db) ->
session.commit()
with Session(energy_db) as session:
catalog = build_catalog(session)
export_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_total"
)
value = export_entry.entity.value_getter(session)
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
catalog = build_catalog(session)
export_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_total"
)
value = export_entry.entity.value_getter(session)
# Credit cumulative must be 0; only the raw SUM is returned.
assert value == pytest.approx(export_sum, rel=1e-9), (
f"When effective_from is in future, credit must be 0; "
f"expected {export_sum}, got {value!r}"
# anchor is in future → empty window → 0 credits
assert value == pytest.approx(0.0, abs=1e-9), (
f"When effective_from is in future, credit must be 0; got {value!r}"
)
+187
View File
@@ -0,0 +1,187 @@
"""Tests for app/services/timezone.py (FU10-energy-tz-accrual).
All tests monkeypatch ``local_tz`` to a fixed zone so they are deterministic
on any CI host timezone.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from zoneinfo import ZoneInfo
import pytest
from app.services import timezone as tz_mod
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@pytest.fixture()
def ams(monkeypatch):
"""Pin local_tz to Europe/Amsterdam for all tests in this module."""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
@pytest.fixture()
def utc_tz(monkeypatch):
"""Pin local_tz to UTC for simple arithmetic tests."""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("UTC"))
# ---------------------------------------------------------------------------
# local_tz() — resolution and monkeypatch contract
# ---------------------------------------------------------------------------
def test_local_tz_returns_tzinfo(ams):
"""local_tz() must return a tzinfo object."""
tz = tz_mod.local_tz()
assert tz is not None
# Verify it behaves like a valid tzinfo.
d = datetime(2026, 6, 25, 12, 0, tzinfo=tz)
assert d.utcoffset() is not None
def test_local_tz_env_var_overrides(monkeypatch):
"""When TZ env var is set, local_tz() returns that zone."""
monkeypatch.setenv("TZ", "America/New_York")
tz = tz_mod.local_tz()
# ZoneInfo("America/New_York") should have UTC-5 or UTC-4 offset.
d = datetime(2026, 1, 1, 12, 0, tzinfo=tz) # January → UTC-5
offset_hours = d.utcoffset().total_seconds() / 3600
assert offset_hours == pytest.approx(-5.0)
monkeypatch.delenv("TZ")
# ---------------------------------------------------------------------------
# to_local() — conversion correctness
# ---------------------------------------------------------------------------
def test_to_local_aware_utc(ams):
"""Aware UTC datetime must convert to CEST (UTC+2) in summer."""
dt_utc = datetime(2026, 6, 25, 12, 0, tzinfo=UTC)
local = tz_mod.to_local(dt_utc)
assert local.tzinfo is not None
assert local.hour == 14 # CEST = UTC+2
def test_to_local_naive_treated_as_utc(ams):
"""Naive datetime must be treated as UTC and converted to local."""
naive = datetime(2026, 6, 25, 12, 0) # naive = UTC convention
local = tz_mod.to_local(naive)
assert local.hour == 14 # CEST = UTC+2
def test_to_local_winter_offset(monkeypatch):
"""CET (UTC+1) applies in January."""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
dt_utc = datetime(2026, 1, 15, 12, 0, tzinfo=UTC)
local = tz_mod.to_local(dt_utc)
assert local.hour == 13 # CET = UTC+1
# ---------------------------------------------------------------------------
# local_date() — date extraction
# ---------------------------------------------------------------------------
def test_local_date_around_cest_midnight(ams):
"""UTC 22:00 on June 24 is local midnight June 25 in CEST."""
# CEST = UTC+2; June 24 22:00 UTC = June 25 00:00 CEST
dt = datetime(2026, 6, 24, 22, 0, tzinfo=UTC)
d = tz_mod.local_date(dt)
assert d == date(2026, 6, 25)
def test_local_date_just_before_cest_midnight(ams):
"""UTC 21:59 on June 24 is still June 24 in CEST (23:59 local)."""
dt = datetime(2026, 6, 24, 21, 59, tzinfo=UTC)
d = tz_mod.local_date(dt)
assert d == date(2026, 6, 24)
# ---------------------------------------------------------------------------
# local_midnight_utc() — round-trip correctness
# ---------------------------------------------------------------------------
def test_local_midnight_utc_cest_summer(ams):
"""Europe/Amsterdam local midnight June 25 → June 24 22:00 UTC (CEST=UTC+2)."""
utc_midnight = tz_mod.local_midnight_utc(date(2026, 6, 25))
expected = datetime(2026, 6, 24, 22, 0, tzinfo=UTC)
assert utc_midnight == expected
def test_local_midnight_utc_cet_winter(monkeypatch):
"""Europe/Amsterdam local midnight Jan 15 → Jan 14 23:00 UTC (CET=UTC+1)."""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
utc_midnight = tz_mod.local_midnight_utc(date(2026, 1, 15))
expected = datetime(2026, 1, 14, 23, 0, tzinfo=UTC)
assert utc_midnight == expected
def test_local_midnight_utc_roundtrip(ams):
"""local_date(local_midnight_utc(D)) must return D for any date D."""
for day in range(1, 32):
d = date(2026, 6, day) if day <= 30 else date(2026, 7, day - 30)
if day > 30:
continue
midnight_utc = tz_mod.local_midnight_utc(d)
recovered = tz_mod.local_date(midnight_utc)
assert recovered == d, f"Round-trip failed for {d}"
# ---------------------------------------------------------------------------
# local_now() — sanity check
# ---------------------------------------------------------------------------
def test_local_now_is_aware(ams):
"""local_now() must return a timezone-aware datetime."""
now = tz_mod.local_now()
assert now.tzinfo is not None
assert now.utcoffset() is not None
# ---------------------------------------------------------------------------
# effective_from naive→local→UTC (Principle A — contract routes)
# ---------------------------------------------------------------------------
def test_localize_naive_effective_from(monkeypatch):
"""A naive '2026-06-25T00:00:00' must be interpreted as CEST local midnight.
Expected: CEST midnight June 25 = UTC June 24 22:00.
"""
monkeypatch.setattr(tz_mod, "local_tz", lambda: ZoneInfo("Europe/Amsterdam"))
# Simulate what the route helper does:
from app.api.routes.api.energy_contracts import _localize_effective_from
naive = datetime(2026, 6, 25, 0, 0, 0) # naive, no tzinfo
utc_result = _localize_effective_from(naive)
assert utc_result.tzinfo is not None
expected = datetime(2026, 6, 24, 22, 0, 0, tzinfo=UTC)
assert utc_result == expected, (
f"Naive local midnight should localize to {expected}, got {utc_result}"
)
def test_localize_aware_effective_from():
"""An aware datetime must be stored as UTC unchanged (no double conversion)."""
from app.api.routes.api.energy_contracts import _localize_effective_from
aware = datetime(2026, 6, 25, 0, 0, 0, tzinfo=UTC)
utc_result = _localize_effective_from(aware)
assert utc_result == aware
def test_localize_none_effective_from():
"""None effective_from must return current UTC time (as before)."""
from app.api.routes.api.energy_contracts import _localize_effective_from
before = datetime.now(UTC)
result = _localize_effective_from(None)
after = datetime.now(UTC)
assert result.tzinfo is not None
assert before <= result <= after