Slice S0380.97: Floor "Insulation Thickness" extractor + mapper (RdSAP 10 §5.13 Table 20)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 14:50:39 +00:00 committed by Jun-te Kim
parent cdc7212d18
commit 9458a03021
4 changed files with 82 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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