Slice 57: Pre-1950 Elmhurst sloping-ceiling roofs map to thickness=0

Cert 001479 Ext2 §8 lodges:
  Type: PS Pitched, sloping ceiling
  Insulation: S Sloping ceiling insulation
  Insulation Thickness: As Built
  age C (1930-49)

The Summary's "As Built" thickness encodes "the dwelling as originally
constructed" — for pre-1950 sloping-ceiling roofs that's uninsulated
(no roof insulation in original 1930s construction). The worksheet's
§3 row pins U=2.30 (Table 16 row 0, uninsulated).

Pre-slice the mapper passed thickness=None through, routing to
`u_roof`'s Table 18 col 1 default (0.40 W/m²K for age C). That table
assumes joist insulation accessible from the loft — wrong geometry for
PS (Pitched, sloping ceiling) which has no loft access for retrofit.

Add `_resolve_sloping_ceiling_thickness`: when roof_type starts with
"PS" + lodged thickness is None + age ∈ {A,B,C,D} → thickness=0.
Other ages leave None (cascade default), matching Ext1's worksheet
U=0.15 at age M.

Cascade SAP 61.93 → 61.39 (−0.54, expected — uninsulated roof adds
heat loss); cohort 6 certs all green at 1e-4 (none have PS+age≤D);
pyright net-zero baseline preserved.

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

View file

@ -306,3 +306,24 @@ def test_summary_001479_ext2_floor_is_exposed_to_external_air() -> None:
ext2 = epc.sap_building_parts[2]
assert ext2.floor_type == "To external air"
assert ext2.sap_floor_dimensions[0].is_exposed_floor is True
def test_summary_001479_ext2_sloping_ceiling_roof_uninsulated_for_pre_1950() -> None:
# Arrange — cert 001479 Ext2 §8 lodges "Type: PS Pitched, sloping
# ceiling" + "Insulation Thickness: As Built" + age band C (1930-49).
# Original 1930s construction had no sloping-ceiling insulation;
# worksheet §3 `External roof Ext2 … 2.30` pins U=2.30 (uninsulated
# Table 16 row 0). Pre-slice the mapper passed thickness=None through,
# routing to `u_roof`'s pitched-roof Table 18 col 1 default (0.40 for
# age C, assumes loft-joist retrofit) — wrong geometry for PS.
# Ext1's PS roof at age M leaves thickness=None (modern build,
# cascade default U=0.15 matches worksheet).
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
assert epc.sap_building_parts[2].roof_insulation_thickness == 0
assert epc.sap_building_parts[1].roof_insulation_thickness is None

View file

@ -1879,6 +1879,33 @@ def _elmhurst_wall_insulation_int(coded: str) -> Optional[int]:
_UPPER_FLOOR_HEIGHT_ADD_M: float = 0.25
# Pre-1950 age bands lodged with an "As Built" sloping-ceiling roof get
# their original-construction U-value (uninsulated) — original 1930-49
# construction had no sloping-ceiling insulation by default. RdSAP10
# Table 18 col 1 (the pitched-roof default behind `u_roof`) assumes
# joist insulation accessible from the loft, which doesn't apply to PS
# sloping-ceiling roofs; without an explicit thickness override the
# cascade would understate U for this geometry. Cert 001479 Ext2's
# worksheet row `External roof Ext2 … 2.22, 2.30` pins U=2.30 at this
# age + geometry combination.
_PRE_1950_AGE_CODES: Final[frozenset[str]] = frozenset({"A", "B", "C", "D"})
def _resolve_sloping_ceiling_thickness(
roof: ElmhurstRoofDetails, age_code: str,
) -> Optional[int]:
"""Map an Elmhurst sloping-ceiling roof (`PS Pitched, sloping ceiling`)
with no lodged thickness ("As Built") to an explicit 0 mm for pre-
1950 age bands. Other lodgements pass through unchanged."""
if roof.insulation_thickness_mm is not None:
return roof.insulation_thickness_mm
if not roof.roof_type.startswith("PS"):
return None
if age_code in _PRE_1950_AGE_CODES:
return 0
return None
def _is_floor_exposed_to_unheated_space(location: Optional[str]) -> bool:
"""True when the floor sits above an unheated space OR is exposed
directly to external air. Both lodgements route through
@ -2118,9 +2145,10 @@ def _map_elmhurst_building_part(
]
while len(alt_walls) < 2:
alt_walls.append(None)
age_code = _leading_code(age_band)
return SapBuildingPart(
identifier=identifier,
construction_age_band=_leading_code(age_band),
construction_age_band=age_code,
wall_construction=_elmhurst_wall_construction_int(walls.wall_type),
wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation),
wall_thickness_measured=not walls.thickness_unknown,
@ -2128,7 +2156,9 @@ def _map_elmhurst_building_part(
sap_floor_dimensions=floor_dims,
wall_thickness_mm=walls.thickness_mm,
roof_insulation_location=_strip_code(roof.insulation),
roof_insulation_thickness=roof.insulation_thickness_mm,
roof_insulation_thickness=_resolve_sloping_ceiling_thickness(
roof, age_code,
),
floor_type=_strip_code(floor.location),
floor_construction_type=_strip_code(floor.floor_type),
floor_insulation_type_str=_strip_code(floor.insulation),