Slice S0380.96: RIR insulation "Unknown" thickness extractor + mapper (RdSAP 10 §3.10.1)

RdSAP 10 Specification §3.10.1 (PDF p.24) "Default U-values of the
roof rooms":

  "Where the details of insulation are not available, the default
   U-values are those for the appropriate age band for the
   construction of the roof rooms (see Table 18 : Assumed roof
   U-values when Table 16 or Table 17 do not apply). The default
   U-values apply when the roof room insulation is 'as built' or
   'unknown'."

Cert 000565 Summary §8.1 BP[4] Ext4 lodges:
  Flat Ceiling 1   5.00   1.00   Unknown   PUR or PIR   0.15   No
Worksheet line (30): `Roof room Ext4 Flat Ceiling 1: 5 × 0.15 =
0.75 W/K` (U985-0001-000565 line 333).

Pre-slice the extractor allow-list `_RIR_INSULATION_THICKNESS_RE
| ("As Built", "None")` did NOT include the "Unknown" thickness
token, so the cell was dropped (`insulation = ""`). The mapper
translated `""` to `insulation_thickness_mm=0`, and the cascade
hit Table 17 row 0 → U=2.30 vs worksheet 0.15 (over-counting
BP[4] FC1 by +10.75 W/K on a 5 m² ceiling).

Two-layer fix:

1. Extractor (`elmhurst_extractor.py:_parse_rir_surface_row`) — add
   "Unknown" as the third spec-valid thickness token alongside
   "As Built" and "None".
2. Mapper (`mapper.py:_elmhurst_rir_insulation_thickness_mm`) —
   return `Optional[int]`; "Unknown" → None. The cascade's existing
   `_u_rr_table_17` already falls back to `u_rr_default_all_elements`
   (Table 18 col 4) when thickness is None — for cert 000565 BP[4]
   age band M, returns 0.15 W/m²K ✓.

Cascade no-op: the existing None → Table 18 col 4 fallback IS the
spec-correct path per §3.10.1; no calculator changes needed.

Movement at HEAD (cert 000565):
- BP[4] FC1 cascade U: 2.30 → 0.15 ✓ EXACT vs ws 0.15
- roof_w_per_k: 63.72 → 52.97 (Δ +12.34 → +1.59, closed -10.75)
- sap_score_continuous: 28.07 → 28.31 (Δ -0.44 → -0.20)
- sap_score (int): 28 (continuous still below 28.5 threshold;
  remaining residual + BP[1] residual + BP[2] floor)
- SH: +533 → +218 kWh

Test count: 585 → 587 pass (+2 new AAA tests) + 9 expected 000565
fails unchanged.

Cohort safety: "Unknown" RIR insulation appears only in cert 000565
across the Summary fixture set (grep audit); cohort certs lodge
concrete thickness or "None"/"As Built". Pyright net-zero per
touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 14:40:59 +00:00
parent 25f3af9eba
commit 32a4cf2080
3 changed files with 75 additions and 7 deletions

View file

@ -530,7 +530,13 @@ class ElmhurstSiteNotesExtractor:
insulation_type: Optional[str] = None
gable_type: Optional[str] = None
for t in middle:
if self._RIR_INSULATION_THICKNESS_RE.match(t) or t in ("As Built", "None"):
if self._RIR_INSULATION_THICKNESS_RE.match(t) or t in ("As Built", "None", "Unknown"):
# "Unknown" is the third spec-valid thickness token
# (RdSAP 10 §3.10.1 PDF p.24: "default U-values apply
# when the roof room insulation is 'as built' or
# 'unknown'"). Mapper routes "Unknown" to
# insulation_thickness_mm=None so the cascade falls
# back to Table 18 col 4 default.
if not insulation:
insulation = t
elif t in ("Mineral or EPS", "PUR", "PIR", "PUR or PIR"):

View file

