mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
dde8ae30fa
commit
ccdaba5acd
4 changed files with 266 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue