Merge pull request #1345 from Hestia-Homes/fix/storage-heater-space-heating-demand

fix(sap): expose roof for mislabelled top-floor flats via roof_insulation_location
This commit is contained in:
KhalimCK 2026-06-29 11:18:03 +01:00 committed by GitHub
commit 31e8b93ea0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 143 additions and 7 deletions

View file

@ -52,7 +52,7 @@ from __future__ import annotations
import math
from dataclasses import dataclass, replace
from decimal import ROUND_HALF_UP, Decimal
from typing import Callable, Final, Literal, Optional
from typing import Callable, Final, Literal, Optional, Union
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
@ -1642,7 +1642,62 @@ from domain.sap10_calculator.exceptions import (
def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure:
def _roof_insulation_location_is_determined(
value: Optional[Union[int, str]]
) -> bool:
"""Whether a building part lodges a *determined* roof-insulation
location an RdSAP integer code (1-7: at-rafters, loft, flat-roof, ),
meaning the unit has an exposed roof to insulate. The gov-EPC API lodges
the string "ND" (Not Defined) when there is no exposed roof: the ceiling
is a party surface (another dwelling above), so there is nowhere to put
roof insulation. `None`/empty is likewise "no signal".
"""
if isinstance(value, int):
return True
if isinstance(value, str):
return value.strip().upper() not in ("", "ND")
return False
def _cert_lodges_exposed_roof(parts: list[SapBuildingPart]) -> bool:
"""Whether the main building part lodges a genuine exposed (heat-loss)
roof keyed on the structured `roof_insulation_location` field, NOT a
description string or `roof_construction`.
The gov-EPC API lodges the *building's* `roof_construction` on every
unit (incl. mid-floor ones whose ceiling is party), so it is not a
per-unit exposure signal. `roof_insulation_location`, by contrast, is
"ND" (Not Defined) exactly when the unit's ceiling is a party surface
(no roof to insulate) and a real RdSAP location code when the roof is
exposed. On the RdSAP-21.0.1 corpus this separates the two classes with
zero disagreement: all 190 party-ceiling flats lodge "ND"; every
mid/ground-floor flat with a determined location is genuinely top-storey.
Motivating cases (gov-API certs lodge `dwelling_type` as the raw
assessor label, which can contradict the fabric): property 715363
(uprn 6027561, location code 6 = flat roof) + sibling 715395 lodge
"Mid-floor flat" yet have their own exposed roof over a dwelling below
top-floor flats mislabelled mid-floor; the correctly labelled
top-floor sibling 715871 (same block, same roof) computes the lodged
SAP 74. Dropping the roof under-read space-heating demand ~32% /
over-read SAP +7. Of the corpus mid/ground-floor flats this exposes,
4/4 move toward the lodged SAP, 0 away.
Reads the MAIN part (parts[0]): a flat's storey position is a
whole-dwelling property, and `DwellingExposure` is a single global flag
that `heat_transmission` applies to every part so a multi-part flat
whose main ceiling is party (e.g. only an extension has an exposed roof)
correctly stays party rather than over-counting the main party ceiling.
"""
if not parts:
return False
return _roof_insulation_location_is_determined(parts[0].roof_insulation_location)
def _dwelling_exposure(
dwelling_type: Optional[str],
parts: Optional[list[SapBuildingPart]] = None,
) -> DwellingExposure:
"""Map `EpcPropertyData.dwelling_type` to which envelope surfaces are
party (not heat-loss). Mid-floor flats/maisonettes lose both floor +
roof; top-floor lose floor only; ground-floor lose roof only. Houses
@ -1651,7 +1706,23 @@ def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure:
RdSAP 10 §3 lists flat-prefix dwelling types ("Top-floor flat",
"Mid-floor maisonette", etc.); matching is prefix-based and
case-insensitive so site-notes capitalisation drift doesn't break it.
The lodged fabric overrides a contradictory label: when the main part
lodges a determined roof-insulation location (`_cert_lodges_exposed_roof`),
the roof is heat-loss even if the label suppressed it (a top-floor flat
lodged as "Mid-floor"). The override is additive it only ever
*exposes* a roof the label dropped, never hides a lodged party ceiling
so a true mid-floor flat (roof_insulation_location "ND") is unaffected.
"""
base = _dwelling_exposure_from_type(dwelling_type)
if not base.has_exposed_roof and parts and _cert_lodges_exposed_roof(parts):
return replace(base, has_exposed_roof=True)
return base
def _dwelling_exposure_from_type(dwelling_type: Optional[str]) -> DwellingExposure:
"""The `dwelling_type`-label-only exposure map (RdSAP 10 §3 prefixes).
`_dwelling_exposure` layers the lodged-fabric override on top."""
if not dwelling_type:
return DwellingExposure(has_exposed_floor=True, has_exposed_roof=True)
dt = dwelling_type.lower()
@ -4557,7 +4628,7 @@ def heat_transmission_section_from_cert(epc: EpcPropertyData) -> HeatTransmissio
PDF.
"""
window_total_area, window_avg_u = _window_total_area_and_avg_u(epc.sap_windows)
exposure = _dwelling_exposure(epc.dwelling_type)
exposure = _dwelling_exposure(epc.dwelling_type, epc.sap_building_parts)
return heat_transmission_from_cert(
epc,
window_total_area_m2=window_total_area,

View file

@ -13,6 +13,7 @@ from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EnergyElement,
EpcPropertyData,
InstantaneousWwhrs,
MainHeatingDetail,
@ -157,6 +158,7 @@ def make_building_part(
wall_thickness_measured: bool = True,
party_wall_construction: Union[int, str] = 1,
roof_construction: Optional[int] = 4,
roof_insulation_location: Optional[Union[int, str]] = None,
floor_dimensions: Optional[list[SapFloorDimension]] = None,
sap_room_in_roof: Optional[SapRoomInRoof] = None,
floor_type: Optional[str] = None,
@ -170,6 +172,7 @@ def make_building_part(
wall_thickness_measured=wall_thickness_measured,
party_wall_construction=party_wall_construction,
roof_construction=roof_construction,
roof_insulation_location=roof_insulation_location,
sap_floor_dimensions=floor_dimensions
if floor_dimensions is not None
else [make_floor_dimension()],
@ -277,6 +280,7 @@ def make_minimal_sap10_epc(
pressure_test: Optional[int] = None,
sap_ventilation: Optional[SapVentilation] = None,
postcode: str = "A1 1AA",
roofs: Optional[list[EnergyElement]] = None,
) -> EpcPropertyData:
"""Construct a minimal valid SAP10 EpcPropertyData with parametrisable targets."""
return EpcPropertyData(
@ -287,7 +291,7 @@ def make_minimal_sap10_epc(
address_line_1="1 Test Street",
postcode=postcode,
post_town="Testtown",
roofs=[],
roofs=list(roofs) if roofs is not None else [],
walls=[],
floors=[],
main_heating=[],

View file

@ -5690,7 +5690,10 @@ def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission()
# Arrange — A "Mid-floor flat" has party floor (downstairs flat) and
# party ceiling (upstairs flat). The mapper must wire DwellingExposure
# to suppress both channels so the HLC matches what RdSAP-driven
# assessor software produces.
# assessor software produces. A genuine mid-floor flat lodges
# roof_insulation_location="ND" (Not Defined — no roof to insulate, the
# ceiling is a party surface), so `_cert_lodges_exposed_roof` does not
# fire.
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
@ -5698,6 +5701,7 @@ def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission()
dwelling_type="Mid-floor flat",
sap_building_parts=[
make_building_part(
roof_insulation_location="ND", # party ceiling — no exposed roof
floor_dimensions=[
make_floor_dimension(total_floor_area_m2=_TYPICAL_TFA_M2, floor=0),
],
@ -5718,6 +5722,51 @@ def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission()
assert inputs.heat_transmission.walls_w_per_k > 0
def test_mid_floor_label_with_determined_roof_insulation_exposes_roof() -> None:
# Arrange — gov-API certs lodge `dwelling_type` as the raw assessor
# label, which can contradict the lodged fabric. Property 715363
# (uprn 6027561) and its sibling 715395 (6027563) lodge
# dwelling_type="Mid-floor flat" yet lodge a determined
# roof_insulation_location (715363: code 6 = flat roof) over a
# "(another dwelling below)" floor — i.e. they are TOP-floor flats
# mislabelled mid-floor. Keying roof exposure on the label alone dropped
# the roof heat-loss term, under-reading space-heating demand ~32% and
# over-reading SAP +7 (calc 81 vs lodged 74). The correctly labelled
# top-floor sibling 715871 (6027574), same block + same flat roof,
# already computes the lodged 74.
#
# `roof_insulation_location` is the authoritative per-unit structured
# signal: a determined location (an RdSAP code, not "ND") means the unit
# has an exposed roof to insulate — unlike roof_construction, which the
# gov-API lodges building-wide on every unit.
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
region_code="1",
dwelling_type="Mid-floor flat",
sap_building_parts=[
make_building_part(
roof_insulation_location=6, # flat roof — a determined location
floor_dimensions=[
make_floor_dimension(total_floor_area_m2=_TYPICAL_TFA_M2, floor=0),
],
),
],
sap_heating=make_sap_heating(
main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)],
),
)
# Act
inputs = cert_to_inputs(epc)
# Assert — the floor stays party (dwelling below) but the determined
# roof-insulation location exposes the roof, matching the top-floor
# sibling.
assert inputs.heat_transmission.floor_w_per_k == 0.0
assert inputs.heat_transmission.roof_w_per_k > 0
def test_top_floor_flat_keeps_roof_drops_floor() -> None:
# Arrange — Top-floor flat: party floor, external roof.
epc = make_minimal_sap10_epc(

View file

@ -193,7 +193,7 @@ _CORPUS = Path(
# within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst
# stress worksheet (simulated case 46): closed its last ventilation residual
# (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst).
_MIN_WITHIN_HALF_SAP = 0.72
_MIN_WITHIN_HALF_SAP = 0.73
# 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak
# trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion)
# -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57,
@ -226,7 +226,19 @@ _MIN_WITHIN_HALF_SAP = 0.72
# old flat default — so flipping the default had silently turned 190 PCDB
# keep-hot combis into no-keep-hot). Closes case 49 EXACTLY (cost £726.696,
# SAP 72 = the worksheet to the digit).
_MAX_SAP_MAE = 0.775
# Then 0.774 -> 0.761 (within-0.5 73.3% -> 73.6%) via the mislabelled-top-floor
# roof-exposure fix (RdSAP 10 §3 / §5): a gov-API flat lodged "Mid-floor" but
# carrying a determined `roof_insulation_location` (an RdSAP code, not "ND")
# has its own exposed roof — a top-floor flat mislabelled mid-floor — so the
# roof heat-loss term must NOT be dropped. `_dwelling_exposure` now reads the
# structured location field (not the dwelling_type label alone). Motivated by
# property 715363 (uprn 6027561): roof dropped under-read space-heating demand
# ~32% (1833 vs lodged RHI 2694), over-read SAP +7 (81 vs lodged 74); the
# correctly labelled top-floor sibling 715871 (same block, same flat roof)
# already computes 74. roof_insulation_location="ND" ⟺ party ceiling separates
# the corpus classes with zero disagreement (all 190 party flats lodge "ND");
# the 4 mid/ground-floor flats this exposes all move toward lodged, 0 away.
_MAX_SAP_MAE = 0.762
_MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 3.5 # kWh / m2 / yr vs energy_consumption_current