From 58cff932e68f99167871dde02f59045f5ea976c3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 19:26:03 +0000 Subject: [PATCH] =?UTF-8?q?fix(roof-U):=20flat=20roof,=20undetermined=20th?= =?UTF-8?q?ickness,=20"no/limited=20insulation"=20=E2=86=92=20Table=2018?= =?UTF-8?q?=20age=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flat roof lodged "Flat, no insulation" / "Flat, limited insulation" with an undetermined insulation thickness ('ND'/'AB' → parsed None) was given the Table 16 row-0/12mm U (2.30 / 1.50) from the description marker, regardless of age band. Per RdSAP 10 §5.11.4 (PDF p.44) "U-values in Table 18 are used when thickness of insulation cannot be determined" — the column (3) flat-roof age-band default applies. The "no/limited insulation" text is RdSAP's as-built rendering: at old bands (A-D) the column (3) default IS 2.30 (so those certs are unchanged), but a newer-band flat roof carries the age-band insulation as built (band H = 0.35, F = 0.68, not 2.30). Confirmed by the description-vs-rating audit: cert 0390-2753 (top-floor flat, band H, "Flat, no insulation", thickness 'ND') lodges roof energy_efficiency_rating = 3 (moderate U), NOT the rating-1 that 2.30 implies — and drove a -31.78 SAP error (roof 202 W/K over 88 m²). Same masked-at-old-bands structure as the cavity-U fix: accurate at A-D where the default coincides with 2.30, catastrophic only where it diverges. Pitched roofs are deliberately NOT rerouted (their "no insulation" text is load-bearing — the broad 'ND'→Table-18 reroute was empirically net-negative for pitched lofts). API SAP eval: 52.1% -> 53.1% within 0.5; <1.0 67.2% -> 68.0%; median |err| 0.475 -> 0.467; mean|err| 1.497 -> 1.424; flat-roof bucket within-0.5 23% -> 35% (11 improved, 2 regressed). Co-Authored-By: Claude Opus 4.8 --- domain/sap10_ml/rdsap_uvalues.py | 25 +++++++++++-- domain/sap10_ml/tests/test_rdsap_uvalues.py | 40 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 845a72e2..1f37b222 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -857,9 +857,30 @@ def u_roof( return u if description is not None: desc = description.lower() - if any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS): + no_insulation = any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS) + limited_insulation = any( + marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS + ) + if (no_insulation or limited_insulation) and is_flat_roof and age_band is not None: + # FLAT roof reached here only when insulation_thickness_mm is None + # — the lodged thickness is undetermined ('ND'/'AB'/absent). Per + # RdSAP 10 §5.11.4 (PDF p.44) "U-values in Table 18 are used when + # thickness of insulation cannot be determined", so the column (3) + # flat-roof age-band default applies — NOT the uninsulated 2.30. + # The "no/limited insulation" text is RdSAP's as-built rendering: + # at old bands (A-D) the column (3) default IS 2.30 (so those certs + # are unchanged), but a newer-band flat roof carries the age-band + # insulation as built (band H = 0.35, F = 0.68, not 2.30). The + # roof's deterministic energy_efficiency_rating confirms it (e.g. + # cert 0390-2753 band H lodges rating 3 = moderate U, not the + # rating-1 that 2.30 implies). PITCHED roofs are deliberately NOT + # routed here — their "no insulation" text is load-bearing (the + # broad 'ND'→Table-18 reroute was empirically net-negative for + # pitched lofts). + return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) + if no_insulation: return _ROOF_BY_THICKNESS[0][1] # 2.30 W/m^2K - if any(marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS): + if limited_insulation: return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row) if age_band is None: return 0.4 diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 2d563dd1..5783af45 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -878,6 +878,46 @@ def test_u_roof_unknown_flat_insulation_uses_table18_flat_column() -> None: assert abs(result - 0.35) <= 0.01 +def test_u_roof_flat_no_insulation_undetermined_thickness_uses_table18_by_age() -> None: + # Arrange — a flat roof lodged "Flat, no insulation" / "Flat, limited + # insulation" with an UNDETERMINED thickness (parsed to None from + # 'ND'/'AB') must take the Table 18 column (3) flat-roof age-band + # default per RdSAP 10 §5.11.4 (PDF p.44), NOT the uninsulated 2.30. + # The "no/limited insulation" text is RdSAP's as-built rendering — at + # old bands the column (3) default IS 2.30 (so they're unchanged), but + # a newer-band flat roof carries the age-band insulation as built. + # Cert 0390-2753 (top-floor flat, band H, "Flat, no insulation", + # thickness 'ND', roof rating 3 = moderate) drove a -31.78 SAP error at + # the 2.30 value; band H column (3) = 0.35. + + # Act — band H "no insulation" → 0.35; band F "limited insulation" → 0.68; + # band C "no insulation" → unchanged 2.30 (column (3) default at C). + band_h = u_roof( + country=Country.ENG, age_band="H", insulation_thickness_mm=None, + description="Flat, no insulation", is_flat_roof=True, + ) + band_f = u_roof( + country=Country.ENG, age_band="F", insulation_thickness_mm=None, + description="Flat, limited insulation", is_flat_roof=True, + ) + band_c = u_roof( + country=Country.ENG, age_band="C", insulation_thickness_mm=None, + description="Flat, no insulation", is_flat_roof=True, + ) + # A PITCHED roof "no insulation" with undetermined thickness is NOT + # rerouted — its text is load-bearing (2.30 stays). + pitched = u_roof( + country=Country.ENG, age_band="H", insulation_thickness_mm=None, + description="Pitched, no insulation", is_flat_roof=False, + ) + + # Assert + assert abs(band_h - 0.35) <= 0.01 + assert abs(band_f - 0.68) <= 0.01 + assert abs(band_c - 2.30) <= 0.01 + assert abs(pitched - 2.30) <= 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.