mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
b0a47cda05
commit
f3dcd7b43e
2 changed files with 68 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue