From f1e7ce313320a847253110f30f6619fc0804aa64 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 25 Jun 2026 17:57:55 +0200 Subject: [PATCH] FUE-T01: count billing window start day in full for fixed-fee/credit accrual (symmetric with end day) --- app/services/energy_cost.py | 80 ++++++++++---- tests/test_energy_cost.py | 204 +++++++++++++++++++++++++++++++----- 2 files changed, 232 insertions(+), 52 deletions(-) diff --git a/app/services/energy_cost.py b/app/services/energy_cost.py index dc06973..206b647 100644 --- a/app/services/energy_cost.py +++ b/app/services/energy_cost.py @@ -678,15 +678,33 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any + fixed_costs -- per-day standing charges, cross-version - credits -- per-day heffingskorting, cross-version - **Fixed-cost / credit counting — Principle C**: - Only *already-elapsed* whole local calendar days are counted. For each - local calendar date D in the window ``[start_local_date, min(end_local_date, - tomorrow_local))``, the contract version whose rate covers D (the one whose - effective_from local-date ≤ D < next version's effective_from local-date) is - used. Days that have not yet started in local time (D > today_local) are - never counted. This is cross-version: if the active contract has V1 from - June 1 and V2 from June 25, querying June 1–30 uses V1 for days 1-24 and V2 - for day 25. Switching versions never resets the counter. + **Fixed-cost / credit counting — Principle C (symmetric begin/end)**: + Both the local calendar day in which *start* falls and the local calendar + day in which *end* falls are counted as full days. Fixed charges + (network_fee, management_fee) and the energy-tax credit (heffingskorting) + are assessed on a "service-is-active" basis — if the meter was online on a + given calendar day, the full day's charge/credit applies, regardless of + whether the window starts at midnight or mid-morning. + + For each local calendar date D in the range + ``[local_date(start), min(local_date(end), today_local)]``: + - D ≤ today_local (only elapsed / today days count as "whole days"). + - The contract version whose effective_from local-date ≤ D is used. + - This is cross-version: if V1 is from June 1 and V2 from June 25, + querying June 1–30 uses V1 for days 1-24 and V2 for day 25. + + The end-day (last_counted) is included when the end's local midnight falls + strictly before end_utc; combined with the always-counted start day this + makes the begin/end handling symmetric. A short same-day window therefore + counts its single local day. A window contributes 0 days only when the + counted range is empty (first_counted > last_counted) — e.g. a window lying + entirely in the future, since last_counted is capped at today_local. + + Daily getters (``*_today``) use windows exactly aligned to local midnight, + so their ``first_counted`` is always today — unaffected by this fix. + + Days that have not yet started in local time (D > today_local) are + never counted. Switching versions never resets the counter. **Timezone note**: the ``days`` field in the returned dict still represents the window length in calendar days (total_seconds / 86400), for backward @@ -750,25 +768,41 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any # --- Compute [first_counted, last_counted] local date range (inclusive) --- # - # We count local calendar day D if its LOCAL MIDNIGHT falls within [start_utc, end_utc) - # AND D ≤ today_local (only elapsed / today days count as "whole days"). + # Both the window-start day and the window-end day are counted as complete + # local calendar days, regardless of whether the window starts/ends at midnight. # - # Semantics: "A day D is counted when its local midnight has arrived (D ≤ today) - # AND the local midnight is within the billing window." + # Principle C (symmetric begin/end): + # • first_counted = local calendar date of start_utc (start day always counted) + # • last_counted = local calendar date of end_utc (end day counted if its + # local midnight is strictly before end_utc) + # • Both are then capped at today_local (only elapsed / today days count). # - # This means a sub-day window [10:00, 10:30) UTC that doesn't contain any - # local midnight contributes 0 days, while a window [22:00 UTC, 23:00 UTC) that - # contains CEST midnight (= 22:00 UTC) contributes 1 day. + # Why symmetric? Fixed charges (network_fee, management_fee) and energy-tax credits + # (heffingskorting) are assessed on a "service-is-active" basis, not on how many + # hours the service was actually running within that calendar day. If the meter + # anchor (started_at) falls at 09:18 on June 24, the full June 24 standing charge + # and credit still apply because the service was online for that day. # - # Implementation: compute the first and last local date whose midnight is in range. + # Previous asymmetric behaviour: a start_utc that was *later* than the local + # midnight of local_start_date caused first_counted to be bumped to the *next* + # day, silently dropping the anchor day's charges/credits. This was incorrect + # for cumulative entities (import_cost_total / export_revenue_total) whose anchor + # is often an above-midnight started_at. The end-side had always been symmetric + # (counted if local midnight < end_utc), creating an inconsistency. + # + # Daily getters (window = [local today 00:00, local tomorrow 00:00)) are + # unaffected: local_date(local_midnight_utc(today)) == today, so first_counted + # is still today regardless of the fix. + # + # A short same-day window now counts its single local day (the start day is + # always counted, symmetric with the end side). A window contributes 0 days + # only when the counted range is empty (first_counted > last_counted) — e.g. a + # window lying entirely in the future, where last_counted is capped at + # today_local while first_counted is later. from app.services.timezone import local_midnight_utc as _lmu - local_start_date: _date = local_date(start_utc) - # Is the midnight of local_start_date ≥ start_utc? If not, first midnight is next day. - if _lmu(local_start_date) >= start_utc: - first_counted: _date = local_start_date - else: - first_counted = local_start_date + _td(days=1) + # Start side: always count the local calendar day in which start_utc falls. + first_counted: _date = local_date(start_utc) local_end_date: _date = local_date(end_utc) # Is the midnight of local_end_date < end_utc? If yes, that day's midnight is in range. diff --git a/tests/test_energy_cost.py b/tests/test_energy_cost.py index 0686cc1..96d0fc8 100644 --- a/tests/test_energy_cost.py +++ b/tests/test_energy_cost.py @@ -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