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.