diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5ab3334d..21619ed4 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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", ) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 899a99f5..3be0ea4f 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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: