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