slice S-B3: flat heat-loss surface awareness

DwellingExposure flags on heat_transmission_from_cert suppress the
floor and/or roof channels when those surfaces are party with a
neighbouring dwelling. Cert mapper derives the flags from
EpcPropertyData.dwelling_type prefix:
  - "Mid-floor *"    → floor=False, roof=False
  - "Top-floor *"    → floor=False, roof=True
  - "Ground-floor *" → floor=True,  roof=False
  - everything else  → both exposed

100-cert parity probe impact:
  MAE   8.41 → 7.53   (-0.88)
  RMSE 13.98 → 11.60  (-2.38)
  bias -2.65 → -0.61  (system bias on flats essentially eliminated)

Bungalow outliers (-56 worst residual) untouched — different failure
mode (full envelope, but cascade U-values too conservative or storey
count over-counted). Next slice tackles that.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 14:10:45 +00:00
parent dde8ae30fa
commit ccdaba5acd
4 changed files with 266 additions and 4 deletions

View file

@ -55,7 +55,10 @@ from domain.ml.sap_efficiencies import (
)
from domain.sap.calculator import CalculatorInputs, WindowInput
from domain.sap.worksheet.dimensions import dimensions_from_cert
from domain.sap.worksheet.heat_transmission import heat_transmission_from_cert
from domain.sap.worksheet.heat_transmission import (
DwellingExposure,
heat_transmission_from_cert,
)
from domain.sap.worksheet.solar_gains import Orientation
from domain.sap.worksheet.ventilation import infiltration_ach
@ -122,6 +125,28 @@ _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
def _dwelling_exposure(dwelling_type: Optional[str]) -> 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
and bungalows expose both surfaces.
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.
"""
if not dwelling_type:
return DwellingExposure(has_exposed_floor=True, has_exposed_roof=True)
dt = dwelling_type.lower()
if dt.startswith("mid-floor"):
return DwellingExposure(has_exposed_floor=False, has_exposed_roof=False)
if dt.startswith("top-floor"):
return DwellingExposure(has_exposed_floor=False, has_exposed_roof=True)
if dt.startswith("ground-floor"):
return DwellingExposure(has_exposed_floor=True, has_exposed_roof=False)
return DwellingExposure(has_exposed_floor=True, has_exposed_roof=True)
def _region_index(region_code: Optional[str]) -> int:
"""Coerce EpcPropertyData.region_code (str) to the integer Appendix U
region index. Out-of-range or unparseable 0 (UK average)."""
@ -293,6 +318,7 @@ def cert_to_inputs(epc: EpcPropertyData) -> CalculatorInputs:
"""Build a typed `CalculatorInputs` aggregate from an `EpcPropertyData`."""
dim = dimensions_from_cert(epc)
window_total_area, window_avg_u = _window_total_area_and_avg_u(epc.sap_windows)
exposure = _dwelling_exposure(epc.dwelling_type)
ht = heat_transmission_from_cert(
epc,
window_total_area_m2=window_total_area,
@ -300,6 +326,7 @@ def cert_to_inputs(epc: EpcPropertyData) -> CalculatorInputs:
door_count=epc.door_count,
insulated_door_count=epc.insulated_door_count,
insulated_door_u_value=epc.insulated_door_u_value,
exposure=exposure,
)
vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0

View file

@ -213,3 +213,92 @@ def test_mains_gas_fuel_cost_in_gbp_per_kwh() -> None:
# Assert
assert inputs.fuel_unit_cost_gbp_per_kwh == 0.0348
def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() -> None:
# 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.
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(
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
assert inputs.heat_transmission.floor_w_per_k == 0.0
assert inputs.heat_transmission.roof_w_per_k == 0.0
# Walls still contribute (perimeter is heat-loss surface).
assert inputs.heat_transmission.walls_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(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
region_code="1",
dwelling_type="Top-floor flat",
sap_building_parts=[
make_building_part(
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
assert inputs.heat_transmission.floor_w_per_k == 0.0
assert inputs.heat_transmission.roof_w_per_k > 0
def test_detached_house_dwelling_type_keeps_full_envelope_exposed() -> None:
# Arrange — A house has no party floor/ceiling; full envelope is
# exposed. Regression guard against the flat-detection logic
# mis-firing on house dwelling-types.
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
region_code="1",
dwelling_type="Detached house",
sap_building_parts=[
make_building_part(
floor_dimensions=[
make_floor_dimension(total_floor_area_m2=45.0, floor=0),
make_floor_dimension(total_floor_area_m2=45.0, floor=1),
],
),
],
sap_heating=make_sap_heating(
main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)],
),
)
# Act
inputs = cert_to_inputs(epc)
# Assert
assert inputs.heat_transmission.floor_w_per_k > 0
assert inputs.heat_transmission.roof_w_per_k > 0

View file

@ -59,6 +59,21 @@ class HeatTransmission:
total_w_per_k: float
@dataclass(frozen=True)
class DwellingExposure:
"""Which envelope surfaces are exposed (heat-loss) vs party in this
dwelling. Houses + bungalows expose both floor and roof; flats expose
only the surfaces that aren't party with neighbouring dwellings.
SAP 10.3 §3 / RdSAP 10 §5: heat-transmission excludes party surfaces;
`party_walls_w_per_k` already captures the party-wall channel using
its own U_party.
"""
has_exposed_floor: bool = True
has_exposed_roof: bool = True
def _int_or_none(value: Any) -> Optional[int]:
return value if isinstance(value, int) else None
@ -129,10 +144,18 @@ def heat_transmission_from_cert(
door_count: int = 0,
insulated_door_count: int = 0,
insulated_door_u_value: Optional[float] = None,
exposure: Optional[DwellingExposure] = None,
) -> HeatTransmission:
"""Conduction HLC + thermal-bridging contribution, summed across every
sap_building_part in the cert. Windows and doors are apportioned to the
first part (the main dwelling) per RdSAP convention."""
first part (the main dwelling) per RdSAP convention.
`exposure` lets the caller suppress floor and/or roof contributions
for flats whose floor/ceiling are party surfaces (mid/top/ground-floor
flats per RdSAP 10 §5). Defaults to fully exposed the right answer
for houses and bungalows."""
if exposure is None:
exposure = DwellingExposure()
parts = epc.sap_building_parts or []
if not parts:
return HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
@ -205,8 +228,8 @@ def heat_transmission_from_cert(
d_area = door_area if i == 0 else 0.0
net_wall_area = max(0.0, gross_wall_area - w_area - d_area)
party_area = geom["party_wall_length_m"] * storey_height * storey_count
roof_area = geom["top_floor_area_m2"]
floor_area_total = geom["ground_floor_area_m2"]
roof_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0
floor_area_total = geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0
walls += uw * net_wall_area
roof += ur * roof_area

View file

@ -23,6 +23,7 @@ from domain.ml.tests._fixtures import (
make_minimal_sap10_epc,
)
from domain.sap.worksheet.heat_transmission import (
DwellingExposure,
HeatTransmission,
heat_transmission_from_cert,
)
@ -253,3 +254,125 @@ def test_main_plus_extension_sums_per_element_contributions() -> None:
assert with_ext.floor_w_per_k > main_only.floor_w_per_k
assert with_ext.thermal_bridging_w_per_k > main_only.thermal_bridging_w_per_k
assert with_ext.total_w_per_k > main_only.total_w_per_k
def test_dwelling_exposure_default_keeps_floor_and_roof_contributions() -> None:
# Arrange — Back-compat check: callers that don't pass `exposure` get
# the house/bungalow shape (both floor and roof exposed).
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=100.0, country_code="ENG", sap_building_parts=[main],
)
# Act
default = heat_transmission_from_cert(epc)
explicit_house = heat_transmission_from_cert(
epc, exposure=DwellingExposure(has_exposed_floor=True, has_exposed_roof=True),
)
# Assert
assert default.floor_w_per_k > 0
assert default.roof_w_per_k > 0
assert default == explicit_house
def test_mid_floor_flat_exposure_zeroes_floor_and_roof_contributions() -> None:
# Arrange — Mid-floor flat has party floor (downstairs neighbour) AND
# party ceiling (upstairs neighbour). SAP 10.3 §3 excludes party
# surfaces from heat transmission; calculator must drop both channels
# to zero.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=60.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=60.0, country_code="ENG", sap_building_parts=[main],
)
# Act
mid_floor = heat_transmission_from_cert(
epc, exposure=DwellingExposure(has_exposed_floor=False, has_exposed_roof=False),
)
# Assert
assert mid_floor.floor_w_per_k == 0.0
assert mid_floor.roof_w_per_k == 0.0
# Walls remain heat-loss surface (still > 0).
assert mid_floor.walls_w_per_k > 0
def test_top_floor_flat_exposure_keeps_roof_drops_floor() -> None:
# Arrange — Top-floor flat: party floor (downstairs neighbour), but
# the roof is the building's external roof so heat loss applies.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=60.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=60.0, country_code="ENG", sap_building_parts=[main],
)
# Act
top_floor = heat_transmission_from_cert(
epc, exposure=DwellingExposure(has_exposed_floor=False, has_exposed_roof=True),
)
# Assert
assert top_floor.floor_w_per_k == 0.0
assert top_floor.roof_w_per_k > 0
def test_ground_floor_flat_exposure_keeps_floor_drops_roof() -> None:
# Arrange — Ground-floor flat: external floor (over solid ground or
# ventilated void), but party ceiling.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=60.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=60.0, country_code="ENG", sap_building_parts=[main],
)
# Act
ground = heat_transmission_from_cert(
epc, exposure=DwellingExposure(has_exposed_floor=True, has_exposed_roof=False),
)
# Assert
assert ground.floor_w_per_k > 0
assert ground.roof_w_per_k == 0.0