From 6f8cb05eab8c1d86573652b3ca7dcf44bdfe2e04 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Wed, 24 Jun 2026 14:26:32 +0200 Subject: [PATCH] expose: fold standing fee into import cost, tax credit into export revenue So HA can show the full payable: net = import - export = Total Payable, with both entities staying monotonic-positive (no negative-value/reset pitfalls). - import_cost_total = SUM(import_cost) + days*(network_fee+management_fee)/30 - export_revenue_total = SUM(export_revenue) + days*heffingskorting/365 (days = calendar days since the active version's effective_from, incl. today) - state_class total_increasing -> total (monetary-compatible; values still only increase). device_class/key/names: key unchanged, names clarified. - buy/sell_price_now and DeviceInfo untouched. Decimal money math. Tests added. --- app/integrations/expose.py | 75 ++++++-- tests/test_energy_expose.py | 331 +++++++++++++++++++++++++++++++++++- 2 files changed, 387 insertions(+), 19 deletions(-) diff --git a/app/integrations/expose.py b/app/integrations/expose.py index 7a5b169..8afd150 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -511,12 +511,23 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: return _getter - # --- value_getter: cumulative import cost --- + # --- value_getter: cumulative import cost (incl. standing charges) --- def _make_import_cost_getter() -> Callable[["Session"], Any]: - """Return a getter for the cumulative import cost (non-degraded rows only).""" + """Return a getter for the cumulative import cost plus prorated standing charges. + + Value = SUM(import_cost, non-degraded) + standing_charges_cumulative + + where standing_charges_cumulative = elapsed_days × (network_fee + management_fee) / 30. + Elapsed days are counted from the active contract version's effective_from date + (inclusive of today). Returns None when no non-degraded periods exist. + """ def _getter(sess: "Session") -> Any: + from decimal import Decimal + from datetime import UTC, datetime as _dt + from app.models.energy import EnergyCostPeriod as _ECP + from app.services.contracts import active_contract_version_at, _as_utc from sqlalchemy import func as _func total = sess.query(_func.sum(_ECP.import_cost)).filter( @@ -524,16 +535,44 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: ).scalar() if total is None: return None - return float(total) + + # Add prorated standing charges from the active contract version. + standing_cumulative = Decimal("0") + now_utc = _dt.now(UTC) + version = active_contract_version_at(sess, now_utc) + if version is not None: + vals = version.values or {} + standing = vals.get("standing", {}) + network_fee = Decimal(str(standing.get("network_fee") or 0)) + management_fee = Decimal(str(standing.get("management_fee") or 0)) + daily_standing = (network_fee + management_fee) / Decimal("30") + + effective_from = _as_utc(version.effective_from) + days = (now_utc.date() - effective_from.date()).days + 1 + days = max(days, 0) + standing_cumulative = daily_standing * days + + return float(Decimal(str(total)) + standing_cumulative) return _getter - # --- value_getter: cumulative export revenue --- + # --- value_getter: cumulative export revenue (incl. tax credit) --- def _make_export_revenue_getter() -> Callable[["Session"], Any]: - """Return a getter for the cumulative export revenue (non-degraded rows only).""" + """Return a getter for the cumulative export revenue plus prorated tax credit. + + Value = SUM(export_revenue, non-degraded) + heffingskorting_cumulative + + where heffingskorting_cumulative = elapsed_days × heffingskorting / 365. + Elapsed days are counted from the active contract version's effective_from date + (inclusive of today). Returns None when no non-degraded periods exist. + """ def _getter(sess: "Session") -> Any: + from decimal import Decimal + from datetime import UTC, datetime as _dt + from app.models.energy import EnergyCostPeriod as _ECP + from app.services.contracts import active_contract_version_at, _as_utc from sqlalchemy import func as _func total = sess.query(_func.sum(_ECP.export_revenue)).filter( @@ -541,7 +580,23 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: ).scalar() if total is None: return None - return float(total) + + # Add prorated heffingskorting credit from the active contract version. + credit_cumulative = Decimal("0") + now_utc = _dt.now(UTC) + version = active_contract_version_at(sess, now_utc) + if version is not None: + vals = version.values or {} + credits = vals.get("credits", {}) + heffingskorting = Decimal(str(credits.get("heffingskorting") or 0)) + daily_credit = heffingskorting / Decimal("365") + + effective_from = _as_utc(version.effective_from) + days = (now_utc.date() - effective_from.date()).days + 1 + days = max(days, 0) + credit_cumulative = daily_credit * days + + return float(Decimal(str(total)) + credit_cumulative) return _getter @@ -575,9 +630,9 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: device=device_info, device_class="monetary", unit=currency, - name="Energy Import Cost Total", + name="Energy Import Cost (incl. standing)", value_getter=_make_import_cost_getter(), - state_class="total_increasing", + state_class="total", ), ExposableEntity( key="energy.export_revenue_total", @@ -585,9 +640,9 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: device=device_info, device_class="monetary", unit=currency, - name="Energy Export Revenue Total", + name="Energy Export Revenue (incl. tax credit)", value_getter=_make_export_revenue_getter(), - state_class="total_increasing", + state_class="total", ), ] diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index b860387..402c17c 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -1,9 +1,9 @@ -"""Tests for M6-T08: energy_cost expose provider + state publish. +"""Tests for M6-T08 + FU6: energy_cost expose provider + state publish. Coverage: 1. build_catalog contains all 4 energy_cost entities. 2. Cumulative entities (import_cost_total / export_revenue_total) have - state_class="total_increasing" and device_class="monetary". + 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 @@ -15,6 +15,10 @@ Coverage: 9. Keys are stable fixed strings (not derived from mutable data or DB ids). 10. 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. """ from __future__ import annotations @@ -145,8 +149,8 @@ def test_build_catalog_contains_4_energy_cost_entities(energy_db) -> None: # --------------------------------------------------------------------------- -def test_cumulative_entities_have_total_increasing_state_class(energy_db) -> None: - """import_cost_total and export_revenue_total must have state_class='total_increasing'.""" +def test_cumulative_entities_have_total_state_class(energy_db) -> None: + """import_cost_total and export_revenue_total must have state_class='total'.""" from app.integrations.expose import build_catalog with Session(energy_db) as session: @@ -155,8 +159,8 @@ def test_cumulative_entities_have_total_increasing_state_class(energy_db) -> Non cumulative_keys = {"energy.import_cost_total", "energy.export_revenue_total"} for entry in catalog: if entry.entity.key in cumulative_keys: - assert entry.entity.state_class == "total_increasing", ( - f"Entity {entry.entity.key!r} must have state_class='total_increasing', " + assert entry.entity.state_class == "total", ( + f"Entity {entry.entity.key!r} must have state_class='total', " f"got {entry.entity.state_class!r}" ) @@ -683,8 +687,8 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None: device=device, device_class="monetary", unit="EUR", - name="Energy Import Cost Total", - state_class="total_increasing", + name="Energy Import Cost (incl. standing)", + state_class="total", ) topic, config = build_discovery_payload(entity, prefix="homeassistant") @@ -698,7 +702,7 @@ def test_energy_entity_discovery_topics_contain_correct_node_id() -> None: f"Topic must start with homeassistant/sensor/, got {topic!r}" ) assert "state_class" in config - assert config["state_class"] == "total_increasing" + assert config["state_class"] == "total" assert config.get("device_class") == "monetary" @@ -804,3 +808,312 @@ def test_energy_provider_registered_alongside_modbus(energy_db) -> None: assert "energy.export_revenue_total" in all_keys, ( "Expected energy.export_revenue_total in catalog" ) + + +# --------------------------------------------------------------------------- +# 11. Standing charges prorated into import_cost_total getter +# --------------------------------------------------------------------------- + + +def _make_contract_with_version( + session: Session, + *, + kind: str = "manual", + values: dict, + effective_from: datetime, + active: bool = True, +) -> None: + """Insert an EnergyContract + one EnergyContractVersion and flush.""" + from app.models.energy import EnergyContract, EnergyContractVersion + + now = datetime.now(timezone.utc) + contract = EnergyContract( + name=f"Test Contract ({kind})", + kind=kind, + currency="EUR", + active=active, + created_at=now, + updated_at=now, + ) + session.add(contract) + session.flush() + + version = EnergyContractVersion( + contract_id=contract.id, + effective_from=effective_from, + effective_to=None, + values=values, + created_at=now, + ) + session.add(version) + session.flush() + + +_STANDING_VALUES = { + "energy": { + "buy": {"normal": 0.133, "dal": 0.127}, + "sell": {"normal": 0.05, "dal": 0.05}, + "energy_tax": 0.11, + "ode": 0.0, + }, + "standing": { + "network_fee": 30.0, # EUR/month + "management_fee": 60.0, # EUR/month → daily = (30+60)/30 = 3.0 + }, + "credits": { + "heffingskorting": 365.0, # EUR/year → daily = 365/365 = 1.0 + }, +} + + +def test_import_cost_total_includes_standing_charges(energy_db) -> None: + """import_cost_total getter must add prorated standing charges from active contract. + + 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. + """ + 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) + + # Expected elapsed days = 9 + 1 = 10 (from effective_from date to today, inclusive) + expected_days = (now_utc.date() - effective_from.date()).days + 1 + + 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) + + 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=3.00, degraded=False) + _make_period(session, period_start=t1, import_cost=2.00, degraded=False) + 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) + + # 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), ( + 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}" + ) + + +# --------------------------------------------------------------------------- +# 12. Tax credit (heffingskorting) prorated into export_revenue_total getter +# --------------------------------------------------------------------------- + + +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. + """ + from decimal import Decimal + from datetime import timedelta + from app.integrations.expose import build_catalog + + 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 + + t0 = datetime(2025, 11, 1, 6, 0, tzinfo=timezone.utc) + t1 = datetime(2025, 11, 1, 6, 15, tzinfo=timezone.utc) + export_sum = 2.50 # EUR + + 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=1.50, degraded=False) + _make_period(session, period_start=t1, export_revenue=1.00, degraded=False) + 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) + + # 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), ( + f"Expected export_revenue_total={expected} (sum={export_sum} + " + f"credit={float(daily_credit * expected_days)} over {expected_days} days), " + f"got {value!r}" + ) + + +# --------------------------------------------------------------------------- +# 13. effective_from in future → standing/credit cumulative = 0 +# --------------------------------------------------------------------------- + + +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.""" + from datetime import timedelta + from app.integrations.expose import build_catalog + + now_utc = datetime.now(timezone.utc) + future_effective = now_utc + timedelta(days=30) + + t0 = datetime(2025, 12, 1, 6, 0, tzinfo=timezone.utc) + import_cost_sum = 4.00 + + with Session(energy_db) as session: + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=future_effective, + active=True, + ) + _make_period(session, period_start=t0, import_cost=import_cost_sum, degraded=False) + 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) + + # 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}" + ) + + +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.""" + from datetime import timedelta + from app.integrations.expose import build_catalog + + now_utc = datetime.now(timezone.utc) + future_effective = now_utc + timedelta(days=60) + + t0 = datetime(2025, 12, 2, 6, 0, tzinfo=timezone.utc) + export_sum = 3.00 + + with Session(energy_db) as session: + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=future_effective, + active=True, + ) + _make_period(session, period_start=t0, export_revenue=export_sum, degraded=False) + 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) + + # 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}" + ) + + +# --------------------------------------------------------------------------- +# 14. No active contract → getters fall back to pure SUM +# --------------------------------------------------------------------------- + + +def test_import_cost_total_falls_back_to_sum_when_no_active_contract(energy_db) -> None: + """When no active contract exists, import_cost_total must return the raw SUM only.""" + from app.integrations.expose import build_catalog + + t0 = datetime(2026, 1, 1, 6, 0, tzinfo=timezone.utc) + t1 = datetime(2026, 1, 1, 6, 15, tzinfo=timezone.utc) + + with Session(energy_db) as session: + # Inactive contract — active=False + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=t0, + active=False, + ) + _make_period(session, period_start=t0, import_cost=1.50, degraded=False) + _make_period(session, period_start=t1, import_cost=2.50, degraded=False) + 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) + + # No active contract → standing cumulative = 0 → value = pure SUM = 4.00 + assert value == pytest.approx(4.00, rel=1e-9), ( + f"Expected pure SUM=4.00 with no active contract, got {value!r}" + ) + + +def test_export_revenue_total_falls_back_to_sum_when_no_active_contract(energy_db) -> None: + """When no active contract exists, export_revenue_total must return the raw SUM only.""" + from app.integrations.expose import build_catalog + + t0 = datetime(2026, 2, 1, 6, 0, tzinfo=timezone.utc) + t1 = datetime(2026, 2, 1, 6, 15, tzinfo=timezone.utc) + + with Session(energy_db) as session: + # Inactive contract — active=False + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=t0, + active=False, + ) + _make_period(session, period_start=t0, export_revenue=0.80, degraded=False) + _make_period(session, period_start=t1, export_revenue=1.20, degraded=False) + 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) + + # No active contract → credit cumulative = 0 → value = pure SUM = 2.00 + assert value == pytest.approx(2.00, rel=1e-9), ( + f"Expected pure SUM=2.00 with no active contract, got {value!r}" + )