slice S-A4: heat-transmission HLC breakdown (SAP 10.3 §3)

Fourth slice of the SAP10 Calculator Session A (ADR-0009). Ports the
per-element conduction HLC logic out of domain.ml.envelope into a typed
HeatTransmission breakdown under domain.sap.worksheet. Aggregates Σ U×A
across walls, roof, floor, party walls, windows, doors, plus thermal-
bridging y × total exposed area, summed across every building part.

The orchestrator can now read walls_w_per_k / roof_w_per_k / floor_w_per_k
etc. directly off the result for audit + monthly-loop wiring, rather than
seeing a single envelope_heat_loss scalar.

U-value cascade still routes through domain.ml.rdsap_uvalues (migrates to
domain.sap.rdsap.cascade_defaults in Session B per ADR-0009 module-layout
plan). domain.ml.envelope stays in place to keep the ML transform's
physics-feature pipeline running until Session B.

6 AAA cycles cover: per-element breakdown for a baseline age-G cavity
mid-terrace, window net-wall subtraction, insulated-door U-value blending,
cavity-party-wall contribution per Table 15, thermal-bridging scaling by
age band per Table 21, and multi-part (main + extension) aggregation.

192 tests pass across domain.sap + domain.ml — no regressions.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 22:30:56 +00:00
parent 3fcec7ef22
commit 732eef6adb
2 changed files with 484 additions and 0 deletions

View file

