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
+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}"
)