diff --git a/app/services/energy_cost.py b/app/services/energy_cost.py index 206b647..65fd51d 100644 --- a/app/services/energy_cost.py +++ b/app/services/energy_cost.py @@ -123,6 +123,10 @@ _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES) # degraded to prevent negative costs or grossly inflated charges. _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). _KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1) _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") # --- 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) --- # @@ -811,8 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any else: last_counted = local_end_date - _td(days=1) - # Cap: only elapsed or today's days. - last_counted = min(last_counted, today_local) + # Settlement cap: "today" is only counted once the local clock has passed the + # 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. diff --git a/tests/test_energy_cost.py b/tests/test_energy_cost.py index 96d0fc8..9f75d65 100644 --- a/tests/test_energy_cost.py +++ b/tests/test_energy_cost.py @@ -1199,10 +1199,16 @@ def _ams_midnight(year: int, month: int, day: int) -> datetime: 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. - # 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: """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) session.commit() - def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict: - """Run summarize with Europe/Amsterdam local_tz monkeypatched.""" + def _run_summarize_ams( + 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 + import app.services.energy_cost as _ec + 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) 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: """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. + 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 26–30 are not yet elapsed → 25 days. Matches table row: 6/1→7/1 (This Month) → 25 days. """ eff_utc = _ams_midnight(2026, 6, 1) @@ -1267,7 +1293,9 @@ class TestSummarizePrincipleC: start = _ams_midnight(2026, 6, 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_credit = 600.0 / 365 @@ -1304,8 +1332,9 @@ class TestSummarizePrincipleC: 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). + 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). - 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. """ eff_utc = _ams_midnight(2026, 6, 1) @@ -1313,7 +1342,9 @@ class TestSummarizePrincipleC: start = _ams_midnight(2026, 6, 25) 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_credit = 600.0 / 365 @@ -1366,8 +1397,12 @@ class TestSummarizePrincipleC: start = _ams_midnight(2026, 6, 1) end = _ams_midnight(2026, 7, 1) 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()): - result = summarize(energy_db, start, end) + with patch.object(_ec, "local_now", return_value=pinned_now): + 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 @@ -1408,12 +1443,16 @@ class TestSummarizePrincipleC: _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) + # 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) end = _ams_midnight(2026, 7, 1) 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()): - result = summarize(energy_db, start, end) + with patch.object(_ec, "local_now", return_value=pinned_now): + result = summarize(energy_db, start, end) # Only 1 day at V2 rate expected_fixed = 1 * 24 / 30 @@ -1434,13 +1473,11 @@ class TestSummarizePrincipleC: 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). + Pins local_now to June 25 noon AMS → today=June 25, settled_cap=June 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) @@ -1449,8 +1486,9 @@ class TestSummarizePrincipleC: # 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) + # Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25. + 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_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.import_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())}" + )