@ -0,0 +1,229 @@
"""SAP 10.3 §3 — heat-transmission Heat Loss Coefficient.
Conduction HLC summed across every building part: Σ U × A across walls,
roof, floor, party walls, windows, doors, plus thermal-bridging factor y
multiplied by total exposed envelope area.
Returns a typed `HeatTransmission` breakdown so the orchestrator can audit
each element's contribution.
This is the calculator-vocabulary sibling of `domain.ml.envelope`. During
Session A both modules coexist the legacy envelope.py continues to feed
the ML transform's `envelope_heat_loss_w_per_k` physics-feature. Session B
will retire envelope.py in favour of this module (ADR-0009 §"Module
layout").
U-value lookups cascade through `domain.ml.rdsap_uvalues` migrating to
`domain.sap.rdsap.cascade_defaults` in Session B.
Reference: SAP 10.3 specification §3 (pages 17-22); RdSAP 10 §5.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart
from domain.ml.rdsap_uvalues import (
Country,
WALL_UNKNOWN,
thermal_bridging_y,
u_door,
u_floor,
u_party_wall,
u_roof,
u_wall,
u_window,
)
_WALL_INSULATION_NONE: Final[int] = 4
_DEFAULT_DOOR_AREA_M2: Final[float] = 1.85
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
@dataclass(frozen=True)
class HeatTransmission:
"""SAP 10.3 §3 conduction HLC broken down per element type, summed
across all sap_building_parts (main dwelling + every extension)."""
walls_w_per_k: float
roof_w_per_k: float
floor_w_per_k: float
party_walls_w_per_k: float
windows_w_per_k: float
doors_w_per_k: float
thermal_bridging_w_per_k: float
total_w_per_k: float
def _int_or_none(value: Any) -> Optional[int]:
return value if isinstance(value, int) else None
def _parse_thickness_mm(value: Any) -> Optional[int]:
if value is None:
return None
if isinstance(value, int):
return value
if not isinstance(value, str):
return None
s = value.strip()
if s.upper() == "NI":
return 0
digits = ""
for c in s:
if c.isdigit():
digits += c
else:
break
return int(digits) if digits else None
def _joined_descriptions(elements: list[Any]) -> Optional[str]:
if not elements:
return None
parts = [getattr(e, "description", "") for e in elements if getattr(e, "description", "")]
if not parts:
return None
return " | ".join(parts)
def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
if not part.sap_floor_dimensions:
return {
"ground_floor_area_m2": 0.0,
"ground_perimeter_m": 0.0,
"top_floor_area_m2": 0.0,
"party_wall_length_m": 0.0,
"avg_room_height_m": _DEFAULT_STOREY_HEIGHT_M,
"storey_count": 1.0,
}
fds = list(part.sap_floor_dimensions)
ground = next((fd for fd in fds if fd.floor == 0), fds[0])
indexed = [(fd.floor if fd.floor is not None else 0, fd) for fd in fds]
top = max(indexed, key=lambda kv: kv[0])[1]
total_area = sum(fd.total_floor_area_m2 or 0.0 for fd in fds)
weighted_height = sum(
(fd.total_floor_area_m2 or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M)
for fd in fds
)
avg_height = (weighted_height / total_area) if total_area > 0 else _DEFAULT_STOREY_HEIGHT_M
return {
"ground_floor_area_m2": ground.total_floor_area_m2 or 0.0,
"ground_perimeter_m": ground.heat_loss_perimeter_m or 0.0,
"top_floor_area_m2": top.total_floor_area_m2 or 0.0,
"party_wall_length_m": ground.party_wall_length_m or 0.0,
"avg_room_height_m": avg_height,
"storey_count": float(len(fds)),
}
def heat_transmission_from_cert(
epc: EpcPropertyData,
*,
window_total_area_m2: float = 0.0,
window_avg_u_value: Optional[float] = None,
door_count: int = 0,
insulated_door_count: int = 0,
insulated_door_u_value: Optional[float] = 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."""
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)
country = Country.from_code(epc.country_code)
roof_description = _joined_descriptions(epc.roofs)
wall_description = _joined_descriptions(epc.walls)
door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2
window_u = window_avg_u_value if (window_avg_u_value or 0) > 0 else u_window(
installed_year=None, glazing_type=None, frame_type=None
)
primary_age = parts[0].construction_age_band
door_uninsulated_u = u_door(country=country, age_band=primary_age, insulated=False, insulated_u_value=None)
door_insulated_u = (
insulated_door_u_value
if insulated_door_u_value is not None
else u_door(country=country, age_band="M", insulated=True, insulated_u_value=None)
)
insulated_share = (insulated_door_count or 0) / door_count if door_count > 0 else 0.0
door_u = (1.0 - insulated_share) * door_uninsulated_u + insulated_share * door_insulated_u
walls = 0.0
roof = 0.0
floor = 0.0
party = 0.0
windows = 0.0
doors = 0.0
bridging = 0.0
for i, part in enumerate(parts):
geom = _part_geometry(part)
age_band = part.construction_age_band
wall_construction = _int_or_none(part.wall_construction)
wall_ins_type = _int_or_none(part.wall_insulation_type)
wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness)
wall_ins_present = wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE
party_construction = _int_or_none(part.party_wall_construction)
roof_thickness = _parse_thickness_mm(getattr(part, "roof_insulation_thickness", None))
floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None))
ground_fd = next(
(fd for fd in part.sap_floor_dimensions if fd.floor == 0),
part.sap_floor_dimensions[0] if part.sap_floor_dimensions else None,
)
floor_area = ground_fd.total_floor_area_m2 if ground_fd is not None else None
floor_perimeter = ground_fd.heat_loss_perimeter_m if ground_fd is not None else None
floor_construction = _int_or_none(ground_fd.floor_construction) if ground_fd is not None else None
uw = u_wall(
country=country, age_band=age_band,
construction=wall_construction if wall_construction != WALL_UNKNOWN else None,
insulation_thickness_mm=wall_ins_thickness,
insulation_present=wall_ins_present,
description=wall_description,
)
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=roof_description)
uf = u_floor(
country=country, age_band=age_band, construction=floor_construction,
insulation_thickness_mm=floor_ins_thickness,
area_m2=floor_area, perimeter_m=floor_perimeter,
wall_thickness_mm=part.wall_thickness_mm,
)
upw = u_party_wall(party_wall_construction=party_construction)
y = thermal_bridging_y(age_band=age_band)
storey_count = geom["storey_count"]
storey_height = geom["avg_room_height_m"]
gross_wall_area = geom["ground_perimeter_m"] * storey_height * storey_count
w_area = window_total_area_m2 if i == 0 else 0.0
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"]
walls += uw * net_wall_area
roof += ur * roof_area
floor += uf * floor_area_total
party += upw * party_area
windows += window_u * w_area
doors += door_u * d_area
bridging += y * (net_wall_area + party_area + roof_area + floor_area_total + w_area + d_area)
total = walls + roof + floor + party + windows + doors + bridging
return HeatTransmission(
walls_w_per_k=walls,
roof_w_per_k=roof,
floor_w_per_k=floor,
party_walls_w_per_k=party,
windows_w_per_k=windows,
doors_w_per_k=doors,
thermal_bridging_w_per_k=bridging,
total_w_per_k=total,
)

