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:
+101
-55
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user