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:
@@ -0,0 +1,11 @@
|
|||||||
|
"""Pricing profile framework and strategy registry.
|
||||||
|
|
||||||
|
This package provides:
|
||||||
|
|
||||||
|
- ``profiles``: Pydantic models describing the structure of pricing profiles
|
||||||
|
(``ManualProfile`` / ``TibberProfile``), YAML loaders, and contract-values
|
||||||
|
validation (``load_profile`` / ``list_profiles`` / ``validate_values``).
|
||||||
|
- ``strategies``: A lightweight registry of price-calculation strategies
|
||||||
|
(``register_strategy`` / ``get_strategy``), with built-in implementations for
|
||||||
|
the ``manual`` (fixed dual-tariff) and ``tibber`` (dynamic API) kinds.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"""Pricing profile loader, validator, and contract-values checker.
|
||||||
|
|
||||||
|
A *pricing profile* is a YAML file that describes the **structure** of an energy
|
||||||
|
contract — which fields exist, their units, and which have defaults. Actual
|
||||||
|
pricing values (the numbers the user fills in via the UI) are stored in the DB
|
||||||
|
as ``EnergyContractVersion.values`` (a JSON blob) and must conform to the
|
||||||
|
corresponding profile's structure.
|
||||||
|
|
||||||
|
Profiles live in ``app/integrations/pricing/profiles/<kind>.yaml`` and are
|
||||||
|
located at runtime relative to *this file* (not CWD), matching the pattern
|
||||||
|
established by the Modbus profile loader.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- **Purely data + functions** — no abstract base classes or inheritance.
|
||||||
|
- Two Pydantic models — ``ManualProfile`` and ``TibberProfile`` — capture the
|
||||||
|
different structures of the two supported kinds. A thin union dispatcher in
|
||||||
|
``load_profile`` picks the right one.
|
||||||
|
- ``validate_values(kind, values)`` fills in fields that carry a ``default`` and
|
||||||
|
raises ``ProfileValidationError`` for missing required fields or wrong types.
|
||||||
|
- All errors are subclasses of built-ins so callers need not import this module
|
||||||
|
just to catch them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ValidationError, model_validator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Directory containing the YAML profiles, located relative to *this* file.
|
||||||
|
_PROFILES_DIR = Path(__file__).parent / "profiles"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom exceptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileNotFoundError(FileNotFoundError):
|
||||||
|
"""Raised when the requested profile YAML file does not exist."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileValidationError(ValueError):
|
||||||
|
"""Raised when a profile YAML fails Pydantic validation or when contract
|
||||||
|
values do not conform to the profile structure."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Leaf-node models (a single pricing field: unit + optional default)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FieldSpec(BaseModel):
|
||||||
|
"""Specification for a single numeric pricing field."""
|
||||||
|
|
||||||
|
unit: str
|
||||||
|
"""Physical / monetary unit string (e.g. ``"EUR/kWh"``, ``"EUR/month"``)."""
|
||||||
|
|
||||||
|
default: Optional[float] = None
|
||||||
|
"""Default value used when the field is absent from contract values.
|
||||||
|
``None`` means the field is required (no default)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ManualProfile — fixed / variable dual-tariff contract structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ManualBuySpec(BaseModel):
|
||||||
|
normal: FieldSpec # high-tariff buy price (delivered_2 registers)
|
||||||
|
dal: FieldSpec # low-tariff buy price (delivered_1 registers)
|
||||||
|
|
||||||
|
|
||||||
|
class ManualSellSpec(BaseModel):
|
||||||
|
normal: FieldSpec # high-tariff sell / return price
|
||||||
|
dal: FieldSpec # low-tariff sell / return price
|
||||||
|
|
||||||
|
|
||||||
|
class ManualEnergySpec(BaseModel):
|
||||||
|
dual_tariff: bool # always True for manual profiles
|
||||||
|
buy: ManualBuySpec
|
||||||
|
sell: ManualSellSpec
|
||||||
|
energy_tax: FieldSpec # added to buy price; includes VAT
|
||||||
|
ode: FieldSpec # currently merged into energy_tax; default 0
|
||||||
|
|
||||||
|
|
||||||
|
class ManualStandingSpec(BaseModel):
|
||||||
|
network_fee: FieldSpec # EUR/month
|
||||||
|
management_fee: FieldSpec # EUR/month
|
||||||
|
|
||||||
|
|
||||||
|
class ManualCreditsSpec(BaseModel):
|
||||||
|
heffingskorting: FieldSpec # EUR/year — energy-tax credit deducted at summary
|
||||||
|
|
||||||
|
|
||||||
|
class ManualProfile(BaseModel):
|
||||||
|
"""Complete structure description for a ``manual`` pricing contract."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
label: str
|
||||||
|
energy: ManualEnergySpec
|
||||||
|
standing: ManualStandingSpec
|
||||||
|
credits: ManualCreditsSpec
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_kind(self) -> "ManualProfile":
|
||||||
|
if self.kind != "manual":
|
||||||
|
raise ValueError(f"ManualProfile requires kind='manual', got {self.kind!r}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TibberProfile — dynamic Tibber API contract structure
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TibberEnergySpec(BaseModel):
|
||||||
|
source: str # must be "tibber_api"
|
||||||
|
energy_tax: FieldSpec # subtracted from total to derive sell price
|
||||||
|
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
|
||||||
|
|
||||||
|
|
||||||
|
class TibberStandingSpec(BaseModel):
|
||||||
|
management_fee: FieldSpec # EUR/month; has a default
|
||||||
|
network_fee: FieldSpec # EUR/month
|
||||||
|
|
||||||
|
|
||||||
|
class TibberCreditsSpec(BaseModel):
|
||||||
|
heffingskorting: FieldSpec # EUR/year
|
||||||
|
|
||||||
|
|
||||||
|
class TibberProfile(BaseModel):
|
||||||
|
"""Complete structure description for a ``tibber`` pricing contract."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
label: str
|
||||||
|
energy: TibberEnergySpec
|
||||||
|
standing: TibberStandingSpec
|
||||||
|
credits: TibberCreditsSpec
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_kind(self) -> "TibberProfile":
|
||||||
|
if self.kind != "tibber":
|
||||||
|
raise ValueError(f"TibberProfile requires kind='tibber', got {self.kind!r}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# A union type for type hints where either profile is acceptable.
|
||||||
|
AnyProfile = ManualProfile | TibberProfile
|
||||||
|
|
||||||
|
# Map kind → Pydantic model class used for validation.
|
||||||
|
_PROFILE_MODELS: dict[str, type[ManualProfile] | type[TibberProfile]] = {
|
||||||
|
"manual": ManualProfile,
|
||||||
|
"tibber": TibberProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_profile(kind: str) -> AnyProfile:
|
||||||
|
"""Load and validate a pricing profile by kind name.
|
||||||
|
|
||||||
|
The profile file is expected at
|
||||||
|
``app/integrations/pricing/profiles/<kind>.yaml``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
kind:
|
||||||
|
Profile kind without extension (``"manual"`` or ``"tibber"``).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ManualProfile | TibberProfile
|
||||||
|
Validated profile model.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ProfileNotFoundError
|
||||||
|
If ``profiles/<kind>.yaml`` does not exist.
|
||||||
|
ProfileValidationError
|
||||||
|
If the YAML is syntactically valid but fails schema validation.
|
||||||
|
"""
|
||||||
|
path = _PROFILES_DIR / f"{kind}.yaml"
|
||||||
|
if not path.exists():
|
||||||
|
raise ProfileNotFoundError(
|
||||||
|
f"Pricing profile '{kind}' not found (looked for {path})"
|
||||||
|
)
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as fh:
|
||||||
|
raw = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Profile '{kind}': expected a YAML mapping, got {type(raw).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Choose the right Pydantic model based on the ``kind`` field in the YAML.
|
||||||
|
yaml_kind = raw.get("kind", kind)
|
||||||
|
model_cls = _PROFILE_MODELS.get(yaml_kind)
|
||||||
|
if model_cls is None:
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Profile '{kind}' has unknown kind={yaml_kind!r}; "
|
||||||
|
f"supported: {list(_PROFILE_MODELS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return model_cls.model_validate(raw)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Profile '{kind}' failed validation: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles() -> list[dict[str, Any]]:
|
||||||
|
"""Return structural data for all available pricing profiles.
|
||||||
|
|
||||||
|
Scans ``app/integrations/pricing/profiles/*.yaml``, loads each, and returns
|
||||||
|
a list of dicts (Pydantic model dumps). Profiles that fail to load are
|
||||||
|
skipped with a warning.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[dict[str, Any]]
|
||||||
|
One entry per valid profile, suitable for JSON serialisation and
|
||||||
|
front-end form rendering.
|
||||||
|
"""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
if not _PROFILES_DIR.exists():
|
||||||
|
return results
|
||||||
|
|
||||||
|
for path in sorted(_PROFILES_DIR.glob("*.yaml")):
|
||||||
|
kind = path.stem
|
||||||
|
try:
|
||||||
|
profile = load_profile(kind)
|
||||||
|
results.append(profile.model_dump())
|
||||||
|
except (ProfileNotFoundError, ProfileValidationError, Exception) as exc:
|
||||||
|
logger.warning("Skipping malformed pricing profile '%s': %s", kind, exc)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Contract-values validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dict[str, Any]:
|
||||||
|
"""Return a copy of *values* with any ``ode`` default applied if missing."""
|
||||||
|
filled = dict(values)
|
||||||
|
|
||||||
|
energy = dict(filled.get("energy", {}))
|
||||||
|
|
||||||
|
# Apply default for ode (default=0) if absent.
|
||||||
|
if "ode" not in energy and profile.energy.ode.default is not None:
|
||||||
|
energy["ode"] = profile.energy.ode.default
|
||||||
|
|
||||||
|
filled["energy"] = energy
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
||||||
|
"""Return a copy of *values* with sell_adjust and management_fee defaults applied."""
|
||||||
|
filled = dict(values)
|
||||||
|
|
||||||
|
energy = dict(filled.get("energy", {}))
|
||||||
|
# Apply default for sell_adjust (default=0) if absent.
|
||||||
|
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
|
||||||
|
energy["sell_adjust"] = profile.energy.sell_adjust.default
|
||||||
|
filled["energy"] = energy
|
||||||
|
|
||||||
|
standing = dict(filled.get("standing", {}))
|
||||||
|
# Apply default for management_fee if absent.
|
||||||
|
if (
|
||||||
|
"management_fee" not in standing
|
||||||
|
and profile.standing.management_fee.default is not None
|
||||||
|
):
|
||||||
|
standing["management_fee"] = profile.standing.management_fee.default
|
||||||
|
filled["standing"] = standing
|
||||||
|
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def _require_numeric(section: str, key: str, container: dict[str, Any]) -> None:
|
||||||
|
"""Assert that *container[key]* exists and is a number; raise ProfileValidationError."""
|
||||||
|
if key not in container:
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Contract values missing required field '{section}.{key}'"
|
||||||
|
)
|
||||||
|
val = container[key]
|
||||||
|
if not isinstance(val, (int, float)):
|
||||||
|
raise ProfileValidationError(
|
||||||
|
f"Contract values field '{section}.{key}' must be a number, "
|
||||||
|
f"got {type(val).__name__!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manual_values(values: dict[str, Any], profile: ManualProfile) -> dict[str, Any]:
|
||||||
|
"""Validate and fill-defaults for manual contract values.
|
||||||
|
|
||||||
|
Returns the filled values dict on success. Raises ProfileValidationError
|
||||||
|
on missing required fields or wrong types.
|
||||||
|
"""
|
||||||
|
filled = _fill_defaults_manual(values, profile)
|
||||||
|
energy = filled.get("energy", {})
|
||||||
|
buy = energy.get("buy", {})
|
||||||
|
sell = energy.get("sell", {})
|
||||||
|
standing = filled.get("standing", {})
|
||||||
|
credits = filled.get("credits", {})
|
||||||
|
|
||||||
|
# Required energy.buy fields.
|
||||||
|
_require_numeric("energy.buy", "normal", buy)
|
||||||
|
_require_numeric("energy.buy", "dal", buy)
|
||||||
|
# Required energy.sell fields.
|
||||||
|
_require_numeric("energy.sell", "normal", sell)
|
||||||
|
_require_numeric("energy.sell", "dal", sell)
|
||||||
|
# Required energy fields.
|
||||||
|
_require_numeric("energy", "energy_tax", energy)
|
||||||
|
_require_numeric("energy", "ode", energy)
|
||||||
|
# Required standing fields.
|
||||||
|
_require_numeric("standing", "network_fee", standing)
|
||||||
|
_require_numeric("standing", "management_fee", standing)
|
||||||
|
# Required credits fields.
|
||||||
|
_require_numeric("credits", "heffingskorting", credits)
|
||||||
|
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
|
||||||
|
"""Validate and fill-defaults for tibber contract values.
|
||||||
|
|
||||||
|
Returns the filled values dict on success. Raises ProfileValidationError
|
||||||
|
on missing required fields or wrong types.
|
||||||
|
"""
|
||||||
|
filled = _fill_defaults_tibber(values, profile)
|
||||||
|
energy = filled.get("energy", {})
|
||||||
|
standing = filled.get("standing", {})
|
||||||
|
credits = filled.get("credits", {})
|
||||||
|
|
||||||
|
# Required energy fields.
|
||||||
|
_require_numeric("energy", "energy_tax", energy)
|
||||||
|
_require_numeric("energy", "sell_adjust", energy)
|
||||||
|
# Required standing fields.
|
||||||
|
_require_numeric("standing", "management_fee", standing)
|
||||||
|
_require_numeric("standing", "network_fee", standing)
|
||||||
|
# Required credits fields.
|
||||||
|
_require_numeric("credits", "heffingskorting", credits)
|
||||||
|
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate a contract-values dict against the named profile structure.
|
||||||
|
|
||||||
|
Fields that carry a ``default`` in the profile (e.g. ``ode``,
|
||||||
|
``sell_adjust``, tibber ``management_fee``) are silently filled in when
|
||||||
|
absent from *values*. Fields with no default that are absent, or fields
|
||||||
|
whose value is not a number, cause a ``ProfileValidationError``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
kind:
|
||||||
|
Profile kind (``"manual"`` or ``"tibber"``).
|
||||||
|
values:
|
||||||
|
Contract values dict as stored in ``EnergyContractVersion.values``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict[str, Any]
|
||||||
|
A (possibly mutated) copy of *values* with defaults applied.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ProfileNotFoundError
|
||||||
|
If the profile YAML for *kind* does not exist.
|
||||||
|
ProfileValidationError
|
||||||
|
If required fields are missing or have wrong types.
|
||||||
|
"""
|
||||||
|
profile = load_profile(kind)
|
||||||
|
if isinstance(profile, ManualProfile):
|
||||||
|
return _validate_manual_values(values, profile)
|
||||||
|
if isinstance(profile, TibberProfile):
|
||||||
|
return _validate_tibber_values(values, profile)
|
||||||
|
# Unreachable with current kinds, but guard for future extensions.
|
||||||
|
raise ProfileValidationError(f"No validator implemented for kind={kind!r}")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
kind: manual
|
||||||
|
label: 固定 / 可变费率(NL,双费率)
|
||||||
|
|
||||||
|
energy:
|
||||||
|
dual_tariff: true # use delivered_1/2, returned_1/2 for low/high tariffs
|
||||||
|
buy:
|
||||||
|
normal: { unit: EUR/kWh } # high-tariff buy price (delivered_2 register)
|
||||||
|
dal: { unit: EUR/kWh } # low-tariff buy price (delivered_1 register)
|
||||||
|
sell:
|
||||||
|
normal: { unit: EUR/kWh } # high-tariff sell / return price
|
||||||
|
dal: { unit: EUR/kWh } # low-tariff sell / return price (currently equal to normal)
|
||||||
|
energy_tax: { unit: EUR/kWh } # energy tax added to buy price (incl. VAT)
|
||||||
|
ode: { unit: EUR/kWh, default: 0 } # currently merged into energy_tax
|
||||||
|
|
||||||
|
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||||
|
network_fee: { unit: EUR/month }
|
||||||
|
management_fee: { unit: EUR/month }
|
||||||
|
|
||||||
|
credits:
|
||||||
|
heffingskorting: { unit: EUR/year } # energy-tax credit; deducted at summary layer
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
kind: tibber
|
||||||
|
label: Tibber 动态电价(15 分钟)
|
||||||
|
|
||||||
|
energy:
|
||||||
|
source: tibber_api # buy = total (from API); sell = total − energy_tax − sell_adjust
|
||||||
|
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
|
||||||
|
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread)
|
||||||
|
|
||||||
|
standing: # fixed charges; UI fills per month, engine prorates to days
|
||||||
|
management_fee: { unit: EUR/month, default: 5.99 }
|
||||||
|
network_fee: { unit: EUR/month }
|
||||||
|
|
||||||
|
credits:
|
||||||
|
heffingskorting: { unit: EUR/year } # energy-tax credit; deducted at summary layer
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Price-calculation strategy registry and built-in implementations.
|
||||||
|
|
||||||
|
A *strategy* is a plain function registered under a ``kind`` string. Given
|
||||||
|
per-register kWh deltas, the period start time, the active contract-version
|
||||||
|
values, and a DB session, it returns a result dict with:
|
||||||
|
|
||||||
|
{
|
||||||
|
"import_cost": Decimal, # total cost of electricity drawn from grid
|
||||||
|
"export_revenue": Decimal, # total revenue from electricity fed to grid
|
||||||
|
"net_cost": Decimal, # import_cost − export_revenue
|
||||||
|
"pricing": dict, # snapshot of the price inputs used (for auditing)
|
||||||
|
}
|
||||||
|
|
||||||
|
**Import and export are kept separate throughout** — net_cost is only derived
|
||||||
|
at the end. This supports the Dutch no-netting rule (saldering afgebouwd).
|
||||||
|
|
||||||
|
**All monetary arithmetic uses Decimal** converted via ``Decimal(str(x))`` to
|
||||||
|
avoid float binary rounding errors.
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
- **Purely data + functions**: no ABC, no class hierarchy. A ``dict``-based
|
||||||
|
registry is the simplest structure that supports the two current kinds and
|
||||||
|
leaves the door open for future additions (e.g. ``octopus``, ``frank``).
|
||||||
|
- ``deltas`` is a plain dataclass ``PeriodDeltas`` — typed, but no ORM.
|
||||||
|
- Tibber strategy queries ``TibberPrice`` directly from the session; it does not
|
||||||
|
call the Tibber API (that is T05's job).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared data types
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PeriodDeltas:
|
||||||
|
"""Per-register kWh deltas for one 15-minute billing period.
|
||||||
|
|
||||||
|
All values are ``Decimal`` and represent ``end_reading − start_reading``
|
||||||
|
for the corresponding cumulative energy register.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
d1: Decimal
|
||||||
|
Delivered (consumed) kWh on the low/dal tariff register (delivered_1).
|
||||||
|
d2: Decimal
|
||||||
|
Delivered (consumed) kWh on the normal/high tariff register (delivered_2).
|
||||||
|
r1: Decimal
|
||||||
|
Returned (fed-to-grid) kWh on the low/dal tariff register (returned_1).
|
||||||
|
r2: Decimal
|
||||||
|
Returned (fed-to-grid) kWh on the normal/high tariff register (returned_2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
d1: Decimal # delivered low-tariff
|
||||||
|
d2: Decimal # delivered high-tariff
|
||||||
|
r1: Decimal # returned low-tariff
|
||||||
|
r2: Decimal # returned high-tariff
|
||||||
|
|
||||||
|
|
||||||
|
# Strategy callable signature.
|
||||||
|
StrategyFn = Callable[
|
||||||
|
[PeriodDeltas, datetime, dict[str, Any], Session],
|
||||||
|
dict[str, Any],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_REGISTRY: dict[str, StrategyFn] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_strategy(kind: str, fn: StrategyFn) -> None:
|
||||||
|
"""Register *fn* as the price-calculation strategy for *kind*.
|
||||||
|
|
||||||
|
Overwrites any previously registered strategy for the same kind (allows
|
||||||
|
monkey-patching in tests).
|
||||||
|
"""
|
||||||
|
_REGISTRY[kind] = fn
|
||||||
|
|
||||||
|
|
||||||
|
def get_strategy(kind: str) -> StrategyFn:
|
||||||
|
"""Return the registered strategy for *kind*.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
KeyError
|
||||||
|
If no strategy is registered for *kind*.
|
||||||
|
"""
|
||||||
|
if kind not in _REGISTRY:
|
||||||
|
raise KeyError(
|
||||||
|
f"No price strategy registered for kind={kind!r}. "
|
||||||
|
f"Available: {sorted(_REGISTRY)}"
|
||||||
|
)
|
||||||
|
return _REGISTRY[kind]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(value: Any) -> Decimal:
|
||||||
|
"""Convert *value* to Decimal via str() to avoid float binary rounding."""
|
||||||
|
return Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Manual strategy — fixed dual-tariff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_strategy(
|
||||||
|
deltas: PeriodDeltas,
|
||||||
|
t0: datetime,
|
||||||
|
values: dict[str, Any],
|
||||||
|
session: Session,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Calculate billing costs for one period using fixed dual-tariff rates.
|
||||||
|
|
||||||
|
Formula (§3.4):
|
||||||
|
- ``buy_dal = energy.buy.dal + energy.energy_tax + energy.ode``
|
||||||
|
- ``buy_normal = energy.buy.normal + energy.energy_tax + energy.ode``
|
||||||
|
- ``import_cost = Δd1 × buy_dal + Δd2 × buy_normal``
|
||||||
|
- ``sell_dal = energy.sell.dal`` (no tax on return price)
|
||||||
|
- ``sell_normal = energy.sell.normal``
|
||||||
|
- ``export_revenue = Δr1 × sell_dal + Δr2 × sell_normal``
|
||||||
|
- ``net_cost = import_cost − export_revenue``
|
||||||
|
|
||||||
|
The ``pricing`` snapshot contains all per-unit prices used so that the
|
||||||
|
result is fully auditable without re-querying the contract version.
|
||||||
|
"""
|
||||||
|
energy = values.get("energy", {})
|
||||||
|
buy = energy.get("buy", {})
|
||||||
|
sell = energy.get("sell", {})
|
||||||
|
|
||||||
|
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||||
|
ode = _to_decimal(energy.get("ode", 0))
|
||||||
|
|
||||||
|
buy_dal_base = _to_decimal(buy.get("dal", 0))
|
||||||
|
buy_normal_base = _to_decimal(buy.get("normal", 0))
|
||||||
|
sell_dal = _to_decimal(sell.get("dal", 0))
|
||||||
|
sell_normal = _to_decimal(sell.get("normal", 0))
|
||||||
|
|
||||||
|
# Effective buy prices including all taxes.
|
||||||
|
buy_dal = buy_dal_base + energy_tax + ode
|
||||||
|
buy_normal = buy_normal_base + energy_tax + ode
|
||||||
|
|
||||||
|
import_cost = deltas.d1 * buy_dal + deltas.d2 * buy_normal
|
||||||
|
export_revenue = deltas.r1 * sell_dal + deltas.r2 * sell_normal
|
||||||
|
net_cost = import_cost - export_revenue
|
||||||
|
|
||||||
|
pricing_snapshot = {
|
||||||
|
"kind": "manual",
|
||||||
|
"buy_dal": str(buy_dal),
|
||||||
|
"buy_normal": str(buy_normal),
|
||||||
|
"sell_dal": str(sell_dal),
|
||||||
|
"sell_normal": str(sell_normal),
|
||||||
|
"energy_tax": str(energy_tax),
|
||||||
|
"ode": str(ode),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"import_cost": import_cost,
|
||||||
|
"export_revenue": export_revenue,
|
||||||
|
"net_cost": net_cost,
|
||||||
|
"pricing": pricing_snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tibber strategy — dynamic 15-minute spot price
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TibberPriceNotFoundError(LookupError):
|
||||||
|
"""Raised when no TibberPrice row covers the requested period start.
|
||||||
|
|
||||||
|
The billing engine (T07) catches this to mark the period as ``degraded``
|
||||||
|
or skip it until a price becomes available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _tibber_strategy(
|
||||||
|
deltas: PeriodDeltas,
|
||||||
|
t0: datetime,
|
||||||
|
values: dict[str, Any],
|
||||||
|
session: Session,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Calculate billing costs for one period using Tibber dynamic pricing.
|
||||||
|
|
||||||
|
Price lookup:
|
||||||
|
Takes the most recent ``TibberPrice`` row where ``starts_at ≤ t0``
|
||||||
|
(i.e. the slot that was in effect at *t0*). This is a DESC LIMIT 1
|
||||||
|
query on ``starts_at``.
|
||||||
|
|
||||||
|
Formula (§3.4):
|
||||||
|
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
|
||||||
|
- ``sell = total − energy_tax − sell_adjust``
|
||||||
|
- ``import_cost = (Δd1 + Δd2) × buy``
|
||||||
|
- ``export_revenue = (Δr1 + Δr2) × sell``
|
||||||
|
- ``net_cost = import_cost − export_revenue``
|
||||||
|
|
||||||
|
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
|
||||||
|
API price applies to the full delivered/returned volume.
|
||||||
|
|
||||||
|
Negative ``total`` (extreme negative spot prices):
|
||||||
|
When ``total`` is negative the ``sell`` price will also be negative,
|
||||||
|
meaning ``export_revenue`` becomes negative (feeding to grid *costs*
|
||||||
|
money). This is the mathematically correct outcome and is left as-is.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
TibberPriceNotFoundError
|
||||||
|
If no ``TibberPrice`` row exists with ``starts_at ≤ t0``. The caller
|
||||||
|
(T07 billing engine) should catch this and mark the period as
|
||||||
|
``degraded`` or skip it for later recomputation.
|
||||||
|
"""
|
||||||
|
from app.models.energy import TibberPrice # local import to avoid circular
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
price_row: TibberPrice | None = (
|
||||||
|
session.query(TibberPrice)
|
||||||
|
.filter(TibberPrice.starts_at <= t0)
|
||||||
|
.order_by(desc(TibberPrice.starts_at))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if price_row is None:
|
||||||
|
raise TibberPriceNotFoundError(
|
||||||
|
f"No TibberPrice found covering t0={t0.isoformat()!r}; "
|
||||||
|
"period cannot be billed until price data is available."
|
||||||
|
)
|
||||||
|
|
||||||
|
energy = values.get("energy", {})
|
||||||
|
energy_tax = _to_decimal(energy.get("energy_tax", 0))
|
||||||
|
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
|
||||||
|
|
||||||
|
total = _to_decimal(price_row.total)
|
||||||
|
buy = total
|
||||||
|
sell = total - energy_tax - sell_adjust
|
||||||
|
|
||||||
|
total_delivered = deltas.d1 + deltas.d2
|
||||||
|
total_returned = deltas.r1 + deltas.r2
|
||||||
|
|
||||||
|
import_cost = total_delivered * buy
|
||||||
|
export_revenue = total_returned * sell
|
||||||
|
net_cost = import_cost - export_revenue
|
||||||
|
|
||||||
|
pricing_snapshot = {
|
||||||
|
"kind": "tibber",
|
||||||
|
"tibber_price_starts_at": price_row.starts_at.isoformat(),
|
||||||
|
"tibber_price_id": price_row.id,
|
||||||
|
"total": str(total),
|
||||||
|
"buy": str(buy),
|
||||||
|
"sell": str(sell),
|
||||||
|
"energy_tax": str(energy_tax),
|
||||||
|
"sell_adjust": str(sell_adjust),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"import_cost": import_cost,
|
||||||
|
"export_revenue": export_revenue,
|
||||||
|
"net_cost": net_cost,
|
||||||
|
"pricing": pricing_snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Register built-in strategies at module import time
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
register_strategy("manual", _manual_strategy)
|
||||||
|
register_strategy("tibber", _tibber_strategy)
|
||||||
@@ -315,7 +315,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: secret 不回显/不进 OpenAPI 示例;`_settings_payload` 无遗漏;默认 off。
|
- **Reviewer checklist**: secret 不回显/不进 OpenAPI 示例;`_settings_payload` 无遗漏;默认 off。
|
||||||
|
|
||||||
### M6-T03 — pricing profile 框架 + strategy 注册表
|
### M6-T03 — pricing profile 框架 + strategy 注册表
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 仓库内 `manual.yaml`/`tibber.yaml` 定结构(pydantic 校验),加 price strategy 注册表(manual/tibber 出价)。纯模块,单测。
|
- **Context**: 仓库内 `manual.yaml`/`tibber.yaml` 定结构(pydantic 校验),加 price strategy 注册表(manual/tibber 出价)。纯模块,单测。
|
||||||
- **Files**: `create app/integrations/pricing/__init__.py`、`pricing/profiles.py`(pydantic 模型 + `load_profile`/`list_profiles`/`validate_values`)、`pricing/profiles/manual.yaml`、`pricing/profiles/tibber.yaml`、`pricing/strategies.py`(`register_strategy`/`get_strategy`、`manual`/`tibber` 实现);`create tests/test_pricing_profiles.py`、`tests/test_pricing_strategies.py`
|
- **Files**: `create app/integrations/pricing/__init__.py`、`pricing/profiles.py`(pydantic 模型 + `load_profile`/`list_profiles`/`validate_values`)、`pricing/profiles/manual.yaml`、`pricing/profiles/tibber.yaml`、`pricing/strategies.py`(`register_strategy`/`get_strategy`、`manual`/`tibber` 实现);`create tests/test_pricing_profiles.py`、`tests/test_pricing_strategies.py`
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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=total−energy_tax−sell_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?"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user