fix(roof-U): flat roof, undetermined thickness, "no/limited insulation" → Table 18 age default

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 19:26:03 +00:00
parent 898dcfda18
commit 58cff932e6
2 changed files with 63 additions and 2 deletions

View file

@ -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

View file

@ -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.