Slice 56: Elmhurst floor exposed to external air routes through u_exposed_floor

`_is_floor_exposed_to_unheated_space` previously only matched
"U Above unheated space" (semi-exposed floor over a porch / car-park).
Cert 001479 Ext2 §9 lodges "Location: E To external air" — a 1.92 m²
cantilevered exposed timber floor (the upper-storey extension hanging
out over the garden). The worksheet's §3 `Exposed floor Ext2 … 1.92,
1.20, 1.20` pins this surface as U=1.20 via Table 20.

Pre-slice the mapper missed the "external air" lodgement entirely;
`is_exposed_floor=False` routed Ext2's ground SapFloorDimension
through the BS EN ISO 13370 ground-floor cascade (default U≈0.5),
mis-modelling a fully-exposed cantilever as a slab on soil.

Both lodgement strings ("above unheated", "external air") now
trigger the Table 20 path. Function docstring updated; name kept
to minimise the diff (refactor candidate for a future slice).

Cohort 6 certs all still green at 1e-4 (none lodge external-air
floors); cert 001479 cascade SAP 61.90 → 61.93 (+0.03), modest
upward move toward the 69.0094 target.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 22:36:22 +00:00
parent c89206fc7f
commit 07ed871f7b
2 changed files with 31 additions and 5 deletions

View file

@ -286,3 +286,23 @@ def test_summary_001479_main_party_wall_construction_is_cavity_unfilled() -> Non
# Assert
assert epc.sap_building_parts[0].party_wall_construction == 4
def test_summary_001479_ext2_floor_is_exposed_to_external_air() -> None:
# Arrange — cert 001479 Ext2 §9 lodges "Location: E To external air"
# — a cantilevered exposed timber floor (the upper-storey extension
# over the back garden). The worksheet's §3 row `Exposed floor Ext2
# … 1.92, 1.20, 1.20` pins this as U=1.20 via Table 20. Pre-slice the
# mapper only routed "U Above unheated space" through `is_exposed_
# floor=True`; "E To external air" fell through to the BS EN ISO
# 13370 ground-floor cascade, dropping the lodged exposure entirely.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert
ext2 = epc.sap_building_parts[2]
assert ext2.floor_type == "To external air"
assert ext2.sap_floor_dimensions[0].is_exposed_floor is True

View file

@ -1880,11 +1880,17 @@ _UPPER_FLOOR_HEIGHT_ADD_M: float = 0.25
def _is_floor_exposed_to_unheated_space(location: Optional[str]) -> bool:
"""True when the floor sits above an unheated space (lodged by the
Elmhurst surveyor as 'U Above unheated space'). The cascade routes
these through `u_exposed_floor` rather than the BS EN ISO 13370
ground-floor formula."""
return location is not None and "above unheated" in location.lower()
"""True when the floor sits above an unheated space OR is exposed
directly to external air. Both lodgements route through
`u_exposed_floor` (Table 20) rather than the BS EN ISO 13370 ground-
floor formula. Elmhurst lodges either 'U Above unheated space'
(extension over a porch / car-park) or 'E To external air' (cantile-
vered floor of an upper-storey extension, first seen on cert 001479
Ext2 a 1.92 exposed timber floor at U=1.20)."""
if location is None:
return False
lower = location.lower()
return "above unheated" in lower or "external air" in lower
def _extract_age_band(age_range: str) -> str: