mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
3fcec7ef22
commit
732eef6adb
2 changed files with 484 additions and 0 deletions
229
packages/domain/src/domain/sap/worksheet/heat_transmission.py
Normal file
229
packages/domain/src/domain/sap/worksheet/heat_transmission.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue