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.
This commit is contained in:
2026-06-24 14:26:32 +02:00
parent effe434637
commit 6f8cb05eab
2 changed files with 387 additions and 19 deletions
+65 -10
View File
@@ -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",
),
]
+322 -9
View File
@@ -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}"
)