From ccdaba5acd7874660bb685379b957a7e27eb702d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 14:10:45 +0000 Subject: [PATCH] slice S-B3: flat heat-loss surface awareness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 29 ++++- .../sap/rdsap/tests/test_cert_to_inputs.py | 89 +++++++++++++ .../domain/sap/worksheet/heat_transmission.py | 29 ++++- .../worksheet/tests/test_heat_transmission.py | 123 ++++++++++++++++++ 4 files changed, 266 insertions(+), 4 deletions(-) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 10a177e4..63804b57 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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 diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 3e24ef27..c818a182 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 24e291d5..3c63c9ca 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py index 084d604d..ae7d6b2d 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -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