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
import logging
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any, Optional
@@ -152,13 +153,86 @@ class TibberProfile(BaseModel):
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.
AnyProfile = ManualProfile | TibberProfile
AnyProfile = ManualProfile | TibberProfile | DistrictHeatingProfile
# 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,
"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__}"
)
# 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.
yaml_kind = raw.get("kind", 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
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]:
"""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)
if isinstance(profile, TibberProfile):
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.
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:
Human-readable label for the contract.
kind:
Pricing strategy identifier (``"manual"`` or ``"tibber"``).
Pricing strategy identifier (``"manual"``, ``"tibber"``, or
``"district_heating"``).
currency:
ISO 4217 currency code (default ``"EUR"``).
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.
effective_from:
UTC datetime at which the first pricing version takes effect.