S0380.225: §10.7 no-water-heating default — A-F → 12mm loose jacket

The §10.7 no-water-heating default cylinder raised UnmappedSapCode for
age bands A-F (2 certs in a 2026 sample, bands B + C) because Table 29's
"A to F: 12 mm loose jacket" row wasn't plumbed — the loose-jacket
storage-loss branch didn't exist. S0380.224 added it, so this slice
completes the Table 29 lookup.

Restructure _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE to carry
(cylinder_insulation_type, thickness_mm) per band — A-F → (loose jacket,
12), G/H → (factory, 25), I-M → (factory, 38) per RdSAP 10 Table 29
(PDF p.56) — and have the default read both, setting the loose-jacket
type for A-F instead of hardcoding factory. The strict-raise is retained
only for an absent / out-of-A-M age band (no Table 29 row).

Validated: certs 2211 (band B, SAP 49.8 vs lodged 52) and 3420 (band C,
11.2 vs 11) now compute. §4 + golden suite 2395 passed — the corpus
"no system" cert (age G, 25 mm factory) is unchanged. cert_to_inputs.py
pyright unchanged at 32; new test suppresses reportPrivateUsage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 16:28:25 +00:00
parent 2e351be957
commit 9c0a373f7d
2 changed files with 57 additions and 11 deletions

View file

@ -4616,11 +4616,25 @@ _WHC_NO_WATER_HEATING_SYSTEM: Final[int] = 999
# "Immersion Heater Type: Single" so the single-immersion path is used.
_CYLINDER_SIZE_CODE_NORMAL_110L: Final[int] = 2
# RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
# accessible" — the §10.7 default cylinder uses the age-band insulation,
# same rule as the inaccessible-cylinder path: A-F → 12 mm loose jacket
# (not yet plumbed — strict-raise), G/H → 25 mm foam, I-M → 38 mm foam.
_TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE: Final[dict[str, int]] = {
"G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38,
# accessible" — the §10.7 default cylinder uses the age-band insulation:
# "Age band of main property A to F: 12 mm loose jacket", G/H → 25 mm
# foam, I-M → 38 mm foam. Each entry is (cylinder_insulation_type,
# thickness_mm); the loose-jacket branch is now plumbed (S0380.224) so
# A-F resolves instead of raising.
_TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE: Final[dict[str, tuple[int, int]]] = {
"A": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"B": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"C": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"D": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"E": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"F": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"G": (_CYLINDER_INSULATION_TYPE_FACTORY, 25),
"H": (_CYLINDER_INSULATION_TYPE_FACTORY, 25),
"I": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
"J": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
"K": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
"L": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
"M": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
}
@ -4643,9 +4657,8 @@ def _apply_rdsap_no_water_heating_system_default(
Elmhurst engine's worksheet header for the corpus "no system" cert
(WHS 903, Single immersion, 110 L cylinder, 25 mm foam at age G).
Raises `UnmappedSapCode` for age bands A-F (12 mm loose jacket)
no corpus member exercises that combination and the SAP 10.2 Table 2
loss-factor dispatch only has the factory-foam path plumbed.
Raises `UnmappedSapCode` only when the main dwelling's age band is
absent / outside A-M (no Table 29 row to apply).
"""
if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM:
return epc
@ -4654,17 +4667,18 @@ def _apply_rdsap_no_water_heating_system_default(
if epc.sap_building_parts else None
)
band = (age_band or "")[:1].upper()
thickness_mm = _TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE.get(band)
if thickness_mm is None:
default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band)
if default is None:
raise UnmappedSapCode(
"rdsap_10_7_default_cylinder_insulation_age_band", age_band
)
insulation_type_code, thickness_mm = default
sap_heating = replace(
epc.sap_heating,
water_heating_code=_WHC_ELECTRIC_IMMERSION,
water_heating_fuel=_STANDARD_ELECTRICITY_FUEL_CODE,
cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L,
cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY,
cylinder_insulation_type=insulation_type_code,
cylinder_insulation_thickness_mm=thickness_mm,
cylinder_thermostat="Y",
)

View file

@ -4032,6 +4032,38 @@ def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_bra
assert got_jan_kwh > 36.9530 # loose jacket loses more than factory
def test_no_water_heating_default_age_a_to_f_uses_12mm_loose_jacket_per_table_29() -> None:
"""RdSAP 10 §10.7 + Table 29 (PDF p.55-56): when no water heating
system is lodged, the default cylinder takes the age-band insulation,
and "Age band of main property A to F: 12 mm loose jacket". Bands
A-F previously raised UnmappedSapCode because the loose-jacket storage
loss branch wasn't plumbed (now it is, S0380.224). A band-B cert must
resolve to a 12 mm loose-jacket cylinder; band G stays 25 mm factory.
"""
from domain.sap10_calculator.rdsap.cert_to_inputs import _apply_rdsap_no_water_heating_system_default # pyright: ignore[reportPrivateUsage]
def _no_dhw_epc(age_band: str) -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[make_building_part(construction_age_band=age_band)],
sap_heating=make_sap_heating(water_heating_code=999),
)
# Act — band B (A-F band) + band G (factory band, regression guard).
band_b = _apply_rdsap_no_water_heating_system_default(_no_dhw_epc("B"))
band_g = _apply_rdsap_no_water_heating_system_default(_no_dhw_epc("G"))
# Assert — band B → 12 mm loose jacket (type 2); band G → 25 mm
# factory (type 1). Both gain the immersion + 110 L cylinder default.
assert band_b.has_hot_water_cylinder is True
assert band_b.sap_heating.cylinder_insulation_type == 2 # loose jacket
assert band_b.sap_heating.cylinder_insulation_thickness_mm == 12
assert band_g.sap_heating.cylinder_insulation_type == 1 # factory
assert band_g.sap_heating.cylinder_insulation_thickness_mm == 25
def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3() -> None:
"""SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary
circuit loss for an indirect cylinder: