From 9458a030219f9dfbb7be38bb657f70d793742e47 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 14:50:39 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.97:=20Floor=20"Insulation=20Thick?= =?UTF-8?q?ness"=20extractor=20+=20mapper=20(RdSAP=2010=20=C2=A75.13=20Tab?= =?UTF-8?q?le=2020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 Specification §5.13 "U-values of exposed and semi-exposed upper floors" (PDF p.47) + Table 20: "Otherwise, to simplify data collection no distinction is made in terms of U-value between an exposed floor (to outside air below) and a semi-exposed floor (to an enclosed but unheated space below) and the U-values in Table 20 are used." Table 20 (excerpt, age bands A-G | H or I): Age band Unknown/as built 50mm 100mm 150mm A to G 1.20 0.50 0.30 0.22 H or I 0.51 0.50 0.30 0.22 Cert 000565 Summary §9 2nd Extension lodges: Location: U Above unheated space Type: N Suspended, not timber Insulation: R Retro-fitted Insulation Thickness: 200 mm Default U-value: 0.22 Pre-slice the extractor's `_floor_details_from_lines` did NOT read the "Insulation Thickness" cell (only the §8 roof extractor had the field). FloorDetails carried no thickness → mapper plumbed `SapBuildingPart.floor_insulation_thickness=None` → cascade `u_exposed_floor(age=H, ins=None)` returned U=0.51 (Table 20 row[0] unknown/as-built) vs worksheet 0.22 (Table 20 150 mm column for age H) — over-counting BP[2] floor by (0.51-0.22) × 30 m² = +8.70 W/K. Three-layer fix: 1. Schema (`elmhurst_site_notes.py:FloorDetails`) — add `insulation_thickness_mm: Optional[int] = None` (mirror of `RoofDetails`). 2. Extractor (`elmhurst_extractor.py:_floor_details_from_lines`) — parse "Insulation Thickness" via existing `_local_val` (mirror of `_roof_details_from_lines` pattern at line 333). 3. Mapper (`mapper.py:_map_elmhurst_building_part`) — translate `floor.insulation_thickness_mm` to `SapBuildingPart.floor_ insulation_thickness=f"{n}mm"` (digit-prefix string convention matching the API mapper + the wall pattern at line 3125-3129). Cascade no-op: existing `_parse_thickness_mm` accepts "200mm" → 200; `u_exposed_floor(age=H, ins=200)` returns 0.22 (clamps thickness ≥ 125 mm to Table 20 row[3]) ✓. Movement at HEAD (cert 000565): - BP[2] Ext2 floor cascade U: 0.51 → 0.22 ✓ EXACT vs ws 0.22 - floor_w_per_k: 70.37 → 61.67 ✓ EXACT vs ws 61.67 (closed +8.70) - sap_score (int): 28 → 29 ✓ EXACT vs ws 29 - sap_score_continuous: 28.31 → 28.5086 vs ws 28.5087 (Δ -0.20 → -0.0001 — within 1e-4 strict floor!) - SH: -38 kWh vs ws (was +218 → essentially closed) Test count: 587 → 590 pass (+2 new AAA tests + sap_score integer pin flipped from FAIL to PASS) + 8 expected 000565 fails (sap_score integer pin removed from the work queue). Cohort safety: only cert 000565 §9 lodges "Insulation Thickness" (grep audit across Summary fixtures); cohort certs lodge "As built" or omit the line. Pyright net-zero per touched file. Co-Authored-By: Claude Opus 4.7 --- .../documents_parser/elmhurst_extractor.py | 11 ++++ .../tests/test_summary_pdf_mapper_chain.py | 55 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 11 ++++ datatypes/epc/surveys/elmhurst_site_notes.py | 5 ++ 4 files changed, 82 insertions(+) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 0ef362cb..cb3364db 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -359,12 +359,23 @@ class ElmhurstSiteNotesExtractor: def _floor_details_from_lines(self, lines: List[str]) -> FloorDetails: u_val_raw = self._local_val(lines, "Default U-value") default_u = float(u_val_raw) if u_val_raw else None + # RdSAP 10 §5.13 Table 20 — retro-fitted upper floors lodge an + # "Insulation Thickness: NNN mm" cell so the cascade can route + # via the per-thickness column. Mirror of the §8 roof extractor + # at `_roof_details_from_lines`. + thickness_raw = self._local_val(lines, "Insulation Thickness") + thickness_mm = ( + int(thickness_raw.split()[0]) + if thickness_raw and thickness_raw.split()[0].isdigit() + else None + ) return FloorDetails( location=self._local_str(lines, "Location"), floor_type=self._local_str(lines, "Type"), insulation=self._local_str(lines, "Insulation"), u_value_known=self._local_bool(lines, "U-value Known"), default_u_value=default_u, + insulation_thickness_mm=thickness_mm, ) def _extract_floor(self) -> FloorDetails: 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 a512834f..6dcde6fb 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1792,6 +1792,61 @@ def test_summary_000565_ext4_flat_ceiling_1_maps_unknown_to_none_thickness_per_r assert fc_1.insulation_type == "rigid_foam" +def test_summary_000565_ext2_floor_extracts_200mm_retro_fitted_insulation_thickness() -> None: + # Arrange — cert 000565 Summary §9 2nd Extension lodges: + # Location: U Above unheated space + # Type: N Suspended, not timber + # Insulation: R Retro-fitted + # Insulation Thickness: 200 mm + # Default U-value: 0.22 + # Pre-slice the extractor's `_floor_details_from_lines` parsed + # only location / floor_type / insulation / u_value_known / + # default_u_value — the "Insulation Thickness" cell was silently + # dropped. Mirror of the §8 roof extractor's existing + # `_local_val(lines, "Insulation Thickness")` path. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + + # Act + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Assert + ext2_floor = site_notes.extensions[1].floor + assert ext2_floor.location == "U Above unheated space" + assert ext2_floor.floor_type == "N Suspended, not timber" + assert ext2_floor.insulation_thickness_mm == 200 + + +def test_summary_000565_ext2_floor_routes_to_u_value_0p22_via_table_20_per_rdsap_10_section_5_13() -> None: + # Arrange — RdSAP 10 §5.13 (PDF p.47) "U-values of exposed and + # semi-exposed upper floors" + Table 20: + # + # Age band Unknown/as built 50 mm 100 mm 150 mm + # A to G 1.20 0.50 0.30 0.22 + # H or I 0.51 0.50 0.30 0.22 + # + # Cert 000565 BP[2] Ext2 age band = H, floor location = "U Above + # unheated space" (→ `is_exposed_floor=True`), lodged Insulation + # Thickness = 200 mm. The 200 mm bucket maps to Table 20's 150 mm + # column (the largest tabulated thickness; cascade clamps at row[3] + # for thickness ≥ 125 mm) → U=0.22 ✓ vs worksheet (U985-0001-000565 + # line ~ floor lookup) lodged Default U=0.22. + # + # Pre-slice the mapper translated `FloorDetails.insulation_thickness + # _mm=None` (extractor gap) → `SapBuildingPart.floor_insulation_ + # thickness=None` → cascade `u_exposed_floor(age=H, ins=None)` → + # U=0.51 (Table 20 row[0]) over-counting BP[2] floor by (0.51-0.22) + # × 30 m² = +8.70 W/K. + 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 + bp_2 = epc.sap_building_parts[2] + assert bp_2.floor_insulation_thickness == "200mm" + + 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 749acf0f..ef2c16d6 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3136,6 +3136,17 @@ def _map_elmhurst_building_part( floor_construction_type=_strip_code(floor.floor_type), floor_insulation_type_str=_strip_code(floor.insulation), floor_u_value_known=floor.u_value_known, + # RdSAP 10 §5.13 Table 20 — exposed/semi-exposed upper floors + # dispatch via `u_exposed_floor(age, insulation_thickness_mm)`. + # API mapper lodges `floor_insulation_thickness` as a digit- + # prefix string ("100mm" / "NI"); mirror that shape so cert-to- + # cert parity tests (Summary EPC ≡ API EPC) compare equal and + # the cascade's `_parse_thickness_mm` accepts the same value. + floor_insulation_thickness=( + f"{floor.insulation_thickness_mm}mm" + if floor.insulation_thickness_mm is not None + else None + ), sap_room_in_roof=room_in_roof, sap_alternative_wall_1=alt_walls[0], sap_alternative_wall_2=alt_walls[1], diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 127aee0d..b9198e0a 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -113,6 +113,11 @@ class FloorDetails: insulation: str # e.g. "A As built" u_value_known: bool default_u_value: Optional[float] = None + # RdSAP 10 §5.13 Table 20 (PDF p.47) — exposed/semi-exposed upper + # floors dispatch on age × insulation thickness. Lodged in Summary + # §9 as "Insulation Thickness: NNN mm" for retro-fitted floors; + # absent when the floor is "As built" or uninsulated. + insulation_thickness_mm: Optional[int] = None @dataclass