mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
commit
31e8b93ea0
4 changed files with 143 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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=[],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue