fix(u-value): "Unknown" roof insulation → Table 18 default, not 2.30

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 18:20:18 +00:00
parent 3aed8f858a
commit a64e857b94
2 changed files with 58 additions and 0 deletions

View file

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

View file

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