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
+159 -2
View File
@@ -25,6 +25,7 @@ Design notes
from __future__ import annotations from __future__ import annotations
import logging import logging
from decimal import Decimal, InvalidOperation
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -152,13 +153,86 @@ class TibberProfile(BaseModel):
return self return self
# ---------------------------------------------------------------------------
# DistrictHeatingProfile — user-entered thermal contract structure
# ---------------------------------------------------------------------------
class DistrictHeatingFieldSpec(BaseModel):
"""A Decimal-safe thermal tariff field displayed to the user."""
model_config = {"extra": "forbid"}
unit: str
label: str
help: str
minimum: Decimal = Decimal("0")
default: Decimal | None = None
class DistrictHeatingVariableSpec(BaseModel):
model_config = {"extra": "forbid"}
heating: DistrictHeatingFieldSpec
hot_water_heating: DistrictHeatingFieldSpec
hot_water: DistrictHeatingFieldSpec
hot_water_tax: DistrictHeatingFieldSpec
class DistrictHeatingStandingSpec(BaseModel):
model_config = {"extra": "forbid"}
heating_network: DistrictHeatingFieldSpec
metering: DistrictHeatingFieldSpec
delivery_set: DistrictHeatingFieldSpec
hot_water_network: DistrictHeatingFieldSpec
other: DistrictHeatingFieldSpec
class DistrictHeatingProfile(BaseModel):
"""Complete structure description for a ``district_heating`` contract."""
model_config = {"extra": "forbid"}
kind: str
label: str
variable: DistrictHeatingVariableSpec
standing: DistrictHeatingStandingSpec
@model_validator(mode="after")
def _check_kind(self) -> "DistrictHeatingProfile":
if self.kind != "district_heating":
raise ValueError(
"DistrictHeatingProfile requires kind='district_heating', "
f"got {self.kind!r}"
)
units = {
"heating": "EUR/GJ",
"hot_water_heating": "EUR/m³",
"hot_water": "EUR/m³",
"hot_water_tax": "EUR/m³",
}
for key, unit in units.items():
field = getattr(self.variable, key)
if field.unit != unit or field.minimum != 0 or field.default is not None:
raise ValueError(f"district_heating.variable.{key} must be required {unit} with minimum 0")
for key in DistrictHeatingStandingSpec.model_fields:
field = getattr(self.standing, key)
if field.unit != "EUR/year" or field.minimum != 0 or field.default != 0:
raise ValueError(
f"district_heating.standing.{key} must be EUR/year with default and minimum 0"
)
return self
# A union type for type hints where either profile is acceptable. # A union type for type hints where either profile is acceptable.
AnyProfile = ManualProfile | TibberProfile AnyProfile = ManualProfile | TibberProfile | DistrictHeatingProfile
# Map kind → Pydantic model class used for validation. # Map kind → Pydantic model class used for validation.
_PROFILE_MODELS: dict[str, type[ManualProfile] | type[TibberProfile]] = { _PROFILE_MODELS: dict[str, type[ManualProfile] | type[TibberProfile] | type[DistrictHeatingProfile]] = {
"manual": ManualProfile, "manual": ManualProfile,
"tibber": TibberProfile, "tibber": TibberProfile,
"district_heating": DistrictHeatingProfile,
} }
@@ -204,6 +278,11 @@ def load_profile(kind: str) -> AnyProfile:
f"Profile '{kind}': expected a YAML mapping, got {type(raw).__name__}" f"Profile '{kind}': expected a YAML mapping, got {type(raw).__name__}"
) )
# YAML's implicit float conversion must never contaminate the thermal profile.
# Existing electricity profiles intentionally retain their established defaults.
if raw.get("kind", kind) == "district_heating":
_reject_yaml_floats(raw, path)
# Choose the right Pydantic model based on the ``kind`` field in the YAML. # Choose the right Pydantic model based on the ``kind`` field in the YAML.
yaml_kind = raw.get("kind", kind) yaml_kind = raw.get("kind", kind)
model_cls = _PROFILE_MODELS.get(yaml_kind) model_cls = _PROFILE_MODELS.get(yaml_kind)
@@ -362,6 +441,82 @@ def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> d
return filled return filled
def _reject_yaml_floats(value: Any, path: Path) -> None:
"""Reject implicit YAML floats for district-heating profile metadata."""
if isinstance(value, float):
raise ProfileValidationError(
f"Profile '{path.stem}' must not contain YAML float values; use integer 0 or strings."
)
if isinstance(value, dict):
for child in value.values():
_reject_yaml_floats(child, path)
elif isinstance(value, list):
for child in value:
_reject_yaml_floats(child, path)
def _decimal_value(section: str, key: str, value: Any) -> str:
"""Validate and normalise one thermal amount without passing through float."""
if isinstance(value, bool) or isinstance(value, float) or not isinstance(value, (str, int, Decimal)):
raise ProfileValidationError(
f"Contract values field '{section}.{key}' must be a Decimal-compatible string or integer, "
f"got {type(value).__name__!r}"
)
try:
amount = Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise ProfileValidationError(
f"Contract values field '{section}.{key}' must be a Decimal-compatible value"
) from exc
if not amount.is_finite() or amount < 0:
raise ProfileValidationError(
f"Contract values field '{section}.{key}' must be a non-negative finite Decimal"
)
return format(amount, "f")
def _validate_district_heating_values(
values: dict[str, Any], profile: DistrictHeatingProfile
) -> dict[str, Any]:
"""Validate thermal values and return a complete JSON-safe Decimal snapshot."""
if not isinstance(values, dict):
raise ProfileValidationError("District-heating contract values must be a mapping")
expected_sections = {"variable", "standing"}
unknown_sections = set(values) - expected_sections
if unknown_sections:
raise ProfileValidationError(
f"District-heating contract values contain unknown section(s): {sorted(unknown_sections)}"
)
def normalise_section(
section: str, specs: Any, *, defaults_allowed: bool
) -> dict[str, str]:
supplied = values.get(section, {})
if not isinstance(supplied, dict):
raise ProfileValidationError(f"Contract values section '{section}' must be a mapping")
expected = set(type(specs).model_fields)
unknown = set(supplied) - expected
if unknown:
raise ProfileValidationError(
f"Contract values section '{section}' contains unknown field(s): {sorted(unknown)}"
)
normalised: dict[str, str] = {}
for key in type(specs).model_fields:
if key not in supplied:
field = getattr(specs, key)
if not defaults_allowed or field.default is None:
raise ProfileValidationError(f"Contract values missing required field '{section}.{key}'")
normalised[key] = format(field.default, "f")
else:
normalised[key] = _decimal_value(section, key, supplied[key])
return normalised
return {
"variable": normalise_section("variable", profile.variable, defaults_allowed=False),
"standing": normalise_section("standing", profile.standing, defaults_allowed=True),
}
def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]: def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
"""Validate a contract-values dict against the named profile structure. """Validate a contract-values dict against the named profile structure.
@@ -395,5 +550,7 @@ def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
return _validate_manual_values(values, profile) return _validate_manual_values(values, profile)
if isinstance(profile, TibberProfile): if isinstance(profile, TibberProfile):
return _validate_tibber_values(values, profile) return _validate_tibber_values(values, profile)
if isinstance(profile, DistrictHeatingProfile):
return _validate_district_heating_values(values, profile)
# Unreachable with current kinds, but guard for future extensions. # Unreachable with current kinds, but guard for future extensions.
raise ProfileValidationError(f"No validator implemented for kind={kind!r}") raise ProfileValidationError(f"No validator implemented for kind={kind!r}")
@@ -0,0 +1,56 @@
kind: district_heating
label: 区域供热
variable:
heating:
unit: EUR/GJ
label: 供暖热量
help: 按供暖用热量计收;请录入合同中的实际金额。
minimum: 0
hot_water_heating:
unit: EUR/m³
label: 热水加热
help: 按热水体积计收的加热部分;请录入合同中的实际金额。
minimum: 0
hot_water:
unit: EUR/m³
label: 热水用量
help: 按热水体积计收的用量部分;请录入合同中的实际金额。
minimum: 0
hot_water_tax:
unit: EUR/m³
label: 热水税费
help: 按热水体积计收的税费部分;请录入合同中的实际金额。
minimum: 0
standing:
heating_network:
unit: EUR/year
label: 供暖网络费
help: 年度固定费用;默认零,按合同实际金额录入。
minimum: 0
default: 0
metering:
unit: EUR/year
label: 计量费
help: 年度固定费用;默认零,按合同实际金额录入。
minimum: 0
default: 0
delivery_set:
unit: EUR/year
label: 交付装置费
help: 年度固定费用;默认零,按合同实际金额录入。
minimum: 0
default: 0
hot_water_network:
unit: EUR/year
label: 热水网络费
help: 年度固定费用;默认零,按合同实际金额录入。
minimum: 0
default: 0
other:
unit: EUR/year
label: 其他固定费
help: 年度固定费用;默认零,按合同实际金额录入。
minimum: 0
default: 0
+5 -2
View File
@@ -160,11 +160,14 @@ def create_contract(
name: name:
Human-readable label for the contract. Human-readable label for the contract.
kind: kind:
Pricing strategy identifier (``"manual"`` or ``"tibber"``). Pricing strategy identifier (``"manual"``, ``"tibber"``, or
``"district_heating"``).
currency: currency:
ISO 4217 currency code (default ``"EUR"``). ISO 4217 currency code (default ``"EUR"``).
values: values:
Pricing values dict conforming to the named profile's structure. Pricing values dict conforming to the named profile's structure. The
district-heating profile normalises its Decimal-safe values to strings
before the JSON snapshot is stored.
Validated via ``validate_values(kind, values)`` before any writes. Validated via ``validate_values(kind, values)`` before any writes.
effective_from: effective_from:
UTC datetime at which the first pricing version takes effect. UTC datetime at which the first pricing version takes effect.
+1 -1
View File
@@ -774,7 +774,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T13 — District-heating 定价 Profile ### M8-T13 — District-heating 定价 Profile
- **Status**: `todo` - **Status**: `done`
- **Depends**: M8-T12 - **Depends**: M8-T12
- **Context**: 用 profile 固定热力字段、单位和验证,不把用户的实际 Vattenfall 金额写进仓库。 - **Context**: 用 profile 固定热力字段、单位和验证,不把用户的实际 Vattenfall 金额写进仓库。
+111 -3
View File
@@ -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]: def _manual_payload(**overrides) -> dict[str, Any]:
base: dict[str, Any] = { base: dict[str, Any] = {
@@ -127,6 +136,17 @@ def _tibber_payload(**overrides) -> dict[str, Any]:
return base 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 # Fixtures
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -156,7 +176,7 @@ def test_profiles_unauthenticated_returns_401(contracts_client):
assert resp.status_code == 401 assert resp.status_code == 401
def test_profiles_returns_both_kinds(contracts_client): def test_profiles_returns_all_kinds(contracts_client):
client, _ = contracts_client client, _ = contracts_client
_login(client) _login(client)
resp = client.get("/api/energy/profiles") 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"]} kinds = {p["kind"] for p in body["profiles"]}
assert "manual" in kinds assert "manual" in kinds
assert "tibber" in kinds assert "tibber" in kinds
assert "district_heating" in kinds
def test_profiles_contain_structure(contracts_client): def test_profiles_contain_structure(contracts_client):
@@ -177,9 +198,12 @@ def test_profiles_contain_structure(contracts_client):
for profile in body["profiles"]: for profile in body["profiles"]:
assert "kind" in profile assert "kind" in profile
assert "label" in profile assert "label" in profile
assert "energy" in profile
assert "standing" 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 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): def test_create_contract_defaults_effective_from(contracts_client):
"""When effective_from is omitted, the version is created with a recent timestamp. """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() .all()
) )
assert len(all_versions) == 2 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",
}
+129 -1
View File
@@ -26,6 +26,7 @@ import pytest
import yaml import yaml
from app.integrations.pricing.profiles import ( from app.integrations.pricing.profiles import (
DistrictHeatingProfile,
ManualProfile, ManualProfile,
ProfileNotFoundError, ProfileNotFoundError,
ProfileValidationError, ProfileValidationError,
@@ -156,6 +157,64 @@ class TestLoadProfileTibber:
assert profile.credits.heffingskorting.unit == "EUR/year" 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 # 3-5: load_profile error cases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -220,6 +279,28 @@ class TestLoadProfileErrors:
with pytest.raises(ProfileValidationError, match="unknown_kind"): with pytest.raises(ProfileValidationError, match="unknown_kind"):
load_profile("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 # 6-9: validate_values
@@ -409,6 +490,52 @@ class TestValidateValuesTibber:
validate_values("tibber", values) 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 # 10: list_profiles
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -421,11 +548,12 @@ class TestListProfiles:
for item in profiles: for item in profiles:
assert isinstance(item, dict) assert isinstance(item, dict)
def test_contains_manual_and_tibber(self) -> None: def test_contains_all_pricing_kinds(self) -> None:
profiles = list_profiles() profiles = list_profiles()
kinds = {p["kind"] for p in profiles} kinds = {p["kind"] for p in profiles}
assert "manual" in kinds assert "manual" in kinds
assert "tibber" in kinds assert "tibber" in kinds
assert "district_heating" in kinds
def test_each_entry_has_label(self) -> None: def test_each_entry_has_label(self) -> None:
profiles = list_profiles() profiles = list_profiles()