M8-T13: add district heating pricing profile
This commit is contained in:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user