M8-T13: add district heating pricing profile
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### 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 金额写进仓库。
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user