From 732eef6adb06ed4e8dfc954fb485dda649829561 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 22:30:56 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-A4:=20heat-transmission=20HLC=20break?= =?UTF-8?q?down=20(SAP=2010.3=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../domain/sap/worksheet/heat_transmission.py | 229 ++++++++++++++++ .../worksheet/tests/test_heat_transmission.py | 255 ++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 packages/domain/src/domain/sap/worksheet/heat_transmission.py create mode 100644 packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py new file mode 100644 index 00000000..24e291d5 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -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, + ) 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 new file mode 100644 index 00000000..084d604d --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -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