View file

@ -0,0 +1,255 @@
"""Tests for SAP 10.3 §3 heat-transmission worksheet.
Computes the conduction Heat Loss Coefficient (W/K) summed across all
building parts: Σ U × A across walls / roof / floor / party walls /
windows / doors, plus thermal bridging y × total exposed area. Returns
a typed `HeatTransmission` breakdown so callers can audit each
worksheet contribution.
U-values cascade through the RdSAP 10 §5 defaults (currently implemented
in `domain.ml.rdsap_uvalues` migrates to `domain.sap.rdsap.cascade_defaults`
in Session B).
Reference: SAP 10.3 specification §3 (pages 17-22);
RdSAP 10 §5 (pages 31-48); test fixtures shared with the legacy
envelope.py test pack so cases match production cert shape.
"""
import pytest
from domain.ml.tests._fixtures import (
make_building_part,
make_floor_dimension,
make_minimal_sap10_epc,
)
from domain.sap.worksheet.heat_transmission import (
HeatTransmission,
heat_transmission_from_cert,
)
def test_single_storey_age_g_cavity_returns_per_element_breakdown() -> None:
# Arrange — Mid-terrace, age G cavity-as-built, 100 m² floor area, 40 m
# heat-loss perimeter, 5 m party wall, 2.5 m room height, single storey.
# Per RdSAP10 §5 + BS EN ISO 13370 cascade defaults:
# u_wall = 0.60 (cavity G uninsulated), u_roof = 0.40, u_floor ≈ 0.60,
# u_party = 0.0 (solid masonry), y = 0.15.
# Areas: gross_wall 100, net_wall 100, party 12.5, roof 100, floor 100.
# walls W/K = 60.0; roof = 40.0; floor ≈ 60.3; party = 0.0;
# bridging = 0.15 × (100 + 12.5 + 100 + 100) = 46.9; total ≈ 207.2 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4, # cavity
wall_insulation_type=4, # none
party_wall_construction=1, # solid masonry
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.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
result = heat_transmission_from_cert(epc)
# Assert
assert isinstance(result, HeatTransmission)
assert result.walls_w_per_k == pytest.approx(60.0, abs=2.0)
assert result.roof_w_per_k == pytest.approx(40.0, abs=2.0)
assert result.floor_w_per_k == pytest.approx(60.3, abs=3.0)
assert result.party_walls_w_per_k == pytest.approx(0.0, abs=0.5)
assert result.windows_w_per_k == pytest.approx(0.0, abs=0.1)
assert result.doors_w_per_k == pytest.approx(0.0, abs=0.1)
assert result.thermal_bridging_w_per_k == pytest.approx(46.9, abs=3.0)
assert result.total_w_per_k == pytest.approx(207.0, abs=8.0)
def test_windows_subtract_from_net_wall_area_so_walls_w_per_k_drops() -> None:
# Arrange — Same age G cavity geometry, but with 15 m² of windows. Walls
# net area drops from 100 to 85 m²; walls_w_per_k drops from 60 to 51.
# windows_w_per_k rises from 0 to ≈ 15 × 2.5 (Table 24 mid-range default).
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=5.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
no_windows = heat_transmission_from_cert(epc)
with_windows = heat_transmission_from_cert(
epc, window_total_area_m2=15.0, window_avg_u_value=2.8,
)
# Assert — walls fall by U_wall × window_area; windows = U_win × window_area.
assert with_windows.walls_w_per_k == pytest.approx(no_windows.walls_w_per_k - 0.60 * 15.0, abs=1.0)
assert with_windows.windows_w_per_k == pytest.approx(2.8 * 15.0, abs=0.5)
# Total rises because U_window (2.8) > U_wall (0.60), so the net swap adds heat loss.
assert with_windows.total_w_per_k > no_windows.total_w_per_k
def test_two_doors_one_insulated_blends_u_values_per_rdsap_share() -> None:
# Arrange — Two doors total, one insulated at U=1.0, the other taking the
# Table 26 default for age G (3.0). Door area = 2 × 1.85 = 3.70 m². Share
# insulated = 0.5, so blended U = 0.5 × 3.0 + 0.5 × 1.0 = 2.0.
# Expected doors_w_per_k = 2.0 × 3.70 = 7.40.
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=5.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], door_count=2, insulated_door_count=1,
)
# Act
result = heat_transmission_from_cert(
epc, door_count=2, insulated_door_count=1, insulated_door_u_value=1.0,
)
# Assert
assert result.doors_w_per_k == pytest.approx(7.40, abs=0.3)
def test_cavity_party_wall_contributes_per_table_15() -> None:
# Arrange — Party wall construction = 4 (cavity, unfilled) → U=0.5 per
# RdSAP10 §5.10. Party area = 5 × 2.5 × 1 = 12.5 m².
# Expected party_walls_w_per_k = 0.5 × 12.5 = 6.25.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="G",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=4, # cavity (unfilled) — 0.5 W/m²K per Table 15
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.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
result = heat_transmission_from_cert(epc)
# Assert
assert result.party_walls_w_per_k == pytest.approx(6.25, abs=0.3)
def test_thermal_bridging_drops_for_newer_age_band_per_table_21() -> None:
# Arrange — RdSAP10 §5.15 / Table 21: y = 0.15 for A-I, 0.11 for J,
# 0.08 for K-M. Compare age G (0.15) vs age M (0.08) with the same
# geometry. Total exposed area is constant; bridging contribution
# scales linearly with y.
def _make_part(age: str):
return make_building_part(
identifier="Main Dwelling",
construction_age_band=age,
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=5.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
epc_g = make_minimal_sap10_epc(
total_floor_area_m2=100.0, country_code="ENG",
sap_building_parts=[_make_part("G")],
)
epc_m = make_minimal_sap10_epc(
total_floor_area_m2=100.0, country_code="ENG",
sap_building_parts=[_make_part("M")],
)
# Act
bridging_g = heat_transmission_from_cert(epc_g).thermal_bridging_w_per_k
bridging_m = heat_transmission_from_cert(epc_m).thermal_bridging_w_per_k
# Assert — same geometry × (0.08 / 0.15) ratio = 0.533. Allow loose abs
# tolerance because age M defaults a much better wall too.
assert bridging_m < bridging_g
assert bridging_m == pytest.approx(bridging_g * 0.08 / 0.15, abs=2.0)
def test_main_plus_extension_sums_per_element_contributions() -> None:
# Arrange — Main + single-storey age L extension. Each contributes to the
# element totals. With_extension > main_only on every populated field
# (walls, roof, floor, bridging) and on the total.
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=5.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
extension = make_building_part(
identifier="Extension 1",
construction_age_band="L",
wall_construction=4, wall_insulation_type=2, # filled cavity
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=15.0, room_height_m=2.4,
party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=0,
),
],
)
epc_main_only = make_minimal_sap10_epc(
total_floor_area_m2=100.0, country_code="ENG", sap_building_parts=[main],
)
epc_with_ext = make_minimal_sap10_epc(
total_floor_area_m2=115.0, country_code="ENG", sap_building_parts=[main, extension],
)
# Act
main_only = heat_transmission_from_cert(epc_main_only)
with_ext = heat_transmission_from_cert(epc_with_ext)
# Assert
assert with_ext.walls_w_per_k > main_only.walls_w_per_k
assert with_ext.roof_w_per_k > main_only.roof_w_per_k
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