mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
cdc7212d18
commit
9458a03021
4 changed files with 82 additions and 0 deletions
|
|
@ -359,12 +359,23 @@ class ElmhurstSiteNotesExtractor:
|
||||||
def _floor_details_from_lines(self, lines: List[str]) -> FloorDetails:
|
def _floor_details_from_lines(self, lines: List[str]) -> FloorDetails:
|
||||||
u_val_raw = self._local_val(lines, "Default U-value")
|
u_val_raw = self._local_val(lines, "Default U-value")
|
||||||
default_u = float(u_val_raw) if u_val_raw else None
|
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(
|
return FloorDetails(
|
||||||
location=self._local_str(lines, "Location"),
|
location=self._local_str(lines, "Location"),
|
||||||
floor_type=self._local_str(lines, "Type"),
|
floor_type=self._local_str(lines, "Type"),
|
||||||
insulation=self._local_str(lines, "Insulation"),
|
insulation=self._local_str(lines, "Insulation"),
|
||||||
u_value_known=self._local_bool(lines, "U-value Known"),
|
u_value_known=self._local_bool(lines, "U-value Known"),
|
||||||
default_u_value=default_u,
|
default_u_value=default_u,
|
||||||
|
insulation_thickness_mm=thickness_mm,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_floor(self) -> FloorDetails:
|
def _extract_floor(self) -> FloorDetails:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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:
|
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
|
# Arrange — RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a
|
||||||
# partially heated space":
|
# partially heated space":
|
||||||
|
|
|
||||||
|
|
@ -3136,6 +3136,17 @@ def _map_elmhurst_building_part(
|
||||||
floor_construction_type=_strip_code(floor.floor_type),
|
floor_construction_type=_strip_code(floor.floor_type),
|
||||||
floor_insulation_type_str=_strip_code(floor.insulation),
|
floor_insulation_type_str=_strip_code(floor.insulation),
|
||||||
floor_u_value_known=floor.u_value_known,
|
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_room_in_roof=room_in_roof,
|
||||||
sap_alternative_wall_1=alt_walls[0],
|
sap_alternative_wall_1=alt_walls[0],
|
||||||
sap_alternative_wall_2=alt_walls[1],
|
sap_alternative_wall_2=alt_walls[1],
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,11 @@ class FloorDetails:
|
||||||
insulation: str # e.g. "A As built"
|
insulation: str # e.g. "A As built"
|
||||||
u_value_known: bool
|
u_value_known: bool
|
||||||
default_u_value: Optional[float] = None
|
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
|
@dataclass
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue