From a64e857b942cd9ead96f70e52781d96e41221d19 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 18:20:18 +0000 Subject: [PATCH] =?UTF-8?q?fix(u-value):=20"Unknown"=20roof=20insulation?= =?UTF-8?q?=20=E2=86=92=20Table=2018=20default,=20not=202.30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A roof lodged "Unknown loft insulation" carries roof_insulation_thickness "NI" (Not Indicated → parsed to 0) or "ND" (None): the thickness is UNDETERMINED, not zero. RdSAP 10 §5.11.4 (p.44) is deterministic here — "U-values in Table 18 are used when thickness of insulation cannot be determined" — so the roof takes the Table 18 age-band default (column (1) pitched / column (3) flat), NOT the uninsulated 2.30 the Table 16 row-0 lookup returns for a parsed-0 thickness. The "Unknown" text is RdSAP's rendering of the undetermined-thickness observation, distinct from a genuine "no insulation" lodgement (which keeps 2.30). u_roof gains an "unknown"-description branch ahead of the parsed-0 → 2.30 path, gated on undetermined thickness (None or 0). Top-floor flats with "Pitched/Flat, Unknown ... insulation" were the worst electric-flat under-raters: roof U=2.30 gave HLP ~3.7 on dwellings rated SAP 69-70. Cluster (14 certs, roof desc contains "unknown", no "no insulation"): mean |err| 7.79 → 1.82, within-0.5 1→4, within-1.0 1→6. Cert 9836 roof_w_per_k 58.2→10.1, SAP -27.8 → -3.5. Eval headline 44.4% → 44.8%, mean |err| 1.944 → 1.851. Two certs overshoot (other residuals the wrong roof-U was masking); the spec value is applied uniformly regardless. Co-Authored-By: Claude Opus 4.8 --- domain/sap10_ml/rdsap_uvalues.py | 18 ++++++++++ domain/sap10_ml/tests/test_rdsap_uvalues.py | 40 +++++++++++++++++++++ 2 files changed, 58 insertions(+) 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.