mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
fix(water-heating): complete RdSAP Table 28 cylinder-size map (codes 5 + 6)
`_CYLINDER_SIZE_CODE_TO_LITRES` held only codes 2/3/4 (Normal/Medium/Large → 110/160/210 L); codes 5 (Inaccessible) and 6 (Exact) fell through to None, so the Table-13 high-rate fraction AND the cylinder storage loss were skipped for those certs (20 code-6 certs in the API sample). Per RdSAP 10 Specification (10-06-2025) §10.5 Table 28 (PDF p.55): - Code 6 "Exact": use the lodged measured volume. The gov API carries it in `cylinder_size_measured` (e.g. 150 L) — now plumbed through the 21.0.0/21.0.1 schema → mapper → `SapHeating.cylinder_volume_measured_l`. - Code 5 "Inaccessible": 210 L if off-peak electric dual immersion, 160 L from a solid-fuel boiler, otherwise 110 L (n=0 in the current sample, but spec-complete). New `_cylinder_volume_l_from_code` centralises Table 28 resolution and replaces the three raw-dict call sites (`_hot_water_cylinder_volume_l`, the cylinder storage-loss path, and the PCDB performance check) so all three honour codes 5/6 identically. `_cylinder_inaccessible_volume_l` applies the code-5 context rule via the existing immersion/off-peak-meter/solid-fuel-boiler detectors. Worksheet harness UNAFFECTED (47/47, 0 divergers): the Summary path lodges neither code 5/6 nor a measured volume. API gauge: within-0.5 64.4% -> 65.1% (mean|err| 1.085 -> 1.075) — the 20 code-6 certs now size their cylinder from the measured volume. 4 AAA tests (code 6 measured; code 5 solid-fuel/default/ off-peak-dual-immersion). pyright net-zero. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
781efd75c0
commit
a97ff60b01
7 changed files with 167 additions and 16 deletions
|
|
@ -140,6 +140,10 @@ class SapHeating:
|
|||
cylinder_size: Optional[Union[int, str]] = (
|
||||
None # int code from API; str (e.g. "Normal (90-130 litres)") from site notes
|
||||
)
|
||||
# RdSAP 10 §10.5 Table 28 — the lodged measured cylinder volume in
|
||||
# litres, present only when `cylinder_size` is the "Exact" descriptor
|
||||
# (gov-API code 6, field `cylinder_size_measured`). None otherwise.
|
||||
cylinder_volume_measured_l: Optional[int] = None
|
||||
water_heating_code: Optional[int] = None # TODO: make enum?
|
||||
water_heating_fuel: Optional[int] = None # TODO: make enum?
|
||||
immersion_heating_type: Optional[Union[int, str]] = None # TODO: make enum?
|
||||
|
|
|
|||
|
|
@ -1331,6 +1331,7 @@ class EpcPropertyDataMapper:
|
|||
has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning
|
||||
== "true",
|
||||
cylinder_size=schema.sap_heating.cylinder_size,
|
||||
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
|
||||
water_heating_code=schema.sap_heating.water_heating_code,
|
||||
water_heating_fuel=schema.sap_heating.water_heating_fuel,
|
||||
immersion_heating_type=schema.sap_heating.immersion_heating_type,
|
||||
|
|
@ -1622,6 +1623,7 @@ class EpcPropertyDataMapper:
|
|||
has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning
|
||||
== "true",
|
||||
cylinder_size=schema.sap_heating.cylinder_size,
|
||||
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
|
||||
water_heating_code=schema.sap_heating.water_heating_code,
|
||||
water_heating_fuel=schema.sap_heating.water_heating_fuel,
|
||||
immersion_heating_type=schema.sap_heating.immersion_heating_type,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ class SapHeating:
|
|||
secondary_fuel_type: Optional[int] = None
|
||||
secondary_heating_type: Optional[int] = None
|
||||
cylinder_insulation_thickness: Optional[int] = None
|
||||
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
|
||||
# only when `cylinder_size` is the "Exact" descriptor (code 6).
|
||||
cylinder_size_measured: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ class SapHeating:
|
|||
secondary_fuel_type: Optional[int] = None
|
||||
secondary_heating_type: Optional[int] = None
|
||||
cylinder_insulation_thickness: Optional[int] = None
|
||||
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
|
||||
# only when `cylinder_size` is the "Exact" descriptor (code 6).
|
||||
cylinder_size_measured: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -5082,11 +5082,55 @@ _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: Final[frozenset[int]] = frozenset(
|
|||
# code 3 → Medium (160 litres) (certs 0350, 0380, 2225, 2636,
|
||||
# 3800, 9285)
|
||||
# code 4 → Large (210 litres) (cert 9418)
|
||||
# Codes 5 / 6 (Inaccessible / Exact) not yet observed.
|
||||
# code 5 → Inaccessible (context-dependent — see Table 28 below,
|
||||
# resolved by `_cylinder_inaccessible_volume_l`)
|
||||
# code 6 → Exact (the lodged measured volume in litres,
|
||||
# `cylinder_volume_measured_l`; 20 API certs)
|
||||
_CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
|
||||
2: 110.0, 3: 160.0, 4: 210.0
|
||||
}
|
||||
|
||||
# RdSAP 10 §10.5 Table 28 (PDF p.55) — the "Inaccessible" descriptor's
|
||||
# size-to-use depends on the heating context, and the "Exact" descriptor
|
||||
# lodges its measured volume separately.
|
||||
_CYLINDER_SIZE_INACCESSIBLE: Final[int] = 5
|
||||
_CYLINDER_SIZE_EXACT: Final[int] = 6
|
||||
_CYLINDER_INACCESSIBLE_DUAL_IMMERSION_L: Final[float] = 210.0
|
||||
_CYLINDER_INACCESSIBLE_SOLID_FUEL_L: Final[float] = 160.0
|
||||
_CYLINDER_INACCESSIBLE_DEFAULT_L: Final[float] = 110.0
|
||||
|
||||
|
||||
def _cylinder_inaccessible_volume_l(epc: EpcPropertyData) -> float:
|
||||
"""RdSAP 10 §10.5 Table 28 (PDF p.55) — size to use for an
|
||||
"Inaccessible" cylinder (code 5): 210 L for off-peak electric DUAL
|
||||
immersion, 160 L from a solid-fuel boiler, otherwise 110 L."""
|
||||
if _immersion_is_single(epc) is False and _is_off_peak_meter(
|
||||
epc.sap_energy_source.meter_type, fuel_is_electric=True
|
||||
):
|
||||
return _CYLINDER_INACCESSIBLE_DUAL_IMMERSION_L
|
||||
main = _first_main_heating(epc)
|
||||
if main is not None and main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES:
|
||||
return _CYLINDER_INACCESSIBLE_SOLID_FUEL_L
|
||||
return _CYLINDER_INACCESSIBLE_DEFAULT_L
|
||||
|
||||
|
||||
def _cylinder_volume_l_from_code(epc: EpcPropertyData) -> Optional[float]:
|
||||
"""RdSAP 10 §10.5 Table 28 — resolve the HW cylinder volume (litres)
|
||||
from the lodged `cylinder_size` descriptor code. Codes 2/3/4 →
|
||||
110/160/210; code 5 (Inaccessible) → context-dependent; code 6 (Exact)
|
||||
→ the lodged measured volume. Returns None when no size code is lodged
|
||||
(or code 6 lodges no measured volume). Does NOT gate on
|
||||
`has_hot_water_cylinder` — callers apply that guard."""
|
||||
size_code = _int_or_none(epc.sap_heating.cylinder_size)
|
||||
if size_code is None:
|
||||
return None
|
||||
if size_code == _CYLINDER_SIZE_EXACT:
|
||||
measured = _int_or_none(epc.sap_heating.cylinder_volume_measured_l)
|
||||
return float(measured) if measured is not None else None
|
||||
if size_code == _CYLINDER_SIZE_INACCESSIBLE:
|
||||
return _cylinder_inaccessible_volume_l(epc)
|
||||
return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
|
||||
|
||||
# RdSAP `immersion_heating_type` lodgement codes. Code 1 = DUAL immersion,
|
||||
# code 2 = SINGLE. Confirmed against RdSAP 10 §10.5 (PDF p.54 — an
|
||||
# immersion is "assumed dual" on a dual/off-peak meter) cross-checked
|
||||
|
|
@ -5484,10 +5528,7 @@ def _heat_pump_cylinder_meets_pcdb_criteria(
|
|||
for the cohort this criterion is always "unknown" → returns False.
|
||||
"""
|
||||
sh = epc.sap_heating
|
||||
size_code = _int_or_none(sh.cylinder_size)
|
||||
if size_code is None:
|
||||
return False
|
||||
cert_volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
|
||||
cert_volume_l = _cylinder_volume_l_from_code(epc)
|
||||
if cert_volume_l is None:
|
||||
return False
|
||||
# Volume criterion.
|
||||
|
|
@ -5995,15 +6036,13 @@ def _orientation_from_summary_string(raw: Optional[str]) -> Optional[Orientation
|
|||
|
||||
def _hot_water_cylinder_volume_l(epc: EpcPropertyData) -> Optional[float]:
|
||||
"""Resolve the HW cylinder volume (litres) from the cert's
|
||||
`cylinder_size` code via RdSAP 10 §10.5 Table 28. Returns None
|
||||
when no cylinder is lodged or the size code falls outside the
|
||||
cohort-observed range (codes 2-4 → Normal / Medium / Large)."""
|
||||
`cylinder_size` code via RdSAP 10 §10.5 Table 28 — Normal/Medium/Large
|
||||
(codes 2/3/4), Inaccessible (5, context-dependent) and Exact (6, lodged
|
||||
measured volume). Returns None when no cylinder is lodged or no size
|
||||
code resolves."""
|
||||
if not epc.has_hot_water_cylinder:
|
||||
return None
|
||||
size_code = _int_or_none(epc.sap_heating.cylinder_size)
|
||||
if size_code is None:
|
||||
return None
|
||||
return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
|
||||
return _cylinder_volume_l_from_code(epc)
|
||||
|
||||
|
||||
def _immersion_is_single(epc: EpcPropertyData) -> Optional[bool]:
|
||||
|
|
@ -6286,10 +6325,7 @@ def _cylinder_storage_loss_override(
|
|||
if not epc.has_hot_water_cylinder:
|
||||
return None
|
||||
sh = epc.sap_heating
|
||||
size_code = _int_or_none(sh.cylinder_size)
|
||||
if size_code is None:
|
||||
return None
|
||||
volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
|
||||
volume_l = _cylinder_volume_l_from_code(epc)
|
||||
if volume_l is None:
|
||||
return None
|
||||
insulation_label = _cylinder_storage_loss_insulation_label(
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ def make_sap_heating(
|
|||
water_heating_code: Optional[int] = 901,
|
||||
water_heating_fuel: Optional[int] = 26,
|
||||
cylinder_size: Optional[Union[int, str]] = None,
|
||||
cylinder_volume_measured_l: Optional[int] = None,
|
||||
immersion_heating_type: Optional[Union[int, str]] = None,
|
||||
cylinder_insulation_type: Optional[int] = None,
|
||||
cylinder_insulation_thickness_mm: Optional[int] = None,
|
||||
cylinder_thermostat: Optional[str] = None,
|
||||
|
|
@ -115,6 +117,8 @@ def make_sap_heating(
|
|||
water_heating_code=water_heating_code,
|
||||
water_heating_fuel=water_heating_fuel,
|
||||
cylinder_size=cylinder_size,
|
||||
cylinder_volume_measured_l=cylinder_volume_measured_l,
|
||||
immersion_heating_type=immersion_heating_type,
|
||||
cylinder_insulation_type=cylinder_insulation_type,
|
||||
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
|
||||
cylinder_thermostat=cylinder_thermostat,
|
||||
|
|
|
|||
|
|
@ -421,6 +421,105 @@ def test_cylinder_thermostat_assumed_present_per_sap_9_4_9() -> None:
|
|||
assert _cylinder_thermostat_present(epc_gas, gas_boiler) is False
|
||||
|
||||
|
||||
def _cylinder_epc(
|
||||
*, cylinder_size: int, cylinder_volume_measured_l: Optional[int] = None,
|
||||
immersion_heating_type: Optional[int] = None, main: Optional[MainHeatingDetail] = None,
|
||||
) -> EpcPropertyData:
|
||||
"""A minimal cylinder-bearing epc for Table 28 size-code resolution."""
|
||||
return make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2, habitable_rooms_count=4,
|
||||
country_code="ENG", has_hot_water_cylinder=True,
|
||||
sap_building_parts=[make_building_part(construction_age_band="D")],
|
||||
sap_heating=make_sap_heating(
|
||||
main_heating_details=[main] if main is not None else None,
|
||||
cylinder_size=cylinder_size,
|
||||
cylinder_volume_measured_l=cylinder_volume_measured_l,
|
||||
immersion_heating_type=immersion_heating_type,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_cylinder_size_exact_code_6_uses_lodged_measured_volume() -> None:
|
||||
# Arrange — RdSAP 10 §10.5 Table 28 (PDF p.55): the "Exact" cylinder-size
|
||||
# descriptor (gov-API code 6) lodges the measured volume in litres via
|
||||
# `cylinder_size_measured`; SAP uses the actual size when present. The
|
||||
# map previously held only codes 2/3/4 → code 6 fell through to None,
|
||||
# skipping the Table-13 high-rate fraction (20 certs in the API sample).
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
epc = _cylinder_epc(cylinder_size=6, cylinder_volume_measured_l=150)
|
||||
|
||||
# Act
|
||||
volume_l = _hot_water_cylinder_volume_l(epc)
|
||||
|
||||
# Assert — the lodged 150 L is used verbatim, not a descriptor default.
|
||||
assert volume_l is not None and abs(volume_l - 150.0) <= 1e-9
|
||||
|
||||
|
||||
def test_cylinder_size_inaccessible_code_5_solid_fuel_boiler_uses_160l() -> None:
|
||||
# Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5)
|
||||
# heated "from a solid fuel boiler" uses 160 litres.
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
solid_fuel = MainHeatingDetail(
|
||||
has_fghrs=False, main_fuel_type=5, heat_emitter_type=1,
|
||||
emitter_temperature=1, main_heating_control=2106,
|
||||
main_heating_category=1, sap_main_heating_code=153, # solid-fuel boiler
|
||||
)
|
||||
epc = _cylinder_epc(cylinder_size=5, main=solid_fuel)
|
||||
|
||||
# Act
|
||||
volume_l = _hot_water_cylinder_volume_l(epc)
|
||||
|
||||
# Assert
|
||||
assert volume_l is not None and abs(volume_l - 160.0) <= 1e-9
|
||||
|
||||
|
||||
def test_cylinder_size_inaccessible_code_5_otherwise_uses_110l() -> None:
|
||||
# Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5)
|
||||
# that is neither off-peak dual immersion nor solid-fuel uses 110 litres.
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
gas_boiler = MainHeatingDetail(
|
||||
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
|
||||
emitter_temperature=1, main_heating_control=2106,
|
||||
main_heating_category=2, sap_main_heating_code=102,
|
||||
)
|
||||
epc = _cylinder_epc(cylinder_size=5, main=gas_boiler)
|
||||
|
||||
# Act
|
||||
volume_l = _hot_water_cylinder_volume_l(epc)
|
||||
|
||||
# Assert
|
||||
assert volume_l is not None and abs(volume_l - 110.0) <= 1e-9
|
||||
|
||||
|
||||
def test_cylinder_size_inaccessible_code_5_off_peak_dual_immersion_uses_210l() -> None:
|
||||
# Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5)
|
||||
# heated by an off-peak electric DUAL immersion (immersion_heating_type=1)
|
||||
# uses 210 litres.
|
||||
from dataclasses import replace
|
||||
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
base = _cylinder_epc(cylinder_size=5, immersion_heating_type=1) # 1 = dual
|
||||
# Off-peak (dual / Economy-7) meter, not the fixture's "Single" default.
|
||||
epc = replace(
|
||||
base,
|
||||
sap_energy_source=replace(base.sap_energy_source, meter_type="1"),
|
||||
)
|
||||
|
||||
# Act
|
||||
volume_l = _hot_water_cylinder_volume_l(epc)
|
||||
|
||||
# Assert
|
||||
assert volume_l is not None and abs(volume_l - 210.0) <= 1e-9
|
||||
|
||||
|
||||
def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None:
|
||||
# Arrange — heat-network main (Table 4a code 301 = community heating,
|
||||
# category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue