diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 44c7e79d..4dc8a40e 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -837,6 +837,24 @@ def u_roof( # ("Average thermal transmittance X W/m²K"); spec §5.11 opening # clause defers to the assessor's value when present. return measured + if ( + age_band is not None + and description is not None + and "unknown" in description.lower() + and (insulation_thickness_mm is None or insulation_thickness_mm == 0) + ): + # RdSAP 10 §5.11.4 (page 44): "U-values in Table 18 are used when + # thickness of insulation cannot be determined." A roof lodged + # "Unknown loft insulation" carries thickness "NI" (Not Indicated, + # parsed to 0) or "ND" (None) — the thickness is UNDETERMINED, not + # zero — so it takes the Table 18 age-band default (column (1) + # pitched / column (3) flat), NOT the uninsulated 2.30 the Table 16 + # row-0 lookup would give for a parsed-0 thickness. Distinct from a + # genuine "no insulation" lodgement, which keeps 2.30 (below). The + # discriminator is the deterministic "Unknown" text RdSAP renders + # for an undetermined-thickness observation. + table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else _ROOF_BY_AGE + return table_18.get(age_band.upper(), 0.4) if ( is_sloping_ceiling and age_band is not None diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 00cf7164..bc06a68c 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -821,6 +821,46 @@ def test_u_roof_ni_thickness_with_no_insulation_description_stays_at_2_30() -> N assert result == pytest.approx(2.30, abs=0.01) +def test_u_roof_unknown_loft_insulation_uses_table18_default_per_section_5_11_4() -> None: + # Arrange — "Pitched, Unknown loft insulation" lodges + # roof_insulation_thickness 'NI' (Not Indicated, parsed to 0) — the + # thickness is UNDETERMINED, not zero. RdSAP 10 §5.11.4 (page 44): + # "U-values in Table 18 are used when thickness of insulation cannot + # be determined." So a pitched roof takes the Table 18 column (1) + # age-band default (age A = 0.40), NOT the uninsulated 2.30 the + # Table 16 row-0 lookup gives for a parsed-0 thickness. Cert + # 9836-5829-1500-0803-7206 (top-floor flat, age A). + + # Act + result = u_roof( + country=Country.ENG, + age_band="A", + insulation_thickness_mm=0, # parsed from "NI" + description="Pitched, Unknown loft insulation", + ) + + # Assert + assert abs(result - 0.40) <= 0.01 + + +def test_u_roof_unknown_flat_insulation_uses_table18_flat_column() -> None: + # Arrange — an "Unknown" flat-roof lodgement with no determinable + # thickness (None) takes Table 18 column (3) "Flat roof" age-band + # default (age H = 0.35), per §5.11.4 — not 2.30. + + # Act + result = u_roof( + country=Country.ENG, + age_band="H", + insulation_thickness_mm=None, + description="Flat, Unknown insulation", + is_flat_roof=True, + ) + + # Assert + assert abs(result - 0.35) <= 0.01 + + def test_u_roof_age_band_j_pitched_returns_table18_value() -> None: # Arrange — Table 18, pitched insulation between joists, age J -> 0.16 W/m^2K.