M8-T13: add district heating pricing profile

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent b812d5ac46
commit 0fb51d338c
6 changed files with 461 additions and 9 deletions
+129 -1
View File
@@ -26,6 +26,7 @@ import pytest
import yaml
from app.integrations.pricing.profiles import (
DistrictHeatingProfile,
ManualProfile,
ProfileNotFoundError,
ProfileValidationError,
@@ -156,6 +157,64 @@ class TestLoadProfileTibber:
assert profile.credits.heffingskorting.unit == "EUR/year"
class TestLoadProfileDistrictHeating:
"""The thermal profile contains only field structure and zero defaults."""
def test_d11_fields_units_and_zero_defaults(self) -> None:
profile = load_profile("district_heating")
assert isinstance(profile, DistrictHeatingProfile)
assert profile.kind == "district_heating"
assert list(type(profile.variable).model_fields) == [
"heating", "hot_water_heating", "hot_water", "hot_water_tax"
]
assert profile.variable.heating.unit == "EUR/GJ"
assert all(
getattr(profile.variable, key).unit == "EUR/m³"
for key in ("hot_water_heating", "hot_water", "hot_water_tax")
)
assert list(type(profile.standing).model_fields) == [
"heating_network", "metering", "delivery_set", "hot_water_network", "other"
]
assert all(
getattr(profile.standing, key).unit == "EUR/year"
for key in type(profile.standing).model_fields
)
assert all(
getattr(profile.standing, key).default == 0 for key in type(profile.standing).model_fields
)
assert all(
getattr(profile.variable, key).minimum == 0 for key in type(profile.variable).model_fields
)
assert all(
getattr(profile.standing, key).minimum == 0 for key in type(profile.standing).model_fields
)
def test_profile_has_no_nonzero_tariff_defaults(self) -> None:
profile_path = Path(__file__).parent.parent / "app/integrations/pricing/profiles/district_heating.yaml"
raw = yaml.safe_load(profile_path.read_text())
defaults = [
field.get("default")
for section in (raw["variable"], raw["standing"])
for field in section.values()
if "default" in field
]
assert defaults == [0, 0, 0, 0, 0]
def test_profile_contains_no_nonzero_numeric_tariff_reference(self) -> None:
raw = yaml.safe_load(
(Path(__file__).parent.parent / "app/integrations/pricing/profiles/district_heating.yaml").read_text()
)
def numeric_values(value):
if isinstance(value, dict):
return [number for child in value.values() for number in numeric_values(child)]
if isinstance(value, (int, float)) and not isinstance(value, bool):
return [value]
return []
assert numeric_values(raw) == [0] * 14
# ---------------------------------------------------------------------------
# 3-5: load_profile error cases
# ---------------------------------------------------------------------------
@@ -220,6 +279,28 @@ class TestLoadProfileErrors:
with pytest.raises(ProfileValidationError, match="unknown_kind"):
load_profile("unknown_kind")
def test_district_heating_yaml_float_raises_validation_error(self, tmp_path: Path) -> None:
path = tmp_path / "district_heating.yaml"
path.write_text(
"""kind: district_heating
label: Bad thermal profile
variable:
heating: {unit: EUR/GJ, label: Heating, help: Enter it, minimum: 0.1}
hot_water_heating: {unit: EUR/m³, label: Heating, help: Enter it, minimum: 0}
hot_water: {unit: EUR/m³, label: Water, help: Enter it, minimum: 0}
hot_water_tax: {unit: EUR/m³, label: Tax, help: Enter it, minimum: 0}
standing:
heating_network: {unit: EUR/year, label: Network, help: Enter it, minimum: 0, default: 0}
metering: {unit: EUR/year, label: Metering, help: Enter it, minimum: 0, default: 0}
delivery_set: {unit: EUR/year, label: Set, help: Enter it, minimum: 0, default: 0}
hot_water_network: {unit: EUR/year, label: Water network, help: Enter it, minimum: 0, default: 0}
other: {unit: EUR/year, label: Other, help: Enter it, minimum: 0, default: 0}
"""
)
with patch("app.integrations.pricing.profiles._PROFILES_DIR", tmp_path):
with pytest.raises(ProfileValidationError, match="float"):
load_profile("district_heating")
# ---------------------------------------------------------------------------
# 6-9: validate_values
@@ -409,6 +490,52 @@ class TestValidateValuesTibber:
validate_values("tibber", values)
_VALID_DISTRICT_HEATING_VALUES = {
"variable": {
"heating": "12.3400",
"hot_water_heating": "1.20",
"hot_water": "2.30",
"hot_water_tax": "0.10",
},
"standing": {
"heating_network": "11",
"metering": "12",
"delivery_set": "13",
"hot_water_network": "14",
"other": "15",
},
}
class TestValidateValuesDistrictHeating:
def test_complete_values_are_decimal_string_snapshots(self) -> None:
filled = validate_values("district_heating", _VALID_DISTRICT_HEATING_VALUES)
assert filled == _VALID_DISTRICT_HEATING_VALUES
assert filled["variable"]["heating"] == "12.3400"
def test_minimal_values_fill_all_zero_standing_fields(self) -> None:
values = {"variable": _VALID_DISTRICT_HEATING_VALUES["variable"]}
filled = validate_values("district_heating", values)
assert filled["standing"] == {
"heating_network": "0", "metering": "0", "delivery_set": "0",
"hot_water_network": "0", "other": "0",
}
@pytest.mark.parametrize(
("values", "match"),
[
({"variable": {"heating": "1"}}, "hot_water_heating"),
({**_VALID_DISTRICT_HEATING_VALUES, "unexpected": {}}, "unknown section"),
({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "extra": "1"}}, "unknown field"),
({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "heating": "-1"}}, "non-negative"),
({"variable": {**_VALID_DISTRICT_HEATING_VALUES["variable"], "heating": 1.5}}, "Decimal-compatible"),
],
)
def test_rejects_missing_unknown_negative_and_float(self, values, match: str) -> None:
with pytest.raises(ProfileValidationError, match=match):
validate_values("district_heating", values)
# ---------------------------------------------------------------------------
# 10: list_profiles
# ---------------------------------------------------------------------------
@@ -421,11 +548,12 @@ class TestListProfiles:
for item in profiles:
assert isinstance(item, dict)
def test_contains_manual_and_tibber(self) -> None:
def test_contains_all_pricing_kinds(self) -> None:
profiles = list_profiles()
kinds = {p["kind"] for p in profiles}
assert "manual" in kinds
assert "tibber" in kinds
assert "district_heating" in kinds
def test_each_entry_has_label(self) -> None:
profiles = list_profiles()