fix(elmhurst-mapper): single-storey flat with exposed roof is Top-floor, not Ground-floor

The Elmhurst dwelling-type classifier keyed "Top-floor flat" on a "dwelling
below" floor lodgement. A single-storey flat exposed BOTH top (a real
external roof) AND bottom (floor over partially-heated space, no dwelling
below) therefore fell through to "Ground-floor flat" — which the cascade's
_dwelling_exposure maps to has_exposed_roof=False, dropping the external
roof entirely.

Surfaced by simulated case 34 (cert 001431 reconfigured as a slimline
electric-storage flat): the worksheet bills (30) External roof = 39.98 m²
x U=2.30 = 91.95 W/K — the dominant heat-loss element — but the cascade
dropped it, under-stating space-heating demand by 42% (6550 vs 11357
kWh/yr) and over-predicting SAP by +21.76 (57.07 vs worksheet 35.31).

Fix: an exposed (non-party) roof puts the flat on the top storey
regardless of what is below it. Classify as "Top-floor flat" whenever the
roof is exposed; the flat's exposed floor is recovered downstream by the
existing per-BP is_above_partially_heated_space / is_exposed_floor override
in heat_transmission (§3). Party-roof flats ("another dwelling above") are
unaffected and stay Ground-/Mid-floor.

This is an Elmhurst-mapper (dwelling_type) bug, NOT a calculator bug: the
calculator correctly trusts dwelling_type, and the gov-API path supplies
the position directly (cert 0036 — a genuine ground-floor flat whose API
data lodges a "Pitched, no access" roof construction under another dwelling
— stays party, 2.51 W/K). API SAP gauge unchanged (57.6% within 0.5);
worksheet harness 47/47 unaffected; case 34 roof now exact (residual -1.61
is a separate flat-corridor wall-U thread). Regression gate green (3
pre-existing fails unrelated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 07:23:56 +00:00
parent b0a47cda05
commit f3dcd7b43e
2 changed files with 68 additions and 1 deletions

View file

@ -42,9 +42,14 @@ from datatypes.epc.domain.mapper import (
EpcPropertyDataMapper,
UnmappedApiCode,
UnmappedElmhurstLabel,
_elmhurst_dwelling_type, # pyright: ignore[reportPrivateUsage]
_elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage]
_elmhurst_immersion_type_code, # pyright: ignore[reportPrivateUsage]
)
from datatypes.epc.surveys.elmhurst_site_notes import (
FloorDetails as ElmhurstFloorDetails,
RoofDetails as ElmhurstRoofDetails,
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
@ -1540,6 +1545,59 @@ def test_summary_mapper_raises_on_unmapped_cylinder_insulation_label() -> None:
assert excinfo.value.value == "Polyester wool"
def test_elmhurst_dwelling_type_single_storey_flat_with_exposed_roof_is_top_floor() -> None:
# Arrange — a single-storey flat exposed BOTH top (external pitched
# roof, access to loft) AND bottom (floor over partially-heated space,
# not another dwelling) — simulated case 34 (cert 001431). Pre-fix the
# absence of a "dwelling below" routed it to "Ground-floor flat", which
# the cascade's `_dwelling_exposure` maps to has_exposed_roof=False,
# dropping the 91.95 W/K external roof (+21.76 SAP over-prediction). An
# exposed (non-party) roof means the flat is on the top storey →
# "Top-floor flat"; its exposed floor is recovered downstream by the
# per-BP is_above_partially_heated_space override.
roof = ElmhurstRoofDetails(
roof_type="PA Pitched (slates/tiles), access to loft",
insulation="N None", u_value_known=False,
)
floor = ElmhurstFloorDetails(
location="P Above partially heated space",
floor_type="", insulation="A As built", u_value_known=False,
)
# Act
result = _elmhurst_dwelling_type(
built_form="Mid-Terrace", property_type="Flat",
floor=floor, roof=roof, room_in_roof=None,
)
# Assert
assert result == "Top-floor flat"
def test_elmhurst_dwelling_type_party_roof_flat_stays_ground_floor() -> None:
# Arrange — a genuine ground-floor flat with a dwelling ABOVE lodges a
# party roof ("A Another dwelling above") + a real ground floor. It must
# stay "Ground-floor flat" (roof party, floor exposed) — the fix must
# not over-promote party-roof flats to top-floor.
roof = ElmhurstRoofDetails(
roof_type="A Another dwelling above",
insulation="N None", u_value_known=False,
)
floor = ElmhurstFloorDetails(
location="G Ground floor",
floor_type="", insulation="A As built", u_value_known=False,
)
# Act
result = _elmhurst_dwelling_type(
built_form="Mid-Terrace", property_type="Flat",
floor=floor, roof=roof, room_in_roof=None,
)
# Assert
assert result == "Ground-floor flat"
def test_elmhurst_glazing_type_code_strips_interleaved_alternative_wall() -> None:
# Arrange — when a property lodges an Alternative Wall (cert 001431
# storage-heater variants, "simulated case 34"), pdftotext interleaves

View file

@ -2368,7 +2368,16 @@ def _elmhurst_dwelling_type(
floor_loc = (floor.location if floor is not None else "") or ""
has_dwelling_below = "dwelling below" in floor_loc.lower()
has_exposed_roof = room_in_roof is not None or _elmhurst_roof_is_exposed(roof)
if has_dwelling_below and has_exposed_roof:
# An exposed (non-party) roof puts the flat on the TOP storey, whether
# or not a dwelling sits below it. A single-storey flat exposed both top
# (external roof) and bottom (floor over partially-heated space, no
# dwelling below) is still top-floor for roof purposes — its exposed
# floor is recovered by the per-BP is_above_partially_heated_space /
# is_exposed_floor override in §3. Keying "Top-floor" on
# has_dwelling_below dropped that roof, routing such flats to
# "Ground-floor flat" → has_exposed_roof=False → no roof heat loss
# (simulated case 34, cert 001431: 91.95 W/K roof dropped, +21.76 SAP).
if has_exposed_roof:
position = "Top-floor"
elif has_dwelling_below:
position = "Mid-floor"