FU10: localize energy time math to server tz; accrue fixed/credit per elapsed local day
- 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:
+339
-20
@@ -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 26–30 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user