@ -1738,6 +1738,60 @@ def test_summary_000565_ext2_stud_wall_2_routes_to_400mm_rigid_foam_via_mapper()
assert sw_2.insulation_type == "rigid_foam"
def test_summary_000565_ext4_flat_ceiling_1_extracts_unknown_thickness_pur_or_pir_lodgement() -> None:
# Arrange — cert 000565 Summary §8.1 BP[4] Ext4 lodges:
# "Flat Ceiling 1 5.00 1.00 Unknown PUR or PIR 0.15 No"
# Worksheet line (30): `Roof room Ext4 Flat Ceiling 1: 5 × 0.15
# = 0.75 W/K` (U985-0001-000565 line 333). Pre-slice the extractor
# allow-list `_RIR_INSULATION_THICKNESS_RE | ("As Built", "None")`
# did NOT include the "Unknown" thickness token, so the cell was
# dropped (`insulation = ""`). Mapper translated `""` to
# `insulation_thickness_mm=0`, cascade hit Table 17 row 0 → U=2.30
# vs worksheet 0.15 (over by +10.75 W/K on a 5 m² ceiling).
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
# Act
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Assert
ext4_rir = site_notes.extensions[3].room_in_roof
assert ext4_rir is not None
flat_ceiling_1 = next(s for s in ext4_rir.surfaces if s.name == "Flat Ceiling 1")
assert flat_ceiling_1.insulation == "Unknown"
assert flat_ceiling_1.insulation_type == "PUR or PIR"
def test_summary_000565_ext4_flat_ceiling_1_maps_unknown_to_none_thickness_per_rdsap_10_section_3_10_1() -> None:
# Arrange — RdSAP 10 §3.10.1 (PDF p.24) "Default U-values of the
# roof rooms":
# "Where the details of insulation are not available, the default
# U-values are those for the appropriate age band for the
# construction of the roof rooms (see Table 18 : Assumed roof
# U-values when Table 16 or Table 17 do not apply). The default
# U-values apply when the roof room insulation is 'as built' or
# 'unknown'."
# Translation: when Summary lodges "Unknown" thickness (regardless
# of named insulation material), the mapper must set
# `insulation_thickness_mm=None` (not 0). The cascade's existing
# `_u_rr_table_17` falls back to `u_rr_default_all_elements`
# (Table 18 col 4) → for cert 000565 BP[4] age band M, returns
# 0.15 W/m²K ✓ matching the worksheet.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert
ext4_rir = epc.sap_building_parts[4].sap_room_in_roof
assert ext4_rir is not None
detailed = ext4_rir.detailed_surfaces or []
flat_ceilings = [s for s in detailed if s.kind == "flat_ceiling"]
fc_1 = next(s for s in flat_ceilings if s.area_m2 == 5.0)
assert fc_1.insulation_thickness_mm is None
assert fc_1.insulation_type == "rigid_foam"
def test_summary_000565_ext1_floor_above_partially_heated_routes_to_u_value_0p7_per_rdsap_10_section_5_14() -> None:
# Arrange — RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a
# partially heated space":

View file

@ -3269,13 +3269,21 @@ def _round_half_up_2dp(*operands: float) -> float:
return float(product.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def _elmhurst_rir_insulation_thickness_mm(insulation_text: str) -> int:
def _elmhurst_rir_insulation_thickness_mm(insulation_text: str) -> Optional[int]:
"""Translate the Insulation cell ("100 mm", "400+ mm", "None", "As
Built", "") into a thickness integer. The Elmhurst cohort uses "As
Built" only on surfaces whose Default U-value is the uninsulated
2.30 row, so treating it as 0 mm is consistent with the Table 17
'none' column. The "400+ mm" bucket-cap (Table 17's largest
tabulated row) is read as 400."""
Built", "Unknown", "") into a thickness value. The Elmhurst cohort
uses "As Built" only on surfaces whose Default U-value is the
uninsulated 2.30 row, so treating it as 0 mm is consistent with
the Table 17 'none' column. The "400+ mm" bucket-cap (Table 17's
largest tabulated row) is read as 400.
"Unknown" returns None per RdSAP 10 §3.10.1 (PDF p.24): "default
U-values apply when the roof room insulation is 'as built' or
'unknown'". The cascade's `_u_rr_table_17` falls back to
`u_rr_default_all_elements` (Table 18 col 4) when the thickness
is None so the spec's age-band default applies."""
if insulation_text == "Unknown":
return None
if not insulation_text or insulation_text in ("None", "As Built"):
return 0
m = re.match(r"^(\d+)\+?\s*mm$", insulation_text)