Files
home-automation/tests/test_pricing_strategies.py
T
tliu93 bedea196c3 M6-T03: add pricing profile framework + strategy registry
- app/integrations/pricing: profiles.py (pydantic ManualProfile/TibberProfile,
  load_profile/list_profiles/validate_values), manual.yaml + tibber.yaml
  (structure-only, no price values), strategies.py (register/get_strategy,
  manual dual-tariff + tibber strategies, all Decimal).
- tibber strategy matches nearest tibber_price with starts_at <= t0.
- tests for profiles + strategies (hand-checked dual-tariff & tibber math).
2026-06-23 20:58:34 +02:00

554 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for app/integrations/pricing/strategies.py.
Acceptance criteria covered
----------------------------
1. ``get_strategy("manual")`` / ``get_strategy("tibber")`` return registered callables.
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
3. Manual strategy: Decimal precision — no float binary rounding errors.
4. Tibber strategy: queries the most recent TibberPrice with starts_at ≤ t0.
5. Tibber strategy: buy=total, sell=totalenergy_taxsell_adjust.
6. Tibber strategy: negative total → negative export_revenue (not clamped).
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
8. ``register_strategy`` / ``get_strategy`` round-trip works.
9. ``get_strategy`` raises KeyError for unknown kinds.
"""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.integrations.pricing.strategies import (
PeriodDeltas,
TibberPriceNotFoundError,
get_strategy,
register_strategy,
)
from app.models.energy import TibberPrice
# ---------------------------------------------------------------------------
# Fixtures: in-memory / temp-file SQLite with energy tables
# ---------------------------------------------------------------------------
def _make_app_alembic_config(database_url: str) -> Config:
cfg = Config("alembic_app.ini")
cfg.set_main_option("sqlalchemy.url", database_url)
return cfg
@pytest.fixture()
def tibber_db(tmp_path: Path):
"""Temporary SQLite DB upgraded to head (has tibber_price table)."""
db_path = tmp_path / "tibber_strategy_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
yield engine
engine.dispose()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_UTC = timezone.utc
def _ts(hour: int, minute: int = 0) -> datetime:
"""Return a UTC datetime on 2026-06-23 at the given hour:minute."""
return datetime(2026, 6, 23, hour, minute, 0, tzinfo=_UTC)
def _insert_tibber_price(
session: Session,
starts_at: datetime,
total: float,
energy: float = 0.08,
tax: float | None = None,
currency: str = "EUR",
) -> TibberPrice:
"""Insert and flush a TibberPrice row; return the ORM instance."""
if tax is None:
tax = round(total - energy, 6)
row = TibberPrice(
starts_at=starts_at,
resolution="QUARTER_HOURLY",
energy=energy,
tax=tax,
total=total,
level="NORMAL",
currency=currency,
fetched_at=datetime.now(_UTC),
)
session.add(row)
session.flush()
return row
# ---------------------------------------------------------------------------
# 1. Registry round-trip
# ---------------------------------------------------------------------------
class TestRegistry:
def test_manual_strategy_registered(self) -> None:
fn = get_strategy("manual")
assert callable(fn)
def test_tibber_strategy_registered(self) -> None:
fn = get_strategy("tibber")
assert callable(fn)
def test_unknown_kind_raises_key_error(self) -> None:
with pytest.raises(KeyError, match="no_such_kind"):
get_strategy("no_such_kind")
def test_register_and_retrieve_custom_strategy(self) -> None:
def _my_fn(deltas, t0, values, session):
return {}
register_strategy("_test_custom", _my_fn)
assert get_strategy("_test_custom") is _my_fn
# Cleanup to avoid polluting other tests.
from app.integrations.pricing.strategies import _REGISTRY
_REGISTRY.pop("_test_custom", None)
# ---------------------------------------------------------------------------
# 2-3. Manual strategy
# ---------------------------------------------------------------------------
# Dummy session (manual strategy does not use the session).
_NO_SESSION = None # type: ignore[assignment]
class TestManualStrategy:
"""Verify manual dual-tariff price calculations."""
# Contract values for all manual tests.
_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": 9.87, "management_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
def _call(self, deltas: PeriodDeltas) -> dict:
fn = get_strategy("manual")
return fn(deltas, _ts(10), self._VALUES, _NO_SESSION)
# --- hand-calculated expected values ---
# buy_dal = 0.127 + 0.11 + 0.0 = 0.237
# buy_normal = 0.133 + 0.11 + 0.0 = 0.243
# sell_dal = 0.05
# sell_normal = 0.05
#
# With Δd1=2, Δd2=3, Δr1=1, Δr2=4:
# import_cost = 2×0.237 + 3×0.243 = 0.474 + 0.729 = 1.203
# export_revenue = 1×0.05 + 4×0.05 = 0.05 + 0.20 = 0.25
# net_cost = 1.203 0.25 = 0.953
def test_import_cost_dual_tariff(self) -> None:
deltas = PeriodDeltas(
d1=Decimal("2"), d2=Decimal("3"),
r1=Decimal("1"), r2=Decimal("4"),
)
result = self._call(deltas)
expected = Decimal("2") * Decimal("0.237") + Decimal("3") * Decimal("0.243")
assert result["import_cost"] == expected
def test_export_revenue_dual_tariff(self) -> None:
deltas = PeriodDeltas(
d1=Decimal("2"), d2=Decimal("3"),
r1=Decimal("1"), r2=Decimal("4"),
)
result = self._call(deltas)
expected = Decimal("1") * Decimal("0.05") + Decimal("4") * Decimal("0.05")
assert result["export_revenue"] == expected
def test_net_cost_equals_import_minus_export(self) -> None:
deltas = PeriodDeltas(
d1=Decimal("2"), d2=Decimal("3"),
r1=Decimal("1"), r2=Decimal("4"),
)
result = self._call(deltas)
assert result["net_cost"] == result["import_cost"] - result["export_revenue"]
def test_hand_calculated_values(self) -> None:
"""Verify exact hand-calculated result for the reference deltas."""
deltas = PeriodDeltas(
d1=Decimal("2"), d2=Decimal("3"),
r1=Decimal("1"), r2=Decimal("4"),
)
result = self._call(deltas)
assert result["import_cost"] == Decimal("1.203")
assert result["export_revenue"] == Decimal("0.25")
assert result["net_cost"] == Decimal("0.953")
def test_zero_deltas_yields_zero_costs(self) -> None:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("0"),
)
result = self._call(deltas)
assert result["import_cost"] == Decimal("0")
assert result["export_revenue"] == Decimal("0")
assert result["net_cost"] == Decimal("0")
def test_ode_included_in_buy_price(self) -> None:
"""When ode > 0 it is added to the effective buy price."""
values = {
"energy": {
"buy": {"normal": 0.10, "dal": 0.10},
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.10,
"ode": 0.01, # non-zero ode
},
"standing": {"network_fee": 0.0, "management_fee": 0.0},
"credits": {"heffingskorting": 0.0},
}
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("0"),
)
fn = get_strategy("manual")
result = fn(deltas, _ts(10), values, _NO_SESSION)
# buy_dal = 0.10 + 0.10 + 0.01 = 0.21; import_cost = 1 × 0.21 = 0.21
assert result["import_cost"] == Decimal("0.21")
def test_import_export_kept_separate(self) -> None:
"""import_cost and export_revenue must not be netted before assignment."""
deltas = PeriodDeltas(
d1=Decimal("5"), d2=Decimal("5"),
r1=Decimal("3"), r2=Decimal("3"),
)
result = self._call(deltas)
# Both must be individually non-zero.
assert result["import_cost"] > 0
assert result["export_revenue"] > 0
def test_pricing_snapshot_present(self) -> None:
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("1"),
r1=Decimal("0"), r2=Decimal("0"),
)
result = self._call(deltas)
snapshot = result["pricing"]
assert snapshot["kind"] == "manual"
assert "buy_dal" in snapshot
assert "buy_normal" in snapshot
assert "sell_dal" in snapshot
assert "sell_normal" in snapshot
# ---------------------------------------------------------------------------
# 3. Decimal precision — float-error exposure test
# ---------------------------------------------------------------------------
class TestManualDecimalPrecision:
"""Use values known to produce binary float errors if float arithmetic is used."""
def test_no_float_rounding_error(self) -> None:
"""0.1 + 0.2 in float gives 0.30000000000000004; Decimal must be exact."""
values = {
"energy": {
"buy": {"normal": 0.1, "dal": 0.2},
"sell": {"normal": 0.1, "dal": 0.1},
"energy_tax": 0.0,
"ode": 0.0,
},
"standing": {"network_fee": 0.0, "management_fee": 0.0},
"credits": {"heffingskorting": 0.0},
}
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("1"),
r1=Decimal("0"), r2=Decimal("0"),
)
fn = get_strategy("manual")
result = fn(deltas, _ts(10), values, _NO_SESSION)
# import_cost = 1×0.2 + 1×0.1 = 0.3 exactly
assert result["import_cost"] == Decimal("0.3"), (
f"Expected Decimal('0.3'), got {result['import_cost']!r} — "
"float arithmetic leaking in?"
)
def test_result_values_are_decimal_type(self) -> None:
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": 9.87, "management_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("1"),
r1=Decimal("1"), r2=Decimal("1"),
)
fn = get_strategy("manual")
result = fn(deltas, _ts(10), values, _NO_SESSION)
assert isinstance(result["import_cost"], Decimal)
assert isinstance(result["export_revenue"], Decimal)
assert isinstance(result["net_cost"], Decimal)
# ---------------------------------------------------------------------------
# 4-7. Tibber strategy
# ---------------------------------------------------------------------------
class TestTibberStrategy:
"""Verify Tibber price-lookup and billing calculations."""
_VALUES = {
"energy": {
"energy_tax": 0.10,
"sell_adjust": 0.0,
},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
def _call(
self,
deltas: PeriodDeltas,
t0: datetime,
session: Session,
values: dict | None = None,
) -> dict:
fn = get_strategy("tibber")
return fn(deltas, t0, values or self._VALUES, session)
def test_buy_equals_total(self, tibber_db) -> None:
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
session.commit()
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("1"),
r1=Decimal("0"), r2=Decimal("0"),
)
result = self._call(deltas, t0, session)
# buy = total = 0.25; import_cost = 2 × 0.25 = 0.50
assert result["import_cost"] == Decimal("2") * Decimal("0.25")
def test_sell_equals_total_minus_energy_tax_minus_adjust(self, tibber_db) -> None:
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
session.commit()
values = {
"energy": {"energy_tax": 0.10, "sell_adjust": 0.02},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("1"), r2=Decimal("1"),
)
result = self._call(deltas, t0, session, values=values)
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
"""Correct row: starts_at ≤ t0, most recent wins."""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
# Older price (should NOT be used)
_insert_tibber_price(session, starts_at=_ts(9, 0), total=0.10)
# Closer price (starts_at ≤ t0, should be used)
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.30)
# Future price (starts_at > t0, must NOT be used)
_insert_tibber_price(session, starts_at=_ts(10, 15), total=0.99)
session.commit()
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("0"),
)
result = self._call(deltas, t0, session)
# Only the 09:45 price (total=0.30) should be used.
snapshot = result["pricing"]
assert Decimal(snapshot["total"]) == Decimal("0.30")
def test_tibber_sums_both_tariff_registers(self, tibber_db) -> None:
"""Tibber does not split dal/normal; import_cost = (d1+d2) × buy."""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20)
session.commit()
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("3"), d2=Decimal("2"),
r1=Decimal("0"), r2=Decimal("0"),
)
result = self._call(deltas, t0, session)
# import_cost = (3+2) × 0.20 = 1.00
assert result["import_cost"] == Decimal("1.00")
def test_negative_total_gives_negative_export_revenue(self, tibber_db) -> None:
"""When total is negative, selling electricity costs money (correct behaviour)."""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
# total = -0.05; sell = -0.05 - 0.10 - 0.0 = -0.15
_insert_tibber_price(session, starts_at=_ts(9, 45), total=-0.05)
session.commit()
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("2"), r2=Decimal("2"),
)
result = self._call(deltas, t0, session)
# sell = -0.05 - 0.10 - 0.0 = -0.15; export_revenue = 4 × (-0.15) = -0.60
assert result["export_revenue"] < 0
assert result["export_revenue"] == Decimal("4") * (
Decimal("-0.05") - Decimal("0.10") - Decimal("0.0")
)
def test_negative_total_exact_calculation(self, tibber_db) -> None:
"""Full hand-calculation for negative-total scenario."""
t0 = _ts(10, 0)
total = Decimal("-0.05")
energy_tax = Decimal("0.10")
sell_adjust = Decimal("0.0")
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=float(total))
session.commit()
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("1"), # delivered = 2 kWh
r1=Decimal("1"), r2=Decimal("1"), # returned = 2 kWh
)
result = self._call(deltas, t0, session)
buy = total # -0.05
sell = total - energy_tax - sell_adjust # -0.15
expected_import = Decimal("2") * buy # -0.10
expected_export = Decimal("2") * sell # -0.30
expected_net = expected_import - expected_export # 0.20
assert result["import_cost"] == expected_import
assert result["export_revenue"] == expected_export
assert result["net_cost"] == expected_net
def test_no_price_before_t0_raises(self, tibber_db) -> None:
"""When no TibberPrice exists with starts_at ≤ t0, TibberPriceNotFoundError is raised."""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
# Only a future price — starts_at > t0.
_insert_tibber_price(session, starts_at=_ts(10, 15), total=0.25)
session.commit()
fn = get_strategy("tibber")
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("0"),
)
with pytest.raises(TibberPriceNotFoundError):
fn(deltas, t0, self._VALUES, session)
def test_empty_tibber_table_raises(self, tibber_db) -> None:
"""Empty tibber_price table raises TibberPriceNotFoundError."""
fn = get_strategy("tibber")
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("0"),
)
with pytest.raises(TibberPriceNotFoundError):
fn(deltas, _ts(10), self._VALUES, session)
def test_pricing_snapshot_contains_expected_keys(self, tibber_db) -> None:
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20)
session.commit()
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("0"),
)
result = self._call(deltas, t0, session)
snapshot = result["pricing"]
assert snapshot["kind"] == "tibber"
assert "tibber_price_starts_at" in snapshot
assert "buy" in snapshot
assert "sell" in snapshot
def test_result_values_are_decimal_type(self, tibber_db) -> None:
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.20)
session.commit()
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("1"), d2=Decimal("1"),
r1=Decimal("1"), r2=Decimal("1"),
)
result = self._call(deltas, t0, session)
assert isinstance(result["import_cost"], Decimal)
assert isinstance(result["export_revenue"], Decimal)
assert isinstance(result["net_cost"], Decimal)
# ---------------------------------------------------------------------------
# Tibber precision test
# ---------------------------------------------------------------------------
class TestTibberDecimalPrecision:
"""Ensure Tibber arithmetic is exact Decimal (no float leakage)."""
def test_no_float_rounding_for_tricky_values(self, tibber_db) -> None:
"""total=0.1, energy_tax=0.2 — float gives 0.1+0.2 error; Decimal must be exact."""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3)
session.commit()
values = {
"energy": {"energy_tax": 0.1, "sell_adjust": 0.2},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
fn = get_strategy("tibber")
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("1"), r2=Decimal("0"),
)
result = fn(deltas, t0, values, session)
# sell = 0.3 - 0.1 - 0.2 = 0.0 exactly (float gives ~2.8e-17)
assert result["export_revenue"] == Decimal("0"), (
f"Expected Decimal('0'), got {result['export_revenue']!r} — "
"float arithmetic leaking in?"
)