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).
This commit is contained in:
2026-06-23 20:58:34 +02:00
parent df54f5518b
commit bedea196c3
8 changed files with 1695 additions and 1 deletions
+415
View File
@@ -0,0 +1,415 @@
"""Tests for app/integrations/pricing/profiles.py.
Acceptance criteria covered
----------------------------
1. ``load_profile("manual")`` succeeds and returns a valid ``ManualProfile``.
2. ``load_profile("tibber")`` succeeds and returns a valid ``TibberProfile``.
3. Missing profile file raises ``ProfileNotFoundError``.
4. Malformed YAML (missing required fields) raises ``ProfileValidationError``.
5. Wrong kind in YAML raises ``ProfileValidationError``.
6. ``validate_values`` accepts conforming values for both kinds.
7. ``validate_values`` fills in default values (``ode``, ``sell_adjust``,
tibber ``management_fee``) when absent.
8. ``validate_values`` raises ``ProfileValidationError`` for missing required
fields (no default).
9. ``validate_values`` raises ``ProfileValidationError`` for wrong-typed fields.
10. ``list_profiles()`` returns both profiles in a list of dicts.
"""
from __future__ import annotations
import textwrap
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from app.integrations.pricing.profiles import (
ManualProfile,
ProfileNotFoundError,
ProfileValidationError,
TibberProfile,
list_profiles,
load_profile,
validate_values,
)
# ---------------------------------------------------------------------------
# 1-2: load_profile happy path
# ---------------------------------------------------------------------------
class TestLoadProfileManual:
"""Validate that the shipped manual.yaml loads and validates correctly."""
def test_returns_manual_profile_instance(self) -> None:
profile = load_profile("manual")
assert isinstance(profile, ManualProfile)
def test_kind_is_manual(self) -> None:
profile = load_profile("manual")
assert profile.kind == "manual"
def test_has_label(self) -> None:
profile = load_profile("manual")
assert isinstance(profile.label, str) and profile.label
def test_dual_tariff_is_true(self) -> None:
profile = load_profile("manual")
assert profile.energy.dual_tariff is True
def test_energy_buy_has_normal_and_dal(self) -> None:
profile = load_profile("manual")
assert profile.energy.buy.normal.unit == "EUR/kWh"
assert profile.energy.buy.dal.unit == "EUR/kWh"
def test_energy_sell_has_normal_and_dal(self) -> None:
profile = load_profile("manual")
assert profile.energy.sell.normal.unit == "EUR/kWh"
assert profile.energy.sell.dal.unit == "EUR/kWh"
def test_energy_tax_unit(self) -> None:
profile = load_profile("manual")
assert profile.energy.energy_tax.unit == "EUR/kWh"
assert profile.energy.energy_tax.default is None # required field, no default
def test_ode_has_default_zero(self) -> None:
profile = load_profile("manual")
assert profile.energy.ode.default == 0
def test_standing_fields(self) -> None:
profile = load_profile("manual")
assert profile.standing.network_fee.unit == "EUR/month"
assert profile.standing.management_fee.unit == "EUR/month"
def test_credits_heffingskorting(self) -> None:
profile = load_profile("manual")
assert profile.credits.heffingskorting.unit == "EUR/year"
def test_no_concrete_values_in_profile(self) -> None:
"""Profile YAML must not contain any concrete price numbers."""
profile_path = (
Path(__file__).parent.parent
/ "app/integrations/pricing/profiles/manual.yaml"
)
with profile_path.open() as fh:
raw = yaml.safe_load(fh)
# Leaf nodes should only have 'unit' and optionally 'default: 0',
# not actual price values like 0.133 or 0.127.
energy = raw.get("energy", {})
buy = energy.get("buy", {})
# Leaf buy nodes: only 'unit' key, no numeric value key.
assert set(buy["normal"].keys()) == {"unit"}, (
"buy.normal leaf must only have 'unit'"
)
assert set(buy["dal"].keys()) == {"unit"}, (
"buy.dal leaf must only have 'unit'"
)
class TestLoadProfileTibber:
"""Validate that the shipped tibber.yaml loads and validates correctly."""
def test_returns_tibber_profile_instance(self) -> None:
profile = load_profile("tibber")
assert isinstance(profile, TibberProfile)
def test_kind_is_tibber(self) -> None:
profile = load_profile("tibber")
assert profile.kind == "tibber"
def test_has_label(self) -> None:
profile = load_profile("tibber")
assert isinstance(profile.label, str) and profile.label
def test_energy_source_is_tibber_api(self) -> None:
profile = load_profile("tibber")
assert profile.energy.source == "tibber_api"
def test_energy_tax_unit(self) -> None:
profile = load_profile("tibber")
assert profile.energy.energy_tax.unit == "EUR/kWh"
assert profile.energy.energy_tax.default is None # required
def test_sell_adjust_has_default_zero(self) -> None:
profile = load_profile("tibber")
assert profile.energy.sell_adjust.default == 0
def test_management_fee_has_default(self) -> None:
profile = load_profile("tibber")
assert profile.standing.management_fee.default is not None
assert isinstance(profile.standing.management_fee.default, float)
def test_network_fee_unit(self) -> None:
profile = load_profile("tibber")
assert profile.standing.network_fee.unit == "EUR/month"
def test_credits_heffingskorting(self) -> None:
profile = load_profile("tibber")
assert profile.credits.heffingskorting.unit == "EUR/year"
# ---------------------------------------------------------------------------
# 3-5: load_profile error cases
# ---------------------------------------------------------------------------
class TestLoadProfileErrors:
def test_missing_profile_raises_not_found(self) -> None:
with pytest.raises(ProfileNotFoundError, match="nonexistent"):
load_profile("nonexistent")
def test_missing_required_fields_raises_validation_error(
self, tmp_path: Path
) -> None:
"""A YAML missing required fields raises ProfileValidationError."""
bad_yaml = textwrap.dedent(
"""\
kind: manual
label: Bad manual profile
# missing energy / standing / credits sections entirely
"""
)
bad_path = tmp_path / "manual.yaml"
bad_path.write_text(bad_yaml)
with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path):
with pytest.raises(ProfileValidationError, match="manual"):
load_profile("manual")
def test_wrong_type_raises_validation_error(self, tmp_path: Path) -> None:
"""A YAML with a wrong type (string where dict is expected) raises ProfileValidationError."""
bad_yaml = textwrap.dedent(
"""\
kind: tibber
label: Wrong type profile
energy: "should be a mapping not a string"
standing:
management_fee: { unit: EUR/month, default: 5.99 }
network_fee: { unit: EUR/month }
credits:
heffingskorting: { unit: EUR/year }
"""
)
bad_path = tmp_path / "tibber.yaml"
bad_path.write_text(bad_yaml)
with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path):
with pytest.raises(ProfileValidationError, match="tibber"):
load_profile("tibber")
def test_unknown_kind_raises_validation_error(self, tmp_path: Path) -> None:
"""A YAML with an unknown kind raises ProfileValidationError."""
bad_yaml = textwrap.dedent(
"""\
kind: unknown_kind
label: Unknown kind profile
"""
)
bad_path = tmp_path / "unknown_kind.yaml"
bad_path.write_text(bad_yaml)
with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path):
with pytest.raises(ProfileValidationError, match="unknown_kind"):
load_profile("unknown_kind")
# ---------------------------------------------------------------------------
# 6-9: validate_values
# ---------------------------------------------------------------------------
# A complete, conforming set of manual contract values.
_VALID_MANUAL_VALUES = {
"energy": {
"buy": {"normal": 0.133, "dal": 0.127},
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.1108,
"ode": 0.0,
},
"standing": {
"network_fee": 9.87,
"management_fee": 9.87,
},
"credits": {
"heffingskorting": 600.0,
},
}
# A complete, conforming set of tibber contract values.
_VALID_TIBBER_VALUES = {
"energy": {
"energy_tax": 0.1108,
"sell_adjust": 0.0,
},
"standing": {
"management_fee": 5.99,
"network_fee": 9.87,
},
"credits": {
"heffingskorting": 600.0,
},
}
class TestValidateValuesManual:
def test_valid_values_accepted(self) -> None:
filled = validate_values("manual", dict(_VALID_MANUAL_VALUES))
# Should not raise and should return a dict.
assert isinstance(filled, dict)
def test_ode_default_applied_when_absent(self) -> None:
"""When 'ode' is not in values, the default (0) should be inserted."""
values = {
"energy": {
"buy": {"normal": 0.133, "dal": 0.127},
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.1108,
# ode is absent
},
"standing": {
"network_fee": 9.87,
"management_fee": 9.87,
},
"credits": {"heffingskorting": 600.0},
}
filled = validate_values("manual", values)
assert filled["energy"]["ode"] == 0
def test_missing_energy_tax_raises(self) -> None:
values = {
"energy": {
"buy": {"normal": 0.133, "dal": 0.127},
"sell": {"normal": 0.05, "dal": 0.05},
# energy_tax absent — no default
},
"standing": {"network_fee": 9.87, "management_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with pytest.raises(ProfileValidationError, match="energy_tax"):
validate_values("manual", values)
def test_missing_buy_normal_raises(self) -> None:
values = {
"energy": {
"buy": {"dal": 0.127}, # normal absent
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.1108,
"ode": 0.0,
},
"standing": {"network_fee": 9.87, "management_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with pytest.raises(ProfileValidationError, match="normal"):
validate_values("manual", values)
def test_wrong_type_for_energy_tax_raises(self) -> None:
values = {
"energy": {
"buy": {"normal": 0.133, "dal": 0.127},
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": "not_a_number", # wrong type
"ode": 0.0,
},
"standing": {"network_fee": 9.87, "management_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with pytest.raises(ProfileValidationError, match="energy_tax"):
validate_values("manual", values)
def test_missing_heffingskorting_raises(self) -> None:
values = {
"energy": {
"buy": {"normal": 0.133, "dal": 0.127},
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.1108,
"ode": 0.0,
},
"standing": {"network_fee": 9.87, "management_fee": 9.87},
"credits": {}, # heffingskorting absent — no default
}
with pytest.raises(ProfileValidationError, match="heffingskorting"):
validate_values("manual", values)
class TestValidateValuesTibber:
def test_valid_values_accepted(self) -> None:
filled = validate_values("tibber", dict(_VALID_TIBBER_VALUES))
assert isinstance(filled, dict)
def test_sell_adjust_default_applied_when_absent(self) -> None:
values = {
"energy": {
"energy_tax": 0.1108,
# sell_adjust absent — has default 0
},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
filled = validate_values("tibber", values)
assert filled["energy"]["sell_adjust"] == 0
def test_management_fee_default_applied_when_absent(self) -> None:
values = {
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
"standing": {
"network_fee": 9.87,
# management_fee absent — has a default
},
"credits": {"heffingskorting": 600.0},
}
filled = validate_values("tibber", values)
assert "management_fee" in filled["standing"]
assert isinstance(filled["standing"]["management_fee"], float)
def test_missing_energy_tax_raises(self) -> None:
values = {
"energy": {"sell_adjust": 0.0}, # energy_tax absent — no default
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with pytest.raises(ProfileValidationError, match="energy_tax"):
validate_values("tibber", values)
def test_missing_network_fee_raises(self) -> None:
values = {
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
"standing": {"management_fee": 5.99}, # network_fee absent — no default
"credits": {"heffingskorting": 600.0},
}
with pytest.raises(ProfileValidationError, match="network_fee"):
validate_values("tibber", values)
def test_wrong_type_for_sell_adjust_raises(self) -> None:
values = {
"energy": {"energy_tax": 0.1108, "sell_adjust": "zero"}, # wrong type
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with pytest.raises(ProfileValidationError, match="sell_adjust"):
validate_values("tibber", values)
# ---------------------------------------------------------------------------
# 10: list_profiles
# ---------------------------------------------------------------------------
class TestListProfiles:
def test_returns_list_of_dicts(self) -> None:
profiles = list_profiles()
assert isinstance(profiles, list)
for item in profiles:
assert isinstance(item, dict)
def test_contains_manual_and_tibber(self) -> None:
profiles = list_profiles()
kinds = {p["kind"] for p in profiles}
assert "manual" in kinds
assert "tibber" in kinds
def test_each_entry_has_label(self) -> None:
profiles = list_profiles()
for p in profiles:
assert "label" in p and isinstance(p["label"], str)
+553
View File
@@ -0,0 +1,553 @@
"""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?"
)