FU11: anchor cumulative cost at recording start; add daily-reset cost entities; prefill add-version form
- MQTT import_cost_total / export_revenue_total now anchor fixed-fee / tax-credit accrual at max(contract start, first recorded period), so standing charges for untracked pre-recording days are no longer folded into the running total. - Add energy.import_cost_today / energy.export_revenue_today (monetary, total_increasing) that reset at server-local midnight, for per-day cost aggregation in Home Assistant alongside the running totals. - ContractForm add-version mode prefills the contract's open version values, so only the fields that actually change need editing.
This commit is contained in:
+356
-34
@@ -1,24 +1,29 @@
|
||||
"""Tests for M6-T08 + FU6: energy_cost expose provider + state publish.
|
||||
"""Tests for M6-T08 + FU6 + FU11: energy_cost expose provider + state publish.
|
||||
|
||||
Coverage:
|
||||
1. build_catalog contains all 4 energy_cost entities.
|
||||
1. build_catalog contains all 6 energy_cost entities (4 original + 2 daily).
|
||||
2. Cumulative entities (import_cost_total / export_revenue_total) have
|
||||
state_class="total" and device_class="monetary".
|
||||
3. All 4 energy_cost entities default to enabled=False (no toggle row).
|
||||
4. value_getter: buy/sell price from pricing snapshot for tibber and manual kinds.
|
||||
5. value_getter: cumulative SUM(import_cost) / SUM(export_revenue) from
|
||||
3. Daily entities (import_cost_today / export_revenue_today) have
|
||||
state_class="total_increasing" and device_class="monetary".
|
||||
4. All 6 energy_cost entities default to enabled=False (no toggle row).
|
||||
5. value_getter: buy/sell price from pricing snapshot for tibber and manual kinds.
|
||||
6. value_getter: cumulative SUM(import_cost) / SUM(export_revenue) from
|
||||
non-degraded rows; degraded rows excluded.
|
||||
6. value_getter returns None when no non-degraded period exists.
|
||||
7. MQTT not enabled → publish_states is a no-op (no raises, no publish calls).
|
||||
8. Integration: build_discovery_payload on an energy_cost entity does NOT raise
|
||||
7. value_getter returns None when no non-degraded period exists.
|
||||
8. MQTT not enabled → publish_states is a no-op (no raises, no publish calls).
|
||||
9. Integration: build_discovery_payload on an energy_cost entity does NOT raise
|
||||
IndexError (validates 2-element identifiers).
|
||||
9. Keys are stable fixed strings (not derived from mutable data or DB ids).
|
||||
10. Provider registered: energy_cost entities appear alongside modbus entities
|
||||
10. Keys are stable fixed strings (not derived from mutable data or DB ids).
|
||||
11. Provider registered: energy_cost entities appear alongside modbus entities
|
||||
in the full catalog.
|
||||
11. Standing charges prorated into import_cost_total getter.
|
||||
12. Tax credit (heffingskorting) prorated into export_revenue_total getter.
|
||||
13. effective_from in future → standing/credit cumulative = 0.
|
||||
14. No active contract → getters fall back to pure SUM.
|
||||
12. Standing charges prorated into import_cost_total getter.
|
||||
13. Tax credit (heffingskorting) prorated into export_revenue_total getter.
|
||||
14. effective_from in future → standing/credit cumulative = 0.
|
||||
15. No active contract → getters fall back to pure SUM (cumulative) or None (daily).
|
||||
16. FU11: anchor = max(contract_start, recording_start) — pre-recording fixed costs excluded.
|
||||
17. FU11: daily getter uses today's local window; returns None without active contract.
|
||||
18. FU11: daily getter None when no non-degraded periods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -127,8 +132,8 @@ def energy_db(tmp_path: Path):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_catalog_contains_4_energy_cost_entities(energy_db) -> None:
|
||||
"""build_catalog must include all 4 energy_cost sensor entities."""
|
||||
def test_build_catalog_contains_6_energy_cost_entities(energy_db) -> None:
|
||||
"""build_catalog must include all 6 energy_cost sensor entities (4 original + 2 daily)."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
@@ -140,6 +145,8 @@ def test_build_catalog_contains_4_energy_cost_entities(energy_db) -> None:
|
||||
"energy.sell_price_now",
|
||||
"energy.import_cost_total",
|
||||
"energy.export_revenue_total",
|
||||
"energy.import_cost_today",
|
||||
"energy.export_revenue_today",
|
||||
}
|
||||
assert expected_keys == energy_keys, (
|
||||
f"Expected energy keys {expected_keys!r}, got {energy_keys!r}"
|
||||
@@ -184,14 +191,14 @@ def test_cumulative_entities_have_monetary_device_class(energy_db) -> None:
|
||||
|
||||
|
||||
def test_all_energy_entities_are_sensors(energy_db) -> None:
|
||||
"""All 4 energy_cost entities must have component='sensor'."""
|
||||
"""All 6 energy_cost entities must have component='sensor'."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||
assert len(energy_entries) == 4
|
||||
assert len(energy_entries) == 6
|
||||
for entry in energy_entries:
|
||||
assert entry.entity.component == "sensor", (
|
||||
f"Expected component='sensor' for {entry.entity.key!r}, "
|
||||
@@ -205,14 +212,14 @@ def test_all_energy_entities_are_sensors(energy_db) -> None:
|
||||
|
||||
|
||||
def test_energy_cost_entities_default_to_disabled(energy_db) -> None:
|
||||
"""All 4 energy_cost entities must default to enabled=False (no toggle row)."""
|
||||
"""All 6 energy_cost entities must default to enabled=False (no toggle row)."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||
assert len(energy_entries) == 4
|
||||
assert len(energy_entries) == 6
|
||||
for entry in energy_entries:
|
||||
assert entry.enabled is False, (
|
||||
f"Entity {entry.entity.key!r} must default to enabled=False "
|
||||
@@ -629,7 +636,7 @@ def test_build_discovery_payload_no_index_error_for_energy_entities(energy_db) -
|
||||
catalog = build_catalog(session)
|
||||
|
||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||
assert len(energy_entries) == 4, "Expected 4 energy_cost entities in catalog"
|
||||
assert len(energy_entries) == 6, "Expected 6 energy_cost entities in catalog"
|
||||
|
||||
for entry in energy_entries:
|
||||
# Must not raise — specifically no IndexError from identifiers[1]
|
||||
@@ -666,7 +673,7 @@ def test_energy_cost_entities_omit_availability_so_ha_shows_them(energy_db) -> N
|
||||
catalog = build_catalog(session)
|
||||
|
||||
energy_entries = [e for e in catalog if e.entity.key.startswith("energy.")]
|
||||
assert len(energy_entries) == 4
|
||||
assert len(energy_entries) == 6
|
||||
|
||||
for entry in energy_entries:
|
||||
assert entry.entity.device.provides_availability is False
|
||||
@@ -761,6 +768,8 @@ def test_energy_entity_keys_are_stable_fixed_strings(energy_db) -> None:
|
||||
"energy.sell_price_now",
|
||||
"energy.import_cost_total",
|
||||
"energy.export_revenue_total",
|
||||
"energy.import_cost_today",
|
||||
"energy.export_revenue_today",
|
||||
}
|
||||
assert keys1 == expected_keys, (
|
||||
f"Energy keys must be exactly {expected_keys!r}, got {keys1!r}"
|
||||
@@ -871,19 +880,18 @@ _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.
|
||||
Principle B/D + FU11 anchor guardrail: the getter delegates to
|
||||
summarize(anchor → now), where anchor = max(contract_start, recording_start).
|
||||
|
||||
With network_fee=30 EUR/month + management_fee=60 EUR/month, daily_standing = 3.0 EUR/day.
|
||||
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.
|
||||
- effective_from = 10 full UTC days ago (exact UTC midnight).
|
||||
- First period placed AT effective_from (same UTC midnight), so recording_start =
|
||||
contract_start → anchor = max(contract_start, recording_start) = contract_start.
|
||||
- Under UTC pinned, Principle C counts days [effective_from.date(), today] = 11 days.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from datetime import timedelta
|
||||
@@ -901,8 +909,9 @@ def test_import_cost_total_includes_standing_charges(energy_db) -> None:
|
||||
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 11
|
||||
|
||||
import_cost_sum = 5.00 # EUR
|
||||
# Place period rows AFTER effective_from so summarize(anchor, now) picks them up.
|
||||
t0 = effective_from + timedelta(hours=1)
|
||||
# Place period rows AT effective_from (= recording_start = contract_start → same anchor).
|
||||
# This ensures anchor = max(effective_from, effective_from) = effective_from exactly.
|
||||
t0 = effective_from # first period starts exactly at contract effective_from
|
||||
t1 = t0 + timedelta(minutes=15)
|
||||
|
||||
with Session(energy_db) as session:
|
||||
@@ -944,9 +953,12 @@ 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.
|
||||
|
||||
Principle B/D: delegates to summarize(anchor → now).
|
||||
Principle B/D + FU11 anchor guardrail: delegates to summarize(anchor → now),
|
||||
where anchor = max(contract_start, recording_start).
|
||||
With heffingskorting=365 EUR/year, daily_credit = 365/365 = 1.0 EUR/day.
|
||||
Timezone pinned to UTC for deterministic local-day counting.
|
||||
|
||||
Setup: period placed AT effective_from → recording_start = contract_start → same anchor.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from datetime import timedelta
|
||||
@@ -961,8 +973,8 @@ def test_export_revenue_total_includes_tax_credit(energy_db) -> None:
|
||||
# Under UTC pinned: expected_days = days from effective_from.date() to today inclusive.
|
||||
expected_days = (now_utc.date() - effective_from.date()).days + 1 # = 5
|
||||
|
||||
# Place period rows AFTER effective_from.
|
||||
t0 = effective_from + timedelta(hours=1)
|
||||
# Place period rows AT effective_from → recording_start = contract_start = same anchor.
|
||||
t0 = effective_from
|
||||
t1 = t0 + timedelta(minutes=15)
|
||||
export_sum = 2.50 # EUR
|
||||
|
||||
@@ -1373,3 +1385,313 @@ def test_tibber_sell_price_not_affected_by_tariff(energy_db, reset_tariff) -> No
|
||||
assert value == pytest.approx(0.1500), (
|
||||
f"Tibber sell price must be 0.1500 regardless of tariff={tariff_val!r}, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FU11 new tests: daily entities metadata + getter behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_daily_entities_in_catalog(energy_db) -> None:
|
||||
"""FU11: catalog must include import_cost_today and export_revenue_today."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
daily_keys = {
|
||||
"energy.import_cost_today",
|
||||
"energy.export_revenue_today",
|
||||
}
|
||||
found = {e.entity.key for e in catalog if e.entity.key in daily_keys}
|
||||
assert found == daily_keys, f"Expected daily keys {daily_keys!r} in catalog, got {found!r}"
|
||||
|
||||
|
||||
def test_daily_entities_have_total_increasing_state_class(energy_db) -> None:
|
||||
"""FU11: daily entities must have state_class='total_increasing' (not 'total')."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"}
|
||||
for entry in catalog:
|
||||
if entry.entity.key in daily_keys:
|
||||
assert entry.entity.state_class == "total_increasing", (
|
||||
f"Daily entity {entry.entity.key!r} must have state_class='total_increasing', "
|
||||
f"got {entry.entity.state_class!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_daily_entities_have_monetary_device_class(energy_db) -> None:
|
||||
"""FU11: daily entities must have device_class='monetary' and state_class='total_increasing'.
|
||||
|
||||
HA developer docs prohibit MONETARY with state_class=measurement, but explicitly
|
||||
allow it with total and total_increasing. monetary+total_increasing is the correct
|
||||
combination for a non-negative, monotonically non-decreasing daily quantity that
|
||||
resets at local midnight.
|
||||
"""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"}
|
||||
for entry in catalog:
|
||||
if entry.entity.key in daily_keys:
|
||||
assert entry.entity.device_class == "monetary", (
|
||||
f"Daily entity {entry.entity.key!r} must have device_class='monetary', "
|
||||
f"got {entry.entity.device_class!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_daily_entities_default_disabled(energy_db) -> None:
|
||||
"""FU11: daily entities must default to enabled=False (no toggle row seeded)."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
|
||||
daily_keys = {"energy.import_cost_today", "energy.export_revenue_today"}
|
||||
for entry in catalog:
|
||||
if entry.entity.key in daily_keys:
|
||||
assert entry.enabled is False, (
|
||||
f"Daily entity {entry.entity.key!r} must default to enabled=False, "
|
||||
f"got enabled={entry.enabled!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
|
||||
"""FU11: import_cost_today getter returns value for today's local window.
|
||||
|
||||
Setup: period at local 'today' (within today's UTC window), active contract.
|
||||
Timezone pinned to UTC. Verifies: value = metered_import + fixed_costs for 1 day
|
||||
(Principle C: today counts as elapsed if we're past local midnight — which UTC is).
|
||||
"""
|
||||
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)
|
||||
# Place a period in today's window (1 hour ago, well within today UTC midnight→tomorrow)
|
||||
# When TZ=UTC, local today = UTC today, so a period 1h ago is in today's window.
|
||||
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
# Ensure t0 is still today UTC (handle edge case where now is < 1h past UTC midnight)
|
||||
if t0.date() < now_utc.date():
|
||||
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Contract starting well before today
|
||||
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
||||
|
||||
import_cost_today = 1.23
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_contract_with_version(
|
||||
session,
|
||||
values=_STANDING_VALUES,
|
||||
effective_from=effective_from,
|
||||
active=True,
|
||||
)
|
||||
_make_period(session, period_start=t0, import_cost=import_cost_today, degraded=False)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
# Pin timezone to UTC so today_local = UTC date (deterministic on any CI host).
|
||||
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||
catalog = build_catalog(session)
|
||||
today_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
||||
)
|
||||
value = today_entry.entity.value_getter(session)
|
||||
|
||||
# Principle C: today counts as 1 day (we're within it, and it's "today").
|
||||
# daily_standing = (30+60)/30 = 3.0 EUR/day × 1 day = 3.0
|
||||
from decimal import Decimal
|
||||
daily_standing = Decimal("90") / Decimal("30") # = 3.0
|
||||
expected = float(Decimal(str(import_cost_today)) + daily_standing * 1)
|
||||
|
||||
assert value == pytest.approx(expected, rel=1e-6), (
|
||||
f"import_cost_today expected {expected}, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None:
|
||||
"""FU11: export_revenue_today getter returns value for today's local window."""
|
||||
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)
|
||||
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
if t0.date() < now_utc.date():
|
||||
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30)
|
||||
export_today = 0.75
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_contract_with_version(
|
||||
session,
|
||||
values=_STANDING_VALUES,
|
||||
effective_from=effective_from,
|
||||
active=True,
|
||||
)
|
||||
_make_period(session, period_start=t0, export_revenue=export_today, degraded=False)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")):
|
||||
catalog = build_catalog(session)
|
||||
today_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.export_revenue_today"
|
||||
)
|
||||
value = today_entry.entity.value_getter(session)
|
||||
|
||||
from decimal import Decimal
|
||||
daily_credit = Decimal("365") / Decimal("365") # = 1.0 EUR/day × 1 day
|
||||
expected = float(Decimal(str(export_today)) + daily_credit * 1)
|
||||
|
||||
assert value == pytest.approx(expected, rel=1e-6), (
|
||||
f"export_revenue_today expected {expected}, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_daily_getter_returns_none_when_no_active_contract(energy_db) -> None:
|
||||
"""FU11: daily getters return None when no active contract exists."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
with Session(energy_db) as session:
|
||||
# Inactive contract
|
||||
_make_contract_with_version(
|
||||
session,
|
||||
values=_STANDING_VALUES,
|
||||
effective_from=t0,
|
||||
active=False,
|
||||
)
|
||||
_make_period(session, period_start=t0, import_cost=1.0, export_revenue=0.5, degraded=False)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
import_today_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
||||
)
|
||||
export_today_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.export_revenue_today"
|
||||
)
|
||||
import_val = import_today_entry.entity.value_getter(session)
|
||||
export_val = export_today_entry.entity.value_getter(session)
|
||||
|
||||
assert import_val is None, (
|
||||
f"import_cost_today must be None with no active contract, got {import_val!r}"
|
||||
)
|
||||
assert export_val is None, (
|
||||
f"export_revenue_today must be None with no active contract, got {export_val!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_daily_getter_returns_none_when_no_non_degraded_periods(energy_db) -> None:
|
||||
"""FU11: daily getters return None when only degraded rows exist."""
|
||||
from app.integrations.expose import build_catalog
|
||||
|
||||
t0 = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_contract_with_version(
|
||||
session,
|
||||
values=_STANDING_VALUES,
|
||||
effective_from=t0 - datetime.resolution * 0, # same time is fine here
|
||||
active=True,
|
||||
)
|
||||
_make_period(session, period_start=t0, import_cost=0.0, degraded=True)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as session:
|
||||
catalog = build_catalog(session)
|
||||
import_today_entry = next(
|
||||
e for e in catalog if e.entity.key == "energy.import_cost_today"
|
||||
)
|
||||
value = import_today_entry.entity.value_getter(session)
|
||||
|
||||
assert value is None, (
|
||||
f"import_cost_today must be None with only degraded rows, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FU11 new test: anchor = max(contract_start, recording_start)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cumulative_anchor_uses_recording_start_when_later_than_contract(energy_db) -> None:
|
||||
"""FU11: anchor = max(contract_start, recording_start) — no pre-recording fixed costs.
|
||||
|
||||
Scenario: contract effective_from = 180 days ago (lots of standing charges if used
|
||||
as anchor), but first non-degraded period_start = 7 days ago. Expected: anchor is
|
||||
7 days ago → only 8 days of standing charges (7 + today under Principle C).
|
||||
|
||||
Without the fix, anchor would be 180 days ago → ~180 days × 3 EUR/day = €540+.
|
||||
"""
|
||||
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)
|
||||
midnight_today = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Contract starts 180 days ago (far before any data)
|
||||
contract_start = midnight_today - timedelta(days=180)
|
||||
# First (and only) data period starts 7 days ago
|
||||
recording_start = midnight_today - timedelta(days=7)
|
||||
|
||||
t0 = recording_start # first period at recording start
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_contract_with_version(
|
||||
session,
|
||||
values=_STANDING_VALUES,
|
||||
effective_from=contract_start,
|
||||
active=True,
|
||||
)
|
||||
_make_period(session, period_start=t0, import_cost=5.00, degraded=False)
|
||||
session.commit()
|
||||
|
||||
with Session(energy_db) as 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)
|
||||
|
||||
# Anchor = recording_start (7 days ago UTC midnight).
|
||||
# Principle C (UTC pinned): count local days from recording_start.date() to today inclusive.
|
||||
# That's 7+1 = 8 days.
|
||||
expected_days = (now_utc.date() - recording_start.date()).days + 1 # = 8
|
||||
daily_standing = Decimal("90") / Decimal("30") # = 3.0 EUR/day
|
||||
expected = float(Decimal("5.00") + daily_standing * expected_days)
|
||||
|
||||
# Without the fix, value would include ~180 days × 3 EUR/day = ~€540+ of extra standing.
|
||||
# With the fix, value ≈ 5.00 + 3.0 × 8 = 29.00.
|
||||
assert value == pytest.approx(expected, rel=1e-6), (
|
||||
f"Expected anchor=recording_start anchor, "
|
||||
f"import_cost_total={expected} (metered=5.00 + standing={float(daily_standing*expected_days)} "
|
||||
f"over {expected_days} days), got {value!r}"
|
||||
)
|
||||
|
||||
# Also verify that using the old anchor (contract start) would give a very different result.
|
||||
old_expected_days = (now_utc.date() - contract_start.date()).days + 1 # ~181 days
|
||||
old_expected = float(Decimal("5.00") + daily_standing * old_expected_days)
|
||||
assert abs(value - old_expected) > 100, (
|
||||
f"Old (wrong) anchor would yield ~{old_expected:.2f}, "
|
||||
f"the new value {value:.2f} should differ by >€100"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user