From 32a4cf2080ef99653901e0c363eb6533b70d48e4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 14:40:59 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.96:=20RIR=20insulation=20"Unknown?= =?UTF-8?q?"=20thickness=20extractor=20+=20mapper=20(RdSAP=2010=20=C2=A73.?= =?UTF-8?q?10.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../documents_parser/elmhurst_extractor.py | 8 ++- .../tests/test_summary_pdf_mapper_chain.py | 54 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 20 ++++--- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 57703995..0ef362cb 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -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"): diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 790dd889..a512834f 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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": diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 4c0fd872..749acf0f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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)