mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Cert 0330 (mid-terrace boiler, Summary_000897.pdf) Summary path was at
Δ +0.4667 SAP vs worksheet 61.5993 because Ext1's flat roof fell through
`_ROOF_BY_AGE` (Table 18 column (1), pitched-roof "between joists"
defaults) to 0.40 W/m²K for age D — the spec value is 2.30 W/m²K from
column (3) "Flat roof" (RdSAP 10 spec page 45).
RdSAP 10 §5.11 Table 18 column (3) verbatim:
Age A,B,C,D → 2.30; E → 1.50; F → 0.68; G → 0.40; H,I → 0.35;
J,K → 0.25; L → 0.18; M → 0.15.
Footnote (a): "If the roof insulation is 'none' use U = 2.3 (all roof
types, except for thatched roofs)" — confirms the col-3 entries for
old ages are the uninsulated row, applied because cert 0330's Ext1
lodges "Flat" construction with no measured insulation thickness.
Changes:
- `_FLAT_ROOF_BY_AGE` added in rdsap_uvalues.py
- `u_roof` gains `is_flat_roof: bool = False` parameter
- `heat_transmission_from_cert` detects flat roofs from
`part.roof_construction_type` ("flat" substring) and routes through
the new column.
Effect on baseline:
- cert 0330 Summary chain test: RED Δ+0.4667 → GREEN at 1e-4 (worksheet
total fabric heat loss 237.7549 W/K matches cascade to 4 d.p.)
- cert 001479 Layer 4 chain test: unchanged (Main pitched, no flat
components)
- cohort certs 000477/000516: unchanged (no flat roofs)
- golden cert 0300-2747-7640-2526-2135: SAP residual +1 → 0 (improved),
Ext1 is genuinely flat; pe/co2 residuals re-pinned. The dwelling has
the same Main-pitched + Ext1-flat shape as cert 0330; same fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1385 lines
46 KiB
Python
1385 lines
46 KiB
Python
"""Tests for RdSAP10 U-value cascade-defaulting helpers.
|
||
|
||
Reference values are taken from the RdSAP10 specification (12 February 2024):
|
||
- Tables 6-9 — wall U-values per country
|
||
- Table 14 — insulation thickness <-> resistance
|
||
- Table 15 — party-wall U-values
|
||
- Table 18 — roof U-values by age band
|
||
- Table 19 — floor insulation defaults by age band
|
||
- Table 20 — exposed/semi-exposed upper-floor U-values
|
||
- Table 21 — thermal-bridging factor y
|
||
- Table 24 — window U-values
|
||
- Table 26 — door U-values
|
||
|
||
The functions never raise on missing inputs; they cascade through age-band
|
||
defaults -> country defaults -> final mid-range value so that callers can
|
||
treat the envelope as if RdSAP had assigned an as-built default.
|
||
"""
|
||
|
||
from typing import Optional
|
||
|
||
import pytest
|
||
|
||
from domain.sap10_ml.rdsap_uvalues import (
|
||
Country,
|
||
WALL_CAVITY,
|
||
WALL_INSULATION_FILLED_CAVITY,
|
||
WALL_SOLID_BRICK,
|
||
WALL_STONE_GRANITE,
|
||
WALL_SYSTEM_BUILT,
|
||
WALL_TIMBER_FRAME,
|
||
thermal_bridging_y,
|
||
u_door,
|
||
u_exposed_floor,
|
||
u_floor,
|
||
u_party_wall,
|
||
u_roof,
|
||
u_rr_default_all_elements,
|
||
u_rr_flat_ceiling,
|
||
u_rr_slope,
|
||
u_rr_stud_wall,
|
||
u_wall,
|
||
u_window,
|
||
)
|
||
|
||
|
||
# ----- Walls -----
|
||
|
||
|
||
def test_u_wall_description_with_measured_transmittance_returns_parsed_value() -> None:
|
||
# Arrange — full SAP (not RdSAP) assessments lodge a measured/calculated
|
||
# U-value per BS EN ISO 6946 in the wall description string, e.g.
|
||
# "Average thermal transmittance 0.18 W/m²K". These certs typically
|
||
# have wall_construction, wall_insulation_type, and age_band all None
|
||
# because the cascade defaults don't apply — the assessor's measured
|
||
# value takes precedence (RdSAP 10 §5.3). Affects ~15% of corpus.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=None,
|
||
age_band=None,
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
description="Average thermal transmittance 0.18 W/m²K",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.18, abs=0.001)
|
||
|
||
|
||
def test_u_wall_description_with_malformed_transmittance_falls_through_to_cascade() -> None:
|
||
# Arrange — a description containing the phrase but a malformed value
|
||
# (e.g. just a stray dot) should NOT short-circuit to a parse failure;
|
||
# it should fall through to the construction cascade and return a
|
||
# spec-defined value. This is the calculator's "trust the cert when
|
||
# parseable, never raise" contract.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="G",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=0,
|
||
description="Average thermal transmittance . W/m²K",
|
||
)
|
||
|
||
# Assert — Table 6 cavity-as-built row at band G = 0.60 W/m²K.
|
||
assert result == pytest.approx(0.60, abs=0.001)
|
||
|
||
|
||
def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote() -> None:
|
||
# Arrange — 685 corpus certs lodge solid-brick walls with
|
||
# wall_insulation_type ∈ {1 external, 3 internal} and
|
||
# wall_insulation_thickness="NI" (Not Indicated). RdSAP 10 Table 6
|
||
# footnote: "If a wall is known to have additional insulation but
|
||
# the insulation thickness is unknown, use the row in the table for
|
||
# 50 mm insulation." Our `_parse_thickness_mm("NI")` returns 0, which
|
||
# combined with `insulation_present=True` must now route to the 50 mm
|
||
# bucket (U=0.55 at A-E), not the as-built bucket (U=1.7).
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=WALL_SOLID_BRICK,
|
||
insulation_thickness_mm=0,
|
||
insulation_present=True,
|
||
)
|
||
|
||
# Assert — Stone/solid brick with 50 mm row at band B = 0.55.
|
||
assert result == pytest.approx(0.55, abs=0.001)
|
||
|
||
|
||
def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() -> None:
|
||
# Arrange — 1 171 corpus certs (~4% of scanned bulk) lodge
|
||
# wall_insulation_type=4 ("as-built / assumed") together with the
|
||
# description "Cavity wall, as built, insulated (assumed)". The
|
||
# assessor is saying: this cavity is filled, but I haven't measured
|
||
# the thickness. Spec footnote on Table 6 covers this: "If a wall
|
||
# is known to have additional insulation but the insulation thickness
|
||
# is unknown, use the row in the table for 50 mm insulation" — but
|
||
# legacy convention (used by the production recommendation engine)
|
||
# is to route this to the Filled-cavity row, U = 0.7 at A-E. We
|
||
# follow the legacy convention here for parity with the cert assessor.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False, # type=4 maps to wall_ins_present=False
|
||
wall_insulation_type=4,
|
||
description="Cavity wall, as built, insulated (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.7, abs=0.001)
|
||
|
||
|
||
def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_row() -> None:
|
||
# Arrange — the same wall_insulation_type=4 ("as-built / assumed")
|
||
# cert population also contains 686 "Cavity wall, as built, no
|
||
# insulation (assumed)" entries which must continue to route to the
|
||
# Cavity-as-built row of Table 6 (U=1.5 at band E). The "no
|
||
# insulation" substring marker takes precedence over the
|
||
# "insulated"-substring filled-cavity rule, so this case is
|
||
# disambiguated from "Cavity wall, as built, insulated (assumed)".
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=4,
|
||
description="Cavity wall, as built, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.5, abs=0.001)
|
||
|
||
|
||
def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() -> None:
|
||
# Arrange — 147 corpus certs lodge "Cavity wall, as built, partial
|
||
# insulation (assumed)" with wall_insulation_type=4. The legacy
|
||
# production map (recommendations/rdsap_tables.py:753) routes these
|
||
# to "Filled cavity" — same destination as the "insulated (assumed)"
|
||
# case. We match that interpretation for parity with the cert
|
||
# assessor and the production recommendation engine.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="D", # 1950-1966 — typical partial-fill retrofit cohort
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=4,
|
||
description="Cavity wall, as built, partial insulation (assumed)",
|
||
)
|
||
|
||
# Assert — Filled-cavity row at band D = 0.7 W/m²K.
|
||
assert result == pytest.approx(0.7, abs=0.001)
|
||
|
||
|
||
def test_u_wall_description_without_transmittance_phrase_routes_through_cascade() -> None:
|
||
# Arrange — the measured-U dispatcher must only fire when the
|
||
# description contains the "thermal transmittance" phrase. The
|
||
# ordinary surveyor-text descriptions (e.g. "Cavity wall, filled
|
||
# cavity") must still route through the construction cascade.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=0,
|
||
insulation_present=True,
|
||
wall_insulation_type=WALL_INSULATION_FILLED_CAVITY,
|
||
description="Cavity wall, filled cavity",
|
||
)
|
||
|
||
# Assert — should return the Filled-cavity row value, not anything
|
||
# parsed out of the description.
|
||
assert result == pytest.approx(0.7, abs=0.001)
|
||
|
||
|
||
def test_u_wall_filled_cavity_england_age_band_e_returns_table6_value() -> None:
|
||
# Arrange — RdSAP 10 Table 6 (England) row "Filled cavity", age band E
|
||
# (1967-1975) -> 0.7 W/m^2K. The cert records this as the triple
|
||
# (wall_construction=4 cavity, wall_insulation_type=2 filled,
|
||
# wall_insulation_thickness="NI"). Spec: domain/sap10_calculator/docs/specs/rdsap-10-
|
||
# specification-2025-06-10.pdf page 33.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=0,
|
||
insulation_present=True,
|
||
wall_insulation_type=WALL_INSULATION_FILLED_CAVITY,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.7, abs=0.001)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"age_band,expected_u",
|
||
[
|
||
# RdSAP 10 Table 6 (England) "Filled cavity" row sampled at three bands:
|
||
# A (pre-1900) = 0.7 — early cavity dwellings, retro-filled.
|
||
# F (1976-1982) = 0.40 — first cavity-insulation era.
|
||
# K (2007+) = 0.30 — "assumed as built" (†) — matches Cavity-as-built K.
|
||
("A", 0.7),
|
||
("F", 0.40),
|
||
("K", 0.30),
|
||
],
|
||
)
|
||
def test_u_wall_filled_cavity_england_row_matches_table6_across_age_bands(
|
||
age_band: str, expected_u: float
|
||
) -> None:
|
||
# Arrange — the dispatcher must return the right cell of the
|
||
# "Filled cavity" row, not just the band-E value used by the tracer.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band=age_band,
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=0,
|
||
insulation_present=True,
|
||
wall_insulation_type=WALL_INSULATION_FILLED_CAVITY,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(expected_u, abs=0.001)
|
||
|
||
|
||
def test_u_wall_unfilled_cavity_england_age_band_e_unchanged_at_1_5() -> None:
|
||
# Arrange — adding the filled-cavity dispatcher must not regress the
|
||
# existing as-built path. Band E + cavity construction + no insulation
|
||
# type set -> the "Cavity as built" row of Table 6, U = 1.5 W/m^2K.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=0,
|
||
insulation_present=False,
|
||
wall_insulation_type=None,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.5, abs=0.001)
|
||
|
||
|
||
def test_u_wall_cavity_as_built_england_age_band_g_returns_table6_value() -> None:
|
||
# Arrange — Table 6, England, Cavity as built, age band G -> 0.60 W/m^2K.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="G",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=0,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.60, abs=0.001)
|
||
|
||
|
||
def test_u_wall_solid_brick_with_100mm_insulation_age_band_e_returns_table6_value() -> None:
|
||
# Arrange — Table 6, England, Solid brick with 100mm insulation, age band E -> 0.32 W/m^2K.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_SOLID_BRICK,
|
||
insulation_thickness_mm=100,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.32, abs=0.001)
|
||
|
||
|
||
def test_u_wall_scotland_age_band_m_returns_country_specific_table7_value() -> None:
|
||
# Arrange — Scotland's Table 7 has tighter age-M U-values (0.17 vs England's 0.26).
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.SCT,
|
||
age_band="M",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=0,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.17, abs=0.001)
|
||
|
||
|
||
def test_u_wall_timber_frame_as_built_age_band_a_returns_table6_value() -> None:
|
||
# Arrange — Timber frame as built, age A, England -> 2.5 W/m^2K.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="A",
|
||
construction=WALL_TIMBER_FRAME,
|
||
insulation_thickness_mm=0,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.5, abs=0.001)
|
||
|
||
|
||
def test_u_wall_falls_back_to_age_band_default_when_construction_unknown() -> None:
|
||
# Arrange — construction missing; falls back to cavity-typical for age band G.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="G",
|
||
construction=None,
|
||
insulation_thickness_mm=0,
|
||
)
|
||
|
||
# Assert — cavity-as-built for G is 0.60 (matches RdSAP "assume as-built" rule).
|
||
assert result == pytest.approx(0.60, abs=0.001)
|
||
|
||
|
||
def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> None:
|
||
# Arrange — no signal at all.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=None,
|
||
age_band=None,
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
)
|
||
|
||
# Assert — mid-range fallback ~1.5 (Cavity-as-built mid-band E typical).
|
||
assert result == pytest.approx(1.5, abs=0.001)
|
||
|
||
|
||
def test_u_wall_description_sandstone_overrides_cavity_default_for_age_e() -> None:
|
||
# Arrange — construction integer is missing on the cert. _DEFAULT_WALL_BY_AGE
|
||
# would pick cavity for age E (1.0 W/m^2K uninsulated), but the surveyor's
|
||
# walls[i].description clearly identifies sandstone -> 1.7 W/m^2K.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
description="Sandstone or limestone, as built, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.7, abs=0.001)
|
||
|
||
|
||
def test_u_wall_description_granite_or_whinstone_picks_stone_default() -> None:
|
||
# Arrange — Scotland whinstone (granite-family) walls, age D, construction
|
||
# null. Should resolve to STONE_GRANITE uninsulated -> 1.7 (age D index 3).
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="D",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
description="Granite or whinstone, as built",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.7, abs=0.001)
|
||
|
||
|
||
def test_u_wall_description_solid_brick_picks_solid_brick_default() -> None:
|
||
# Arrange — construction null, description names solid brick. For age E
|
||
# uninsulated solid brick -> 1.7 W/m^2K (vs cavity 1.0).
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
description="Solid brick, as built, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.7, abs=0.001)
|
||
|
||
|
||
def test_u_wall_explicit_construction_beats_description() -> None:
|
||
# Arrange — wall_construction integer is cavity (4); ignore any conflicting
|
||
# description text. Cavity-as-built age E -> 1.5 W/m^2K, NOT stone's 1.7.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=None,
|
||
description="Granite, as built", # ignored
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.5, abs=0.001)
|
||
|
||
|
||
def test_u_wall_description_unmatched_falls_back_to_age_band_default() -> None:
|
||
# Arrange — construction null, description says nothing recognisable.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
description="something unparseable",
|
||
)
|
||
|
||
# Assert — cavity default for age E uninsulated -> 1.5 W/m^2K.
|
||
assert result == pytest.approx(1.5, abs=0.001)
|
||
|
||
|
||
def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_unknown() -> None:
|
||
# Arrange — RdSAP10 footnote: if wall is known insulated but thickness unknown, use 50mm row.
|
||
# System built with 50mm insulation, England, age band G -> 0.35 W/m^2K.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="G",
|
||
construction=WALL_SYSTEM_BUILT,
|
||
insulation_thickness_mm=None, # unknown but insulation present
|
||
insulation_present=True,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.35, abs=0.001)
|
||
|
||
|
||
# ----- Roofs -----
|
||
|
||
|
||
def test_u_roof_description_with_measured_transmittance_returns_parsed_value() -> None:
|
||
# Arrange — ~1 140 corpus certs lodge a full-SAP measured roof
|
||
# U-value in the description, e.g. "Average thermal transmittance
|
||
# 0.11 W/m²K". The age-band cascade is bypassed: the assessor's
|
||
# measured/calculated value is used directly. Same contract as
|
||
# `u_wall` (S-B24) and `u_floor` (S-B29 cycle 1).
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=None,
|
||
description="Average thermal transmittance 0.11 W/m²K",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.11, abs=0.001)
|
||
|
||
|
||
def test_u_roof_ni_thickness_with_insulated_description_applies_50mm_per_section_5_11_4() -> None:
|
||
# Arrange — 346 corpus certs lodge roof_insulation_thickness="NI"
|
||
# (Not Indicated, parsed to 0 by _parse_thickness_mm). When the
|
||
# description also signals retrofit insulation ("Pitched, insulated
|
||
# (assumed)" / "Flat, insulated" / "Roof room(s), insulated
|
||
# (assumed)"), RdSAP 10 §5.11.4 (page 44) footnote applies:
|
||
# "If retrofit insulation present of unknown thickness use 50 mm".
|
||
# That maps to Table 16 row "50 mm at joists at ceiling level" = 0.68
|
||
# W/m²K — vs the current 2.30 we return when thickness=0 hits the
|
||
# Table 16 row-0 lookup.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
description="Pitched, insulated (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.68, abs=0.01)
|
||
|
||
|
||
def test_u_roof_ni_thickness_with_no_insulation_description_stays_at_2_30() -> None:
|
||
# Arrange — 706 corpus certs lodge "Pitched, no insulation
|
||
# (assumed)" which can co-occur with thickness="NI". The
|
||
# description-based override for retrofit-insulated roofs must
|
||
# respect the "no insulation" negation: `_described_as_insulated`
|
||
# returns False on "no insulation" substring, so the Table 16
|
||
# row-0 lookup applies and U = 2.30 W/m²K stays.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=0,
|
||
description="Pitched, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.01)
|
||
|
||
|
||
def test_u_roof_age_band_j_pitched_returns_table18_value() -> None:
|
||
# Arrange — Table 18, pitched insulation between joists, age J -> 0.16 W/m^2K.
|
||
|
||
# Act
|
||
result = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.16, abs=0.001)
|
||
|
||
|
||
def test_u_roof_with_explicit_insulation_thickness_uses_table16() -> None:
|
||
# Arrange — Table 16 joist insulation 200mm -> 0.21 W/m^2K.
|
||
|
||
# Act
|
||
result = u_roof(country=Country.ENG, age_band="G", insulation_thickness_mm=200)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.21, abs=0.001)
|
||
|
||
|
||
def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
|
||
# Arrange — nothing known.
|
||
|
||
# Act
|
||
result = u_roof(country=None, age_band=None, insulation_thickness_mm=None)
|
||
|
||
# Assert — mid-range default ~0.4 (Table 18 age G typical).
|
||
assert result == pytest.approx(0.4, abs=0.001)
|
||
|
||
|
||
def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None:
|
||
# Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof":
|
||
# age band D, thickness unknown → U = 2.30 W/m²K. Column (1)
|
||
# (pitched-between-joists default) returns 0.40 for the same age
|
||
# band; routing must pick column (3) when the per-bp roof
|
||
# construction lodges as flat.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="D", insulation_thickness_mm=None,
|
||
is_flat_roof=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 2.30) <= 1e-4
|
||
|
||
|
||
def test_u_roof_flat_age_band_g_returns_table18_col3_value() -> None:
|
||
# Arrange — Table 18 column (3) flat-roof default is 0.40 for age G,
|
||
# the cross-over point where the flat-roof and pitched-roof columns
|
||
# agree. Confirms the dict is populated across the full age range.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="G", insulation_thickness_mm=None,
|
||
is_flat_roof=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.40) <= 1e-4
|
||
|
||
|
||
def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None:
|
||
# Arrange — Table 18 column (3) flat-roof default is 0.18 for age L,
|
||
# the modern band where both columns agree.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="L", insulation_thickness_mm=None,
|
||
is_flat_roof=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.18) <= 1e-4
|
||
|
||
|
||
def test_u_roof_description_no_insulation_overrides_age_band_default() -> None:
|
||
# Arrange — surveyor description on a Victorian roof says uninsulated;
|
||
# Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm
|
||
# joist insulation is 2.30 W/m^2K.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=None,
|
||
description="Pitched, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.001)
|
||
|
||
|
||
def test_u_roof_description_limited_insulation_overrides_age_band_default() -> None:
|
||
# Arrange — "limited insulation" maps to Table 16 row 12mm -> 1.50 W/m^2K.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="D",
|
||
insulation_thickness_mm=None,
|
||
description="Roof room(s), limited insulation",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.50, abs=0.001)
|
||
|
||
|
||
def test_u_roof_description_uninsulated_synonym_also_triggers_high_u() -> None:
|
||
# Arrange — surveyor writes "uninsulated" (no space) instead of "no insulation".
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=None,
|
||
description="Flat, uninsulated",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.001)
|
||
|
||
|
||
def test_u_roof_description_well_insulated_does_not_override_default() -> None:
|
||
# Arrange — description says "insulated"; do NOT override the Table 18
|
||
# age-G default of 0.40 with a penalty.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="G",
|
||
insulation_thickness_mm=None,
|
||
description="Pitched, insulated at rafters",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.40, abs=0.001)
|
||
|
||
|
||
def test_u_roof_explicit_thickness_beats_description() -> None:
|
||
# Arrange — when surveyor measured 200mm joist insulation, Table 16 wins
|
||
# regardless of any description text. 200mm -> 0.21 W/m^2K.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=200,
|
||
description="No insulation", # ignored because thickness is explicit
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.21, abs=0.001)
|
||
|
||
|
||
# ----- Floors -----
|
||
|
||
|
||
def test_u_floor_description_with_measured_transmittance_returns_parsed_value() -> None:
|
||
# Arrange — ~1 391 corpus certs lodge a full-SAP measured floor
|
||
# U-value in the description, e.g. "Average thermal transmittance
|
||
# 0.18 W/m²K". The BS EN ISO 13370 calculation is bypassed: the
|
||
# assessor's measured/calculated value is used directly. Same
|
||
# contract as `u_wall` (S-B24).
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
area_m2=100.0,
|
||
perimeter_m=40.0,
|
||
wall_thickness_mm=300,
|
||
description="Average thermal transmittance 0.18 W/m²K",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.18, abs=0.001)
|
||
|
||
|
||
def test_u_floor_ni_thickness_with_insulated_description_applies_50mm_per_table19_footnote() -> None:
|
||
# Arrange — 2 413 corpus certs (~12%) lodge floors with
|
||
# floor_insulation_thickness="NI" (Not Indicated, which our
|
||
# _parse_thickness_mm returns as 0) AND a description "Solid,
|
||
# insulated (assumed)" or "Suspended, insulated (assumed)". The
|
||
# assessor sees insulation but hasn't measured the thickness.
|
||
# RdSAP 10 §5.12 Table 19 footnote (2):
|
||
# "For floors which have retrofitted insulation, use the greater
|
||
# of 50 mm and the thickness according to the age band."
|
||
# Band B's age-band default is 0 mm, so max(50, 0) = 50 mm applies.
|
||
# Geometry: 100 m² × 40 m perimeter, w=0.3, gives B=5, d_t=2.758
|
||
# (with R_f from 50 mm/0.035 = 1.429); U = 2 × 1.5 × ln(π×5/2.758 + 1)
|
||
# / (π×5 + 2.758) ≈ 0.31 W/m²K.
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=None,
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
area_m2=100.0,
|
||
perimeter_m=40.0,
|
||
wall_thickness_mm=300,
|
||
description="Solid, insulated (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.31, abs=0.02)
|
||
|
||
|
||
def test_u_floor_ni_thickness_with_no_insulation_description_stays_uninsulated() -> None:
|
||
# Arrange — 8 221 corpus certs lodge "Solid, no insulation
|
||
# (assumed)" with thickness="NI". The Table 19 footnote (2) override
|
||
# must not fire on these: the "no insulation" substring takes
|
||
# precedence over the "insulated" substring per
|
||
# `_described_as_insulated`. Same geometry as the cycle-1 test;
|
||
# uninsulated U should be ~0.60 W/m²K (B=5, d_t=0.615 with R_f=0).
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=None,
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
area_m2=100.0,
|
||
perimeter_m=40.0,
|
||
wall_thickness_mm=300,
|
||
description="Solid, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.60, abs=0.02)
|
||
|
||
|
||
def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() -> None:
|
||
# Arrange — solid floor, age C, England.
|
||
# BS EN ISO 13370 with A=80, P=36, w=0.22m, soil g=1.5, Rsi=0.17, Rse=0.04, Rf=0
|
||
# d_t = 0.22 + 1.5 * (0.17 + 0 + 0.04) = 0.535
|
||
# B = 2 * 80 / 36 = 4.444
|
||
# d_t < B so U = 2 * 1.5 * ln(pi*B/d_t + 1) / (pi*B + d_t)
|
||
# = 3 * ln(pi*4.444/0.535 + 1) / (pi*4.444 + 0.535)
|
||
# = 3 * ln(27.10) / (14.49)
|
||
# = 3 * 3.300 / 14.49 = 0.683 -> rounds to 0.68
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
area_m2=80.0,
|
||
perimeter_m=36.0,
|
||
wall_thickness_mm=220,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.68, abs=0.05)
|
||
|
||
|
||
def test_u_floor_age_b_unknown_construction_uses_suspended_timber_per_table_19_footnote_1() -> None:
|
||
# Arrange — RdSAP10 §5.12 Table 19 footnote (1) routes age A, B with
|
||
# unknown floor_construction to the suspended-timber branch. Geometry
|
||
# is taken from Elmhurst worksheet U985-0001-000490 Main Dwelling
|
||
# (A=14.85, P=7.42, w=0.400) — the worksheet records U=0.71 W/m²K,
|
||
# confirming the suspended-floor formula on §5.12 (page 46) is the
|
||
# one Elmhurst applies for this fixture.
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
area_m2=14.85,
|
||
perimeter_m=7.42,
|
||
wall_thickness_mm=400,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.71, abs=0.01)
|
||
|
||
|
||
def test_u_floor_with_insulation_lowers_u_value() -> None:
|
||
# Arrange — same geometry but with 100mm insulation -> R_f = 0.1/0.035 = 2.857.
|
||
|
||
# Act
|
||
insulated = u_floor(
|
||
country=Country.ENG,
|
||
age_band="K",
|
||
construction=None,
|
||
insulation_thickness_mm=100,
|
||
area_m2=80.0,
|
||
perimeter_m=36.0,
|
||
wall_thickness_mm=220,
|
||
)
|
||
|
||
# Assert — well below uninsulated case (~0.27 W/m^2K).
|
||
assert insulated < 0.3
|
||
|
||
|
||
def test_u_exposed_floor_age_b_unknown_insulation_uses_table_20_row_a_to_g() -> None:
|
||
# Arrange — RdSAP10 §5.13 Table 20 (page 47) gives U-values for
|
||
# exposed and semi-exposed upper floors keyed on age band +
|
||
# insulation thickness. The "Insulation unknown or as built"
|
||
# column at age band A-G = 1.20 W/m²K. Elmhurst worksheet
|
||
# U985-0001-000490 Extension 1 records U=1.20 for its exposed
|
||
# timber floor (1900-1929, no insulation lodged) — this lookup
|
||
# reproduces that exact value without any geometry input.
|
||
|
||
# Act
|
||
result = u_exposed_floor(age_band="B", insulation_thickness_mm=None)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.20, abs=0.001)
|
||
|
||
|
||
def test_u_floor_falls_back_to_mid_range_when_geometry_unknown() -> None:
|
||
# Arrange — geometry missing.
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=None,
|
||
age_band=None,
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
area_m2=None,
|
||
perimeter_m=None,
|
||
wall_thickness_mm=None,
|
||
)
|
||
|
||
# Assert — mid-range fallback ~0.7 W/m^2K (solid-uninsulated mid-band typical).
|
||
assert result == pytest.approx(0.7, abs=0.05)
|
||
|
||
|
||
# ----- Windows -----
|
||
|
||
|
||
def test_u_window_single_glazed_pvc_returns_table24_value() -> None:
|
||
# Arrange — Table 24: single glazing, any period, PVC/wooden frame -> 4.8 W/m^2K.
|
||
|
||
# Act
|
||
result = u_window(installed_year=None, glazing_type="single", frame_type="pvc")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(4.8, abs=0.001)
|
||
|
||
|
||
def test_u_window_post_2022_pvc_returns_low_table24_value() -> None:
|
||
# Arrange — Table 24: double or triple glazed, 2022 or later, PVC -> 1.4 W/m^2K.
|
||
|
||
# Act
|
||
result = u_window(installed_year=2023, glazing_type="double", frame_type="pvc")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.4, abs=0.001)
|
||
|
||
|
||
def test_u_window_falls_back_to_mid_range_when_unknown() -> None:
|
||
# Arrange — nothing known.
|
||
|
||
# Act
|
||
result = u_window(installed_year=None, glazing_type=None, frame_type=None)
|
||
|
||
# Assert — mid-range default ~2.5 (pre-2002 double glazed PVC typical).
|
||
assert result == pytest.approx(2.5, abs=0.5)
|
||
|
||
|
||
# ----- Doors -----
|
||
|
||
|
||
def test_u_door_age_band_a_uninsulated_returns_table26_value() -> None:
|
||
# Arrange — Table 26: age A-J unisulated -> 3.0 W/m^2K.
|
||
|
||
# Act
|
||
result = u_door(country=Country.ENG, age_band="A", insulated=False, insulated_u_value=None)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(3.0, abs=0.001)
|
||
|
||
|
||
def test_u_door_age_band_m_uninsulated_returns_lower_table26_value() -> None:
|
||
# Arrange — Table 26: age M -> 1.4 W/m^2K.
|
||
|
||
# Act
|
||
result = u_door(country=Country.ENG, age_band="M", insulated=False, insulated_u_value=None)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.4, abs=0.001)
|
||
|
||
|
||
def test_u_door_insulated_uses_explicit_u_value_when_supplied() -> None:
|
||
# Arrange — door declared insulated with U-value 1.0 from cert.
|
||
|
||
# Act
|
||
result = u_door(country=Country.ENG, age_band="C", insulated=True, insulated_u_value=1.0)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.0, abs=0.001)
|
||
|
||
|
||
# ----- Party walls -----
|
||
|
||
|
||
def test_u_party_wall_solid_masonry_returns_zero() -> None:
|
||
# Arrange — Table 15: solid masonry / timber frame / system built -> 0.0 W/m^2K.
|
||
|
||
# Act
|
||
result = u_party_wall(party_wall_construction=WALL_SOLID_BRICK)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.0, abs=0.001)
|
||
|
||
|
||
def test_u_party_wall_unfilled_cavity_returns_table15_value() -> None:
|
||
# Arrange — Table 15: cavity masonry unfilled -> 0.5 W/m^2K.
|
||
|
||
# Act
|
||
result = u_party_wall(party_wall_construction=WALL_CAVITY)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.5, abs=0.001)
|
||
|
||
|
||
def test_u_party_wall_unknown_returns_table15_house_default() -> None:
|
||
# Arrange — Table 15: unable to determine, house -> 0.25 W/m^2K.
|
||
|
||
# Act
|
||
result = u_party_wall(party_wall_construction=None)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.25, abs=0.001)
|
||
|
||
|
||
# ----- Thermal bridging -----
|
||
|
||
|
||
def test_thermal_bridging_y_age_band_g_returns_table21_value() -> None:
|
||
# Arrange — Table 21: ages A-I -> 0.15.
|
||
|
||
# Act
|
||
result = thermal_bridging_y(age_band="G")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.15, abs=0.001)
|
||
|
||
|
||
def test_thermal_bridging_y_age_band_j_returns_table21_value() -> None:
|
||
# Arrange — Table 21: age J -> 0.11.
|
||
|
||
# Act
|
||
result = thermal_bridging_y(age_band="J")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.11, abs=0.001)
|
||
|
||
|
||
def test_thermal_bridging_y_age_band_l_returns_table21_value() -> None:
|
||
# Arrange — Table 21: ages K, L, M -> 0.08.
|
||
|
||
# Act
|
||
result = thermal_bridging_y(age_band="L")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.08, abs=0.001)
|
||
|
||
|
||
def test_thermal_bridging_y_unknown_age_band_returns_mid_range() -> None:
|
||
# Arrange — age unknown.
|
||
|
||
# Act
|
||
result = thermal_bridging_y(age_band=None)
|
||
|
||
# Assert — mid-range fallback ~0.15 (the most common value across age bands).
|
||
assert result == pytest.approx(0.15, abs=0.001)
|
||
|
||
|
||
def test_country_unknown_string_falls_back_to_england() -> None:
|
||
# Arrange — Country.from_code('XX') -> Country.ENG.
|
||
|
||
# Act
|
||
result = Country.from_code("XX")
|
||
|
||
# Assert
|
||
assert result is Country.ENG
|
||
|
||
|
||
def test_country_from_code_recognises_known_codes() -> None:
|
||
# Arrange / Act / Assert
|
||
assert Country.from_code("ENG") is Country.ENG
|
||
assert Country.from_code("WAL") is Country.WAL
|
||
assert Country.from_code("SCT") is Country.SCT
|
||
assert Country.from_code("NIR") is Country.NIR
|
||
assert Country.from_code("EAW") is Country.ENG # England-and-Wales aggregate maps to ENG
|
||
|
||
|
||
def test_u_rr_default_all_elements_age_band_b_returns_table18_col4_value() -> None:
|
||
"""RdSAP10 §5.11.4 + Table 18 column (4) — "Room-in-roof, all elements"
|
||
as-built / unknown default. Age band B (1900-1929) → 2.30 W/m²K (the
|
||
uninsulated row carries footnote (1): "value from the table applies
|
||
for unknown and as built")."""
|
||
# Arrange / Act
|
||
result = u_rr_default_all_elements(country=Country.ENG, age_band="B")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.001)
|
||
|
||
|
||
def test_u_rr_default_all_elements_table18_col4_matches_spec_across_age_bands() -> None:
|
||
"""Table 18 column (4) per RdSAP10 spec page 45:
|
||
A-D 2.30, E 1.50, F 0.80, G 0.50, H 0.35, I 0.35, J 0.30,
|
||
K 0.25, L 0.18, M 0.15.
|
||
"""
|
||
# Arrange — expected RR-all-elements U-values for England.
|
||
expected = {
|
||
"A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30,
|
||
"E": 1.50, "F": 0.80, "G": 0.50, "H": 0.35,
|
||
"I": 0.35, "J": 0.30, "K": 0.25, "L": 0.18, "M": 0.15,
|
||
}
|
||
|
||
# Act / Assert
|
||
for age_band, want in expected.items():
|
||
got = u_rr_default_all_elements(country=Country.ENG, age_band=age_band)
|
||
assert got == pytest.approx(want, abs=0.001), (
|
||
f"age={age_band}: got {got}, want {want}"
|
||
)
|
||
|
||
|
||
def test_u_rr_default_all_elements_scotland_age_band_k_returns_0_20_per_footnote() -> None:
|
||
"""Table 18 footnote (2): "0.20 W/m²K in Scotland" applies to the
|
||
age band K row of column (4). Other age bands unchanged."""
|
||
# Arrange / Act
|
||
result = u_rr_default_all_elements(country=Country.SCT, age_band="K")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.20, abs=0.001)
|
||
|
||
|
||
def test_u_rr_default_all_elements_unknown_age_band_falls_back_to_mid_range() -> None:
|
||
"""Robustness: no age band → return the mid-range default rather than
|
||
raising. Picks the column (4) value at age G (0.50) as a sensible
|
||
middle estimate, matching the cascade convention used by `u_roof`."""
|
||
# Arrange / Act
|
||
result = u_rr_default_all_elements(country=None, age_band=None)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.50, abs=0.001)
|
||
|
||
|
||
# ----- Room-in-roof Table 17 lookups (insulation thickness known) -----
|
||
|
||
|
||
def test_u_rr_slope_table17_col1a_mineral_wool_100mm_returns_0_40() -> None:
|
||
"""RdSAP10 §5.11.3 + Table 17 column (1a): "Insulated slope - sloping
|
||
ceiling, mineral wool or EPS slab" 100 mm row → 0.40 W/m²K."""
|
||
# Arrange / Act
|
||
result = u_rr_slope(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=100,
|
||
insulation_type="mineral_wool",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.40, abs=0.001)
|
||
|
||
|
||
def test_u_rr_slope_table17_col1b_pur_pir_100mm_returns_0_30() -> None:
|
||
"""Table 17 column (1b): "Insulated slope - sloping ceiling, PUR or
|
||
PIR optional" 100 mm row → 0.30 W/m²K. The PUR/PIR rigid foam route
|
||
gives a tighter U than mineral wool at the same thickness."""
|
||
# Arrange / Act
|
||
result = u_rr_slope(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=100,
|
||
insulation_type="pir",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.30, abs=0.001)
|
||
|
||
|
||
def test_u_rr_flat_ceiling_table17_col2a_mineral_wool_100mm_returns_0_54() -> None:
|
||
"""Table 17 column (2a): "Insulated slope - flat ceiling, mineral wool
|
||
or EPS slab" 100 mm row → 0.54 W/m²K."""
|
||
# Arrange / Act
|
||
result = u_rr_flat_ceiling(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=100,
|
||
insulation_type="mineral_wool",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.54, abs=0.001)
|
||
|
||
|
||
def test_u_rr_stud_wall_table17_col3a_mineral_wool_100mm_returns_0_36() -> None:
|
||
"""Table 17 column (3a): "Stud wall u-value For Room in Roof, mineral
|
||
wool or EPS slab" 100 mm row → 0.36 W/m²K. (Used by the U985 worksheet
|
||
for 000477's RR stud walls.)"""
|
||
# Arrange / Act
|
||
result = u_rr_stud_wall(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=100,
|
||
insulation_type="mineral_wool",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.36, abs=0.001)
|
||
|
||
|
||
def test_u_rr_slope_table17_none_row_uninsulated_returns_2_30() -> None:
|
||
"""Table 17 "none" row (every column collapses to 2.3 when no
|
||
insulation). Used by the U985 worksheet for 000477's RR slope panels
|
||
that lodge as uninsulated."""
|
||
# Arrange / Act
|
||
result = u_rr_slope(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=0,
|
||
insulation_type="mineral_wool",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.001)
|
||
|
||
|
||
def test_u_rr_flat_ceiling_table17_col2b_pir_over_400mm_returns_0_09() -> None:
|
||
"""Table 17 row ">400 mm" column (2b) PUR/PIR → 0.09 W/m²K. The U985
|
||
worksheet for 000477 lodges 0.14 for "External roof Main" which is
|
||
Table 17 col (2a) row >400 (mineral wool) — but this test uses the
|
||
PIR column for completeness."""
|
||
# Arrange / Act
|
||
result = u_rr_flat_ceiling(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=450,
|
||
insulation_type="pir",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.09, abs=0.001)
|
||
|
||
|
||
def test_u_rr_slope_unknown_thickness_falls_back_to_table18_all_elements() -> None:
|
||
"""When `insulation_thickness_mm is None`, Table 17 doesn't apply and
|
||
we cascade to Table 18 col (4) "Room-in-roof, all elements" by age
|
||
band — same fallback as the spec text at §5.11.3 / §5.11.4.
|
||
|
||
For age band B, that's the 2.30 W/m²K uninsulated default."""
|
||
# Arrange / Act
|
||
result = u_rr_slope(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=None,
|
||
insulation_type="mineral_wool",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.001)
|
||
|
||
|
||
def test_u_rr_stud_wall_thickness_125mm_takes_nearest_tabulated_row_below() -> None:
|
||
"""Table 17 row alignment: an arbitrary thickness picks the nearest
|
||
tabulated row ≤ supplied (the same convention `u_roof` uses against
|
||
Table 16). 125 mm matches the exact row → col (3a) = 0.31 W/m²K."""
|
||
# Arrange / Act
|
||
result = u_rr_stud_wall(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=125,
|
||
insulation_type="mineral_wool",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.31, abs=0.001)
|
||
|
||
|
||
# ----- Description-cascade cohort pins (Walls) -----
|
||
#
|
||
# The Elmhurst worksheet fixtures lodge `walls=[]` and so cannot exercise
|
||
# the description-driven branches of `u_wall`. The 8 golden API certs DO
|
||
# carry `walls[0].description` strings that route through `_described_as_
|
||
# insulated`, `_wall_type_from_description`, and the Table 6 footnote
|
||
# 50 mm-bucket override. These tests pin every (description, age) pair
|
||
# seen in that cohort against the RdSAP10 Table 6 (England) value the
|
||
# spec mandates — closing the cascade-coverage gap identified during the
|
||
# 2026-05-24 audit (description cascade was 100% spec-correct on clean
|
||
# Table 6 rows; this test locks that in for regression).
|
||
#
|
||
# Cases routing through §5.7 (solid brick from wall thickness) or §5.8
|
||
# (stone/brick with insulation, ages A–D — formula not table) are
|
||
# intentionally excluded — they need separate pinning when those
|
||
# formulas land.
|
||
_TABLE_6_ENG_WALL_COHORT_PINS: tuple[tuple[str, str, Optional[int], Optional[int], Optional[int], bool, float], ...] = (
|
||
# (description, age_band, wall_construction, wall_insulation_type,
|
||
# insulation_thickness_mm, insulation_present, expected_u_w_per_m2k)
|
||
# `insulation_present` mirrors the heat_transmission cascade: type != 4 (NONE)
|
||
# OR description asserts insulation per _described_as_insulated.
|
||
("Sandstone, as built, insulated (assumed)", "J", 2, 4, 0, True, 0.25), # cert 0240 (50 mm bucket per footnote)
|
||
("Cavity wall, filled cavity", "C", 4, 2, 0, True, 0.7), # cert 8135 bp0
|
||
("Cavity wall, filled cavity", "D", 4, 2, 0, True, 0.7), # cert 0300, 7536 bp0
|
||
("Cavity wall, filled cavity", "F", 4, 2, 0, True, 0.40), # cert 7536 bp2
|
||
("Cavity wall, filled cavity", "G", 4, 2, 0, True, 0.35), # cert 8135 bp1
|
||
("Cavity wall, filled cavity", "L", 4, 4, 0, False, 0.28), # cert 7536 bp1 (assumed-as-built †)
|
||
("Cavity wall, as built, no insulation (assumed)", "D", 4, 4, 0, False, 1.5), # cert 0390-2954, 9390
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"description, age_band, construction, insulation_type, thickness_mm, insulation_present, expected_u",
|
||
_TABLE_6_ENG_WALL_COHORT_PINS,
|
||
)
|
||
def test_u_wall_matches_table6_for_every_cohort_description_age_pair(
|
||
description: str,
|
||
age_band: str,
|
||
construction: Optional[int],
|
||
insulation_type: Optional[int],
|
||
thickness_mm: Optional[int],
|
||
insulation_present: bool,
|
||
expected_u: float,
|
||
) -> None:
|
||
# Arrange — inputs replicate what `heat_transmission_from_cert` feeds
|
||
# `u_wall` for the corresponding building part in the golden cert cohort.
|
||
|
||
# Act
|
||
u = u_wall(
|
||
country=Country.ENG,
|
||
age_band=age_band,
|
||
construction=construction,
|
||
insulation_thickness_mm=thickness_mm,
|
||
insulation_present=insulation_present,
|
||
description=description,
|
||
wall_insulation_type=insulation_type,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(u - expected_u) < 1e-4
|
||
|
||
|
||
# ----- Description-cascade cohort pins (Roofs) -----
|
||
#
|
||
# Mirror of the wall cohort pins above. The Elmhurst worksheet fixtures
|
||
# lodge `roofs=[]`, so the cascade-pin tests do not exercise u_roof's
|
||
# description path either. The 8 golden API certs lodge `roofs[].
|
||
# description` strings — these tests pin each (description, age,
|
||
# thickness) tuple seen in that cohort against the RdSAP10 Table 16
|
||
# (loft-insulation-thickness-known) value the spec mandates.
|
||
#
|
||
# Excluded: ambiguous joined-description cases where one bp lodges no
|
||
# thickness and another lodges a value — the calc routes through Table
|
||
# 18 defaults whose interaction with the description cascade needs
|
||
# separate pinning. "(another dwelling above)" is also excluded — its
|
||
# u_roof value is ignored by heat_transmission once roof_area is zeroed.
|
||
_TABLE_16_ENG_ROOF_COHORT_PINS: tuple[tuple[str, str, int, float], ...] = (
|
||
# (joined_description, age_band, thickness_mm, expected_u_w_per_m2k)
|
||
# Table 16 col 1 — thickness-known path, U independent of age band.
|
||
("Pitched, 100 mm loft insulation", "D", 100, 0.40), # cert 7536 bp0
|
||
("Pitched, 100 mm loft insulation | Pitched, insulated (assumed)", "D", 100, 0.40), # cert 7536 bp0 (joined)
|
||
("Pitched, 270 mm loft insulation", "D", 270, 0.16), # cert 0300 bp0
|
||
("Pitched, 300 mm loft insulation | Flat, no insulation", "D", 300, 0.14), # cert 0390-2954 bp0
|
||
("Pitched, 300 mm loft insulation | Roof room(s), limited insulation (assumed)", "A", 300, 0.14), # cert 6035 bp0
|
||
("Pitched, 300 mm loft insulation | Flat, insulated", "C", 300, 0.14), # cert 8135 bp0
|
||
("Pitched, 300 mm loft insulation | Pitched, 100 mm loft insulation", "B", 300, 0.14), # cert 2130 bp0
|
||
("Pitched, 400+ mm loft insulation | Pitched, insulated (assumed)", "J", 400, 0.11), # cert 0240 bp0
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"description, age_band, thickness_mm, expected_u",
|
||
_TABLE_16_ENG_ROOF_COHORT_PINS,
|
||
)
|
||
def test_u_roof_matches_table16_for_every_cohort_description_thickness_pair(
|
||
description: str,
|
||
age_band: str,
|
||
thickness_mm: int,
|
||
expected_u: float,
|
||
) -> None:
|
||
# Arrange — inputs replicate what `heat_transmission_from_cert` feeds
|
||
# `u_roof` for the main building part in the golden cert cohort.
|
||
|
||
# Act
|
||
u = u_roof(
|
||
country=Country.ENG,
|
||
age_band=age_band,
|
||
insulation_thickness_mm=thickness_mm,
|
||
description=description,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(u - expected_u) < 1e-4
|
||
|
||
|
||
# ----- §5.12 formula cascade cohort pins (Floors) -----
|
||
#
|
||
# u_floor is formula-driven (BS EN ISO 13370 + RdSAP10 §5.12) rather
|
||
# than table-lookup, so each pin asserts a per-geometry value derived
|
||
# by hand from the spec formula. Two cases from cert 0240 (main +
|
||
# extension) cover the dt < B and dt > B branches of the solid-floor
|
||
# branch; suspended-floor + Table 19 footnote (2) overrides land in
|
||
# follow-on slices when cohort coverage demands them.
|
||
#
|
||
# Hand-derivation for the first row (cert 0240 bp0):
|
||
# age J → Table 19 default insulation = 75 mm
|
||
# w = 0.3 m (default), g = 1.5, Rsi+Rse = 0.21, Rf = 0.001×75/0.035 = 2.143
|
||
# dt = 0.3 + 1.5×(0.21 + 2.143) = 3.829
|
||
# B = 2×97.72/36.45 = 5.362 → dt < B branch
|
||
# U = 2g·ln(πB/dt + 1)/(πB + dt) = 0.2447 → rounds to 0.24
|
||
_FLOOR_FORMULA_COHORT_PINS: tuple[tuple[str, str, Optional[int], float, float, Optional[int], float], ...] = (
|
||
# (description, age, construction, area_m2, perimeter_m, wall_thickness_mm, expected_u)
|
||
("Solid, insulated (assumed)", "J", 1, 97.72, 36.45, None, 0.24), # cert 0240 bp0 (dt < B)
|
||
("Solid, insulated (assumed)", "J", 1, 20.61, 13.45, None, 0.29), # cert 0240 bp1 (dt > B)
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"description, age_band, construction, area_m2, perimeter_m, wall_thickness_mm, expected_u",
|
||
_FLOOR_FORMULA_COHORT_PINS,
|
||
)
|
||
def test_u_floor_matches_section_5_12_formula_for_cohort_geometry(
|
||
description: str,
|
||
age_band: str,
|
||
construction: Optional[int],
|
||
area_m2: float,
|
||
perimeter_m: float,
|
||
wall_thickness_mm: Optional[int],
|
||
expected_u: float,
|
||
) -> None:
|
||
# Arrange — inputs replicate what `heat_transmission_from_cert` feeds
|
||
# `u_floor` for the corresponding building part in the cohort.
|
||
|
||
# Act
|
||
u = u_floor(
|
||
country=Country.ENG,
|
||
age_band=age_band,
|
||
construction=construction,
|
||
insulation_thickness_mm=None,
|
||
area_m2=area_m2,
|
||
perimeter_m=perimeter_m,
|
||
wall_thickness_mm=wall_thickness_mm,
|
||
description=description,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(u - expected_u) < 1e-4
|