FUE-T01: count billing window start day in full for fixed-fee/credit accrual (symmetric with end day)
This commit is contained in:
+175
-29
@@ -996,35 +996,52 @@ class TestSummarize:
|
||||
assert result["period_count"] == 2
|
||||
assert result["degraded_count"] == 0
|
||||
|
||||
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_fixed_costs_one_day_for_sub_day_window(self, energy_db: Session) -> None:
|
||||
"""FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day.
|
||||
|
||||
Fixed costs are only added for elapsed whole local calendar days.
|
||||
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.
|
||||
Under the symmetric-begin/end fix, fixed charges are assessed on a
|
||||
"service-is-active" basis: if the window falls within a local calendar day,
|
||||
that entire day's charge applies. The begin side no longer requires a local
|
||||
midnight to fall *inside* the window — the local date of start_utc itself is
|
||||
always counted as the first day.
|
||||
|
||||
Window [10:00, 10:30) UTC in Europe/Amsterdam (CEST = UTC+2):
|
||||
local date of start: June 23 (12:00 CEST)
|
||||
local date of end: June 23 (12:30 CEST)
|
||||
→ first_counted = last_counted = June 23 → 1 day
|
||||
→ fixed_costs = (9.87 + 9.87) / 30 × 1
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
from unittest.mock import patch
|
||||
|
||||
self._setup_two_periods(energy_db)
|
||||
# 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")
|
||||
):
|
||||
with 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"
|
||||
|
||||
expected_fixed = (9.87 + 9.87) / 30 # 1 day
|
||||
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
|
||||
f"FUE-T01: sub-day window in one local day should count 1 day of fixed costs; "
|
||||
f"expected {expected_fixed:.6f}, got {result['fixed_costs']}"
|
||||
)
|
||||
|
||||
def test_credits_zero_for_sub_day_window(self, energy_db: Session) -> None:
|
||||
"""Principle C: a 30-minute window within the same local day counts 0 whole days for credits."""
|
||||
def test_credits_one_day_for_sub_day_window(self, energy_db: Session) -> None:
|
||||
"""FUE-T01 (symmetric begin/end): a 30-minute window within one local day counts 1 day of credits.
|
||||
|
||||
Same rationale as test_fixed_costs_one_day_for_sub_day_window.
|
||||
credits = 600.0 / 365 × 1 day.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
from unittest.mock import patch
|
||||
|
||||
self._setup_two_periods(energy_db)
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
|
||||
tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")
|
||||
):
|
||||
with patch.object(tz_module, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")):
|
||||
result = summarize(energy_db, _ts(10, 0), _ts(10, 30))
|
||||
assert result["credits"] == 0.0, (
|
||||
"Sub-day window should contribute 0 whole-day credits under Principle C"
|
||||
|
||||
expected_credits = 600.0 / 365 # 1 day
|
||||
assert abs(result["credits"] - expected_credits) < 1e-9, (
|
||||
f"FUE-T01: sub-day window in one local day should count 1 day of credits; "
|
||||
f"expected {expected_credits:.6f}, got {result['credits']}"
|
||||
)
|
||||
|
||||
def test_total_payable_formula(self, energy_db: Session) -> None:
|
||||
@@ -1068,12 +1085,20 @@ 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 UTC 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.
|
||||
FUE-T01 (symmetric begin/end): the window [2026-06-23 00:00 UTC, 2026-06-24 00:00 UTC)
|
||||
in Europe/Amsterdam (CEST=UTC+2):
|
||||
- local date of start (June 23 00:00 UTC = June 23 02:00 CEST) = June 23
|
||||
→ first_counted = June 23 (anchor day always counted)
|
||||
- local midnight of June 24 = June 23 22:00 UTC, which is < end_utc (June 24 00:00 UTC)
|
||||
→ last_counted = June 24
|
||||
- today (2026-06-25) ≥ June 24 → cap doesn't apply
|
||||
→ 2 local calendar days counted (June 23 + June 24)
|
||||
|
||||
Note: a 1-day UTC window centered at non-midnight UTC spans **two** local days
|
||||
in Amsterdam (CEST=UTC+2) because local midnight (June 23 22:00 UTC) falls
|
||||
inside the window. This is correct under the symmetric begin/end principle.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
from unittest.mock import patch
|
||||
@@ -1091,12 +1116,18 @@ class TestSummarize:
|
||||
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
|
||||
# credits = 600 / 365
|
||||
assert abs(result["credits"] - 600.0 / 365) < 1e-9
|
||||
# total = 0 + 0.658 − (600/365)
|
||||
expected_total = (9.87 + 9.87) / 30 - 600.0 / 365
|
||||
# 2 local days (June 23 start day + June 24 because local midnight of June 24
|
||||
# = June 23 22:00 UTC falls within [start, end)).
|
||||
n_days = 2
|
||||
assert abs(result["fixed_costs"] - (9.87 + 9.87) / 30 * n_days) < 1e-9, (
|
||||
f"FUE-T01: 1-day UTC window = 2 local days in Amsterdam; "
|
||||
f"expected fixed={((9.87 + 9.87) / 30 * n_days):.6f}, got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - 600.0 / 365 * n_days) < 1e-9, (
|
||||
f"FUE-T01: expected credits={600.0 / 365 * n_days:.6f}, got {result['credits']}"
|
||||
)
|
||||
# total = 0 + fixed − credits
|
||||
expected_total = (9.87 + 9.87) / 30 * n_days - 600.0 / 365 * n_days
|
||||
assert abs(result["total_payable"] - expected_total) < 1e-9
|
||||
|
||||
|
||||
@@ -1390,6 +1421,121 @@ class TestSummarizePrincipleC:
|
||||
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9
|
||||
assert abs(result["credits"] - expected_credits) < 1e-9
|
||||
|
||||
def test_morning_anchor_first_day_counted(self, energy_db: Session) -> None:
|
||||
"""FUE-T01: anchor falling above local midnight counts its local day as day 1.
|
||||
|
||||
Bug scenario: meter.started_at = June 24 09:18 local time (07:18 UTC, CEST=UTC+2).
|
||||
Old code: local_date(07:18 UTC) = June 24; _lmu(June 24) = June 23 22:00 UTC;
|
||||
June 23 22:00 UTC < June 24 07:18 UTC → False (22:00 < 07:18? No — 22:00 UTC June 23 is
|
||||
*before* 07:18 UTC June 24; so the condition >= start_utc was False);
|
||||
first_counted bumped to June 25. June 24's charges were silently dropped.
|
||||
|
||||
Fix: first_counted = local_date(start_utc) = June 24 always, regardless of where
|
||||
within the day start_utc falls. June 24 is the anchor day → its charge is counted.
|
||||
|
||||
Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)).
|
||||
Today local = June 25 (2026-06-25).
|
||||
first_counted = June 24 (anchor day, previously dropped).
|
||||
last_counted = min(June 26, June 25) = June 25.
|
||||
n_days = June 25 - June 24 + 1 = 2.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
eff_utc = _ams_midnight(2026, 6, 1)
|
||||
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||
|
||||
# anchor = June 24 07:18 UTC = June 24 09:18 CEST (above local midnight)
|
||||
anchor_utc = datetime(2026, 6, 24, 7, 18, 0, tzinfo=_UTC)
|
||||
# end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped)
|
||||
end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
|
||||
|
||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
# June 24 (anchor day) + June 25 (today) = 2 days
|
||||
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
|
||||
f"FUE-T01: morning anchor should count anchor day + today = 2 days of fixed costs; "
|
||||
f"expected {daily_fixed * 2:.6f}, got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
|
||||
f"FUE-T01: morning anchor should count anchor day + today = 2 days of credits; "
|
||||
f"expected {daily_credit * 2:.6f}, got {result['credits']}"
|
||||
)
|
||||
|
||||
def test_daily_window_midnight_aligned_unaffected(self, energy_db: Session) -> None:
|
||||
"""FUE-T01: the daily getter window [today midnight, tomorrow midnight) is not affected.
|
||||
|
||||
The *_today getters use windows exactly aligned to local midnight:
|
||||
start = local_midnight_utc(today) = June 24 22:00 UTC (for June 25 local)
|
||||
end = local_midnight_utc(tomorrow) = June 25 22:00 UTC
|
||||
|
||||
With either old or new logic, first_counted = June 25 (today):
|
||||
- Old: local_date(June 24 22:00 UTC in Amsterdam) = June 25;
|
||||
_lmu(June 25) = June 24 22:00 UTC >= June 24 22:00 UTC → True → first = June 25.
|
||||
- New: first_counted = local_date(June 24 22:00 UTC) = June 25.
|
||||
Both give the same result: 1 day counted.
|
||||
|
||||
This test explicitly verifies daily window behaviour is unchanged.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
eff_utc = _ams_midnight(2026, 6, 1)
|
||||
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||
|
||||
# Today's window aligned to local midnight (June 25 CEST = June 24 22:00 UTC).
|
||||
today_start_utc = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
|
||||
tomorrow_start_utc = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
|
||||
|
||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||
result = self._run_summarize_ams(energy_db, today_start_utc, tomorrow_start_utc)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
# Exactly 1 day counted (June 25 only; June 26 is future, capped).
|
||||
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
||||
f"FUE-T01: daily window should count exactly 1 day; "
|
||||
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
||||
f"FUE-T01: daily window should count exactly 1 day of credits; "
|
||||
f"expected {daily_credit:.6f}, got {result['credits']}"
|
||||
)
|
||||
|
||||
def test_single_day_morning_anchor_counts_one_day(self, energy_db: Session) -> None:
|
||||
"""FUE-T01: a window starting mid-morning on a single local day counts that 1 day.
|
||||
|
||||
Window: [June 24 08:00 UTC (= 10:00 CEST), June 24 22:00 UTC (= June 25 00:00 CEST)).
|
||||
Both endpoints resolve to June 24 local (end is exactly midnight of June 25,
|
||||
which is June 24 22:00 UTC, so _lmu(June 25) = June 24 22:00 UTC is NOT < end_utc
|
||||
but equal → last_counted = June 24).
|
||||
first_counted = June 24.
|
||||
n_days = 1. Today (June 25) is past June 24 → elapsed.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
eff_utc = _ams_midnight(2026, 6, 1)
|
||||
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
|
||||
|
||||
start_utc = datetime(2026, 6, 24, 8, 0, 0, tzinfo=_UTC) # 10:00 CEST = mid-morning June 24
|
||||
end_utc = datetime(2026, 6, 24, 22, 0, 0, tzinfo=_UTC) # = June 25 00:00 CEST midnight
|
||||
|
||||
with patch.object(tz_module, "local_tz", return_value=_ams()):
|
||||
result = self._run_summarize_ams(energy_db, start_utc, end_utc)
|
||||
|
||||
daily_fixed = (9.87 + 9.87) / 30
|
||||
daily_credit = 600.0 / 365
|
||||
# Exactly 1 day: June 24 (start day, counted as full day; elapsed since today is June 25).
|
||||
assert abs(result["fixed_costs"] - daily_fixed * 1) < 1e-9, (
|
||||
f"FUE-T01: mid-morning to midnight window on one day should count 1 day; "
|
||||
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
|
||||
)
|
||||
assert abs(result["credits"] - daily_credit * 1) < 1e-9, (
|
||||
f"FUE-T01: mid-morning to midnight window on one day should count 1 day of credits; "
|
||||
f"expected {daily_credit:.6f}, got {result['credits']}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. compute_closed_periods
|
||||
|
||||
Reference in New Issue
Block a user