M8-T13: add district heating pricing profile
This commit is contained in:
@@ -104,6 +104,15 @@ _TIBBER_VALUES: dict[str, Any] = {
|
||||
},
|
||||
}
|
||||
|
||||
_DISTRICT_HEATING_VALUES: dict[str, Any] = {
|
||||
"variable": {
|
||||
"heating": "12.34",
|
||||
"hot_water_heating": "1.20",
|
||||
"hot_water": "2.30",
|
||||
"hot_water_tax": "0.10",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _manual_payload(**overrides) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
@@ -127,6 +136,17 @@ def _tibber_payload(**overrides) -> dict[str, Any]:
|
||||
return base
|
||||
|
||||
|
||||
def _district_heating_payload(**overrides) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"name": "District Heating",
|
||||
"kind": "district_heating",
|
||||
"currency": "EUR",
|
||||
"values": _DISTRICT_HEATING_VALUES,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -156,7 +176,7 @@ def test_profiles_unauthenticated_returns_401(contracts_client):
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_profiles_returns_both_kinds(contracts_client):
|
||||
def test_profiles_returns_all_kinds(contracts_client):
|
||||
client, _ = contracts_client
|
||||
_login(client)
|
||||
resp = client.get("/api/energy/profiles")
|
||||
@@ -166,6 +186,7 @@ def test_profiles_returns_both_kinds(contracts_client):
|
||||
kinds = {p["kind"] for p in body["profiles"]}
|
||||
assert "manual" in kinds
|
||||
assert "tibber" in kinds
|
||||
assert "district_heating" in kinds
|
||||
|
||||
|
||||
def test_profiles_contain_structure(contracts_client):
|
||||
@@ -177,9 +198,12 @@ def test_profiles_contain_structure(contracts_client):
|
||||
for profile in body["profiles"]:
|
||||
assert "kind" in profile
|
||||
assert "label" in profile
|
||||
assert "energy" in profile
|
||||
assert "standing" in profile
|
||||
assert "credits" in profile
|
||||
if profile["kind"] == "district_heating":
|
||||
assert "variable" in profile
|
||||
else:
|
||||
assert "energy" in profile
|
||||
assert "credits" in profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -346,6 +370,57 @@ def test_create_tibber_contract_success(contracts_client):
|
||||
assert len(body["versions"]) == 1
|
||||
|
||||
|
||||
def test_create_district_heating_contract_normalises_snapshot_and_scope(contracts_client):
|
||||
client, _ = contracts_client
|
||||
_login(client)
|
||||
response = client.post(
|
||||
"/api/energy/contracts",
|
||||
json=_district_heating_payload(),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["kind"] == "district_heating"
|
||||
assert body["scope"] == "thermal"
|
||||
assert body["versions"][0]["values"] == {
|
||||
"variable": _DISTRICT_HEATING_VALUES["variable"],
|
||||
"standing": {
|
||||
"heating_network": "0", "metering": "0", "delivery_set": "0",
|
||||
"hot_water_network": "0", "other": "0",
|
||||
},
|
||||
}
|
||||
assert client.get("/api/energy/contracts").json()["items"] == []
|
||||
assert client.get("/api/energy/contracts?scope=thermal").json()["total"] == 1
|
||||
mismatch = client.post(
|
||||
"/api/energy/contracts",
|
||||
json=_district_heating_payload(scope="electricity"),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert mismatch.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"values",
|
||||
[
|
||||
{"variable": {"heating": "1"}},
|
||||
{"variable": {**_DISTRICT_HEATING_VALUES["variable"], "heating": -1}},
|
||||
{"variable": {**_DISTRICT_HEATING_VALUES["variable"], "heating": 1.5}},
|
||||
{"variable": {**_DISTRICT_HEATING_VALUES["variable"], "extra": "1"}},
|
||||
],
|
||||
)
|
||||
def test_create_district_heating_rejects_invalid_decimal_values(contracts_client, values):
|
||||
client, engine = contracts_client
|
||||
_login(client)
|
||||
response = client.post(
|
||||
"/api/energy/contracts",
|
||||
json=_district_heating_payload(values=values),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
with Session(engine) as session:
|
||||
assert session.execute(select(EnergyContract)).scalars().all() == []
|
||||
|
||||
|
||||
def test_create_contract_defaults_effective_from(contracts_client):
|
||||
"""When effective_from is omitted, the version is created with a recent timestamp.
|
||||
|
||||
@@ -815,3 +890,36 @@ def test_add_version_closes_previous_and_appends(contracts_client):
|
||||
.all()
|
||||
)
|
||||
assert len(all_versions) == 2
|
||||
|
||||
|
||||
def test_district_heating_version_timeline_keeps_normalised_snapshots(contracts_client):
|
||||
client, _ = contracts_client
|
||||
_login(client)
|
||||
t0 = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
created = client.post(
|
||||
"/api/energy/contracts",
|
||||
json=_district_heating_payload(effective_from=t0.isoformat()),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
contract_id = created.json()["id"]
|
||||
t1 = datetime(2026, 6, 1, tzinfo=UTC)
|
||||
updated_values = {
|
||||
"variable": {**_DISTRICT_HEATING_VALUES["variable"], "heating": "13.500"},
|
||||
"standing": {"metering": "10.00"},
|
||||
}
|
||||
response = client.post(
|
||||
f"/api/energy/contracts/{contract_id}/versions",
|
||||
json={"effective_from": t1.isoformat(), "values": updated_values},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
old, new = response.json()["versions"]
|
||||
assert old["effective_to"] is not None
|
||||
assert old["values"]["variable"]["heating"] == "12.34"
|
||||
assert new["effective_to"] is None
|
||||
assert new["values"]["variable"]["heating"] == "13.500"
|
||||
assert new["values"]["standing"] == {
|
||||
"heating_network": "0", "metering": "10.00", "delivery_set": "0",
|
||||
"hot_water_network": "0", "other": "0",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user