Model/domain/sap10_ml/tests/test_rdsap_uvalues.py
Khalim Conn-Kowlessar f895dd3ab7 S0380.217: capture wall_insulation_thermal_conductivity (was dropped)
Second silently-dropped field from the 2130 audit: the schema-21
SapBuildingPart never declared `wall_insulation_thermal_conductivity`, so
`from_dict` discarded it. Captured it through schema 21.0.0/21.0.1 → domain
SapBuildingPart → API mapper, and wired it into u_wall's RdSAP 10 §5.8
documentary-evidence R-value calc (both the solid-brick §5.7/§5.8 path and
the cavity-composite path), replacing the bare 0.04 λ constant with a
resolved λ.

Resolver: absent / "Unknown" → the §5.8 default 0.04 W/m·K (mineral wool /
EPS); a mapped code → its λ; an unmapped integer code RAISES so the enum is
confirmed against a worksheet rather than silently mis-factored (same
incremental-coverage discipline as the glazing-type map). Only code 1
(= the default 0.04) is mapped — the sole observed value (cert 2130 Ext1).

Zero cascade effect today: the λ path fires only for solid-brick/cavity
walls with a *measured* wall thickness, and 2130 Ext1 lodges no wall
thickness, so its conductivity is captured-but-unused; all existing §5.8
certs lodge no conductivity → 0.04 default unchanged. The point is to stop
dropping lodged data and make λ correct when a future cert exercises it.

Suite: 2523 passed (1 pre-existing TFA fail); sap10_ml 237 passed (2
pre-existing stone-formula fails). Zero new pyright errors (46=46).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:57:00 +00:00

1928 lines
67 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_CAVITY_FILLED_PARTY,
WALL_CURTAIN,
WALL_INSULATION_FILLED_CAVITY,
WALL_SOLID_BRICK,
WALL_STONE_GRANITE,
WALL_STONE_SANDSTONE,
WALL_SYSTEM_BUILT,
WALL_TIMBER_FRAME,
thermal_bridging_y,
u_door,
u_exposed_floor,
u_floor,
u_floor_above_partially_heated_space,
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_as_built_row() -> None:
# Arrange — a cavity lodged "Cavity wall, as built, partial insulation
# (assumed)" with wall_insulation_type=4 is in its AS-BUILT state (the
# partial fill of the age band), NOT a retrofit cavity fill. Per
# RdSAP 10 Table 6 (England) it uses the "Cavity as built" row, not
# "Filled cavity": band D = 1.5 (as built) vs 0.7 (filled). A genuine
# fill lodges the distinct "Cavity wall, filled cavity"
# (wall_insulation_type=2), caught by the explicit-code branch.
#
# Slice S0380.210 corrected this: the prior routing to "Filled cavity"
# mirrored a legacy production map, but golden cert 0390-2954-3640
# (band F, cavity type 4, "partial insulation (assumed)") closes all
# four SAP metrics on the as-built row (band F = 1.0) and under-counts
# PE by ~28 kWh/m² on the filled row — the legacy parity was a latent
# bug at bands A-H (bands I-M coincide per the Table 6 † footnote).
# The "insulated (assumed)" variant still routes to filled (see the
# heat_transmission `_cavity_described_as_filled` sibling test).
# Act
result = u_wall(
country=Country.ENG,
age_band="D", # 1950-1966 — as-built ≠ filled at this band
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
description="Cavity wall, as built, partial insulation (assumed)",
)
# Assert — Cavity-as-built row at band D = 1.5 W/m²K (not filled 0.7).
assert abs(result - 1.5) <= 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_dry_lined_cavity_as_built_age_c_applies_rdsap_5_8_r_0_17_adjustment() -> None:
# Arrange — RdSAP10 §5.8 final note + Table 14 page 41: "For drylining
# including laths and plaster use Rinsulation = 0.17 m²K/W." Applied
# additively to the base U-value of an otherwise-uninsulated wall.
# Cohort fixture: cert 7700-3362-0922-7022-3563 Alt 1 lodges Cavity,
# As-Built, Dry-lining: Yes, age band C → worksheet
# `CavityWallPlasterOnDabsDenseBlock` U-value = 1.20 W/m²K.
# Closed form: 1 / (1/1.5 + 0.17) = 1.19522... → 2 d.p. half-up = 1.20.
# Act
result = u_wall(
country=Country.ENG,
age_band="C",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
dry_lined=True,
)
# Assert — adjusted U is rounded to 2 d.p. matching the dr87 worksheet's
# `UValueFinal` column for this construction.
assert abs(result - 1.20) <= 1e-9
def test_u_wall_not_dry_lined_cavity_as_built_age_c_returns_unadjusted_1_5() -> None:
# Arrange — same age + construction as the dry-lined case above but
# without the dry-lining flag. Cascade must return the bare Table 6
# "Cavity as built" row value (no R = 0.17 added).
# Act
result = u_wall(
country=Country.ENG,
age_band="C",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
dry_lined=False,
)
# Assert
assert abs(result - 1.50) <= 1e-9
def test_u_wall_dry_lined_with_measured_insulation_thickness_no_adjustment() -> None:
# Arrange — once a measured insulation thickness is lodged, Table 6's
# insulated buckets already incorporate the dry-lining R via Table 14.
# Applying R = 0.17 on top would double-count. Cavity + 100 mm
# insulation, age band E → Table 6 cavity-100mm row = 0.32 W/m²K
# regardless of the dry-lining flag.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=100,
insulation_present=True,
wall_insulation_type=4,
dry_lined=True,
)
# Assert
assert abs(result - 0.32) <= 1e-9
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)
def test_u_wall_curtain_wall_post_2023_routes_to_window_table_24_u_1p4_per_rdsap_5_18() -> None:
# Arrange — RdSAP 10 §5.18 (PDF p.48): "Otherwise for the purpose of
# RdSAP, U= 2.0 W/m²K for pre-2023 curtain walls, And for post-2023
# (2024 in Scotland) U-values as for windows given in Notes below
# Table 24." Table 24 row "Double or triple glazed England/Wales:
# 2022 or later" PVC/wood column = 1.4 W/m²K. Cert 000565 BP[2]
# Ext2 lodges `Type: CW Curtain Wall` + `Curtain Wall Age: Post 2023`
# — worksheet pins U=1.40 for this BP.
#
# Pre-S0380.85: `WALL_CURTAIN=9` was defined but not in `known_types`
# at u_wall:373-376, so the dispatch fell through to
# `_DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)` → cavity table at age
# H = 0.60. Cascade walls subtotal under-counted by ~112 W/K on
# this BP.
# Act
result = u_wall(
country=Country.ENG,
age_band="H",
construction=WALL_CURTAIN,
insulation_thickness_mm=None,
curtain_wall_age="Post 2023",
)
# Assert
assert abs(result - 1.4) <= 1e-9
def test_u_wall_curtain_wall_pre_2023_uses_rdsap_5_18_default_u_2p0() -> None:
# Arrange — RdSAP 10 §5.18 (PDF p.48) fallback for curtain walls
# built before 2023 (or installed-age unknown): U = 2.0 W/m²K.
# Independent of construction age band — §5.18 keys solely on the
# curtain-wall-age lodging (Post 2023 vs everything else), not on
# the dwelling-wide `construction_age_band`.
# Act
result = u_wall(
country=Country.ENG,
age_band="H",
construction=WALL_CURTAIN,
insulation_thickness_mm=None,
curtain_wall_age="Pre 2023",
)
# Assert
assert abs(result - 2.0) <= 1e-9
def test_u_wall_stone_granite_thin_wall_age_a_120mm_dry_lined_applies_5_6_formula_with_5_8_adjustment() -> None:
# Arrange — RdSAP 10 §5.6 (PDF p.40) "U-values of uninsulated stone
# walls, age bands A to E":
#
# Table 12: Default U-values of stone walls
# Granite or whinstone: U = 45.315 × W^(-0.513)
# Where W is wall thickness in mm.
#
# Then RdSAP 10 §5.8 (PDF p.40) + Table 14 (PDF p.41) — for
# dry-lining (including laths and plaster) apply R = 0.17 m²K/W
# additively to U₀:
#
# U = 1 / (1/U₀ + R_insulation)
#
# Cert 000565 BP[0] Main alt1 is the cohort fixture: stone granite,
# age band A (inherited from Main), wall thickness 120 mm, dry-lined.
# §5.6 formula: U₀ = 45.315 × 120^(-0.513) ≈ 3.8871
# §5.8 + Table 14 dry-line: U = 1/(1/3.8871 + 0.17) ≈ 2.3405
# → matches worksheet U985-0001-000565 line (29a) pin U=2.34.
#
# Pre-S0380.86: the cert lodged its alt-wall thickness via the
# misnamed `wall_insulation_thickness="120"` field, which routed
# through `_insulation_bucket(120, ins_present=False)` → 100 →
# _BRICK_INS_100 (the stone-insulated-100mm row) → 0.32 W/m²K at
# age A. Δ contribution 46.5 W/K on the 23 m² alt area.
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_GRANITE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
dry_lined=True,
wall_thickness_mm=120,
)
# Assert — worksheet 2.34 (4 d.p. tolerance for round-half-up)
assert abs(result - 2.34) <= 1e-2
def test_u_wall_stone_granite_thin_wall_age_a_120mm_no_dry_line_returns_raw_5_6_formula() -> None:
# Arrange — same wall + thickness as above but without dry-lining.
# §5.6 formula returns U₀ directly (no §5.8 adjustment applied).
# U₀ = 45.315 × 120^(-0.513) ≈ 3.8871
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_GRANITE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
dry_lined=False,
wall_thickness_mm=120,
)
# Assert
assert abs(result - 3.8871) <= 1e-3
def test_u_wall_stone_sandstone_thin_wall_age_a_120mm_uses_5_6_sandstone_formula() -> None:
# Arrange — §5.6 (PDF p.40) Table 12: sandstone/limestone formula
# is distinct from granite/whinstone:
# Sandstone or limestone: U = 54.876 × W^(-0.561)
# At W=120 mm: U₀ ≈ 3.7408. The dispatch must pick the formula
# by construction code (WALL_STONE_SANDSTONE vs WALL_STONE_GRANITE).
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
dry_lined=False,
wall_thickness_mm=120,
)
# Assert
assert abs(result - 3.7408) <= 1e-3
def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_age_a_to_e_gate() -> None:
# Arrange — §5.6 (PDF p.40) heading explicitly scopes the formula
# to "age bands A to E". For age F onwards Table 6 gives literal
# U-values that already encode typical-thickness stone wall heat
# loss — applying §5.6 outside the A-E gate would over-estimate U
# for modern stone walls. Cert 000565 alt1 happens to be age A,
# but this test guards against §5.6 leaking into post-1976 stone
# constructions.
#
# At age G stone granite, Table 6 gives U=0.60 (cohort-typical row).
# The §5.6 formula at 120 mm would return 3.89 — wildly over.
# Act
result = u_wall(
country=Country.ENG,
age_band="G",
construction=WALL_STONE_GRANITE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
wall_thickness_mm=120,
)
# Assert — Table 6 row at age G, NOT §5.6 formula.
assert abs(result - 0.60) <= 1e-3
def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a_default() -> None:
# Arrange — §5.6 formula only fires when a wall thickness is
# lodged. Without documentary wall-thickness evidence, fall back
# to the Table 6 row (which represents typical thickness). For
# age A stone granite without thickness, the cascade preserves
# its existing "as-built typical" U value rather than the formula
# extrapolation.
# Act
result = u_wall(
country=Country.ENG,
age_band="A",
construction=WALL_STONE_GRANITE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
wall_thickness_mm=None,
)
# Assert — _TYPICAL_STONE_UNINSULATED at age A = 1.7 (cohort default).
assert abs(result - 1.7) <= 1e-3
def test_u_wall_curtain_wall_missing_age_lodgement_defaults_to_pre_2023_u_2p0_per_rdsap_5_18() -> None:
# Arrange — when the cert lodges `Type: CW Curtain Wall` but no
# `Curtain Wall Age` line (older Elmhurst Summary PDFs, or API EPCs
# without the per-BP curtain_wall_age field), apply the §5.18
# default. The §5.18 sentence "U= 2.0 W/m²K for pre-2023 curtain
# walls" applies as the unknown-age fallback — matches the spec's
# "assume as-built" convention elsewhere in the cascade.
# Act
result = u_wall(
country=Country.ENG,
age_band="H",
construction=WALL_CURTAIN,
insulation_thickness_mm=None,
curtain_wall_age=None,
)
# Assert
assert abs(result - 2.0) <= 1e-9
# ----- 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_vaulted_ni_unknown_band_j_uses_col1_age_band_not_50mm() -> None:
# Arrange — a pitched roof with a vaulted/sloping ceiling (no joist
# void) lodged with insulation thickness "NI" (Not Indicated, parsed
# to 0) + an "insulated (assumed)" description. For a NORMAL pitched
# roof this hits the §5.11.4 "retrofit 50 mm" override (U=0.68, the
# Table 16 joist row) — but a vaulted/sloping ceiling has no joist
# void, so RdSAP 10 Table 18 routes it to the column (1) age-band
# default: band J = 0.16 W/m²K (NOT 0.68). This is the same value a
# vaulted roof lodged "ND" (thickness None) already reaches by falling
# through to the age-band default.
#
# Cohort-validated: 33 cohort-2 certs lodge "ND" vaulted roofs
# (roof_construction=5, band D) that pin to worksheet U=0.40 = col (1).
# Closes golden cert 0240's Ext1 vaulted roof (code 5, NI, band J)
# which the cascade returned at 0.68 (offsetting the wall under-count
# fixed in S0380.209).
# Act
result = u_roof(
country=Country.ENG,
age_band="J",
insulation_thickness_mm=0, # parsed from "NI"
description="Pitched, insulated (assumed)",
is_sloping_ceiling=True,
)
# Assert
assert abs(result - 0.16) <= 1e-4
def test_u_roof_normal_pitched_ni_insulated_still_returns_50mm_per_5_11_4() -> None:
# Arrange — regression guard: the is_sloping_ceiling flag defaults
# False, so a NORMAL pitched roof (with loft) lodged NI + "insulated
# (assumed)" must STILL hit the §5.11.4 retrofit-50 mm row (U=0.68).
# Same inputs as the sloping test above minus is_sloping_ceiling.
# Act
result = u_roof(
country=Country.ENG,
age_band="J",
insulation_thickness_mm=0,
description="Pitched, insulated (assumed)",
)
# Assert
assert abs(result - 0.68) <= 1e-4
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_pitched_sloping_ceiling_as_built_band_f_uses_col3() -> None:
# Arrange — RdSAP 10 §5.11 Table 18 page 45 + roof-input item 5-5
# ("Sloping ceiling insulation ... unknown / as built → Table 18").
# A "Pitched, sloping ceiling" roof (roof_construction code 8) with an
# "As Built" insulation lodgement (no measured thickness → None) takes
# the Table 18 column (3) age-band default, NOT the column (1)
# "insulation between joists" default. Note (b) on column (3) states it
# "applies also to roof with sloping ceiling". For age band F the
# column (3) value is 0.68 W/m²K (vs column (1) 0.40 — the loft-joist
# assumption that is wrong for a sloping ceiling with no joist void).
#
# Worksheet-validated: simulated case 15 (7536 replica) lodges Ext2 as
# band F "PS Pitched, sloping ceiling, As Built"; its P960 worksheet
# pins `External roof Ext2 … 0.68`, and the full-cascade roof HLC and
# SAP match Elmhurst exactly only with column (3).
# Act
result = u_roof(
country=Country.ENG, age_band="F", insulation_thickness_mm=None,
is_pitched_sloping_ceiling=True,
)
# Assert
assert abs(result - 0.68) <= 1e-4
def test_u_roof_pitched_sloping_ceiling_as_built_band_l_uses_col3() -> None:
# Arrange — same rule at band L (2012-2022): Table 18 column (3) gives
# 0.18 W/m²K, where columns (2)/(3) coincide. Simulated case 15's Ext1
# (band L PS sloping ceiling, As Built) pins worksheet U=0.18 (vs the
# column (1) value 0.16 the cascade returned pre-fix).
# Act
result = u_roof(
country=Country.ENG, age_band="L", insulation_thickness_mm=None,
is_pitched_sloping_ceiling=True,
)
# Assert
assert abs(result - 0.18) <= 1e-4
def test_u_roof_vaulted_nd_unknown_band_d_still_col1_not_col3() -> None:
# Arrange — regression guard for the discriminator: a code-5 "vaulted"
# roof lodged "ND" (thickness None) is the UNKNOWN-insulation case and
# must stay on Table 18 column (1) — band D = 0.40 — per the 33
# cohort-2 vaulted certs (S0380.211). The col (3) routing fires only
# for code-8 "Pitched, sloping ceiling" (is_pitched_sloping_ceiling),
# NOT for vaulted ceilings, so this defaults False here and resolves
# to column (1) 0.40, NOT column (3) 2.30.
# Act
result = u_roof(
country=Country.ENG, age_band="D", insulation_thickness_mm=None,
)
# Assert
assert abs(result - 0.40) <= 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_above_partially_heated_space_returns_0p7_per_rdsap_10_section_5_14() -> None:
# Arrange — RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a
# partially heated space":
# "The U-value of a floor above partially heated premises is
# taken as 0.7 W/m²K. This applies typically for a flat above
# non-domestic premises that are not heated to the same extent
# or duration as the flat."
# Verbatim constant — no age-band or insulation-thickness inputs.
# Cert 000565 Ext1 (Summary §9: "P Above partially heated space",
# Default U-value 0.70) exercises this branch.
# Act
result = u_floor_above_partially_heated_space()
# Assert
assert abs(result - 0.7) <= 1e-4
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_cavity_masonry_filled_returns_0p2_per_rdsap_10_table_15_row_3() -> None:
# Arrange — RdSAP 10 §5.10 Table 15 row 3 (PDF p.42) "Cavity masonry
# filled -> 0.2 W/m²K". Before slice S0380.91 the `u_party_wall`
# cascade only resolved 0.0 / 0.5 / 0.25 for code 4 so Elmhurst
# "CF" lodgements rounded up to the conservative cavity-unfilled
# U=0.5 — over-counting party-wall heat loss by (0.5 - 0.2) × area.
# New synthetic code `WALL_CAVITY_FILLED_PARTY = 11` distinguishes
# filled cavity from the construction-class-shared code 4.
# Act
result = u_party_wall(party_wall_construction=WALL_CAVITY_FILLED_PARTY)
# Assert
assert abs(result - 0.2) <= 1e-4
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)
def test_u_party_wall_unknown_for_flat_returns_table15_footnote_zero() -> None:
# Arrange — RdSAP 10 Table 15 footnote *: "for flats and maisonettes
# with unknown party-wall construction, U = 0.0" (both sides of the
# party wall are heated dwellings, so no heat loss).
# Act
result = u_party_wall(party_wall_construction=None, is_flat=True)
# Assert
assert abs(result - 0.0) <= 0.001
def test_u_party_wall_unknown_sentinel_zero_treated_as_unknown_for_flat() -> None:
# Arrange — the Elmhurst mapper lodges `0` as the explicit "unknown"
# sentinel (per `datatypes/epc/domain/mapper.py:_ELMHURST_PARTY_WALL_
# CODE_TO_SAP10` cross-mapper-parity comment) where the API mapper
# would have lodged `None`. The cascade must treat both equivalently
# so a flat cert from either source surfaces Table 15 footnote *.
# Act
result = u_party_wall(party_wall_construction=0, is_flat=True)
# Assert
assert abs(result - 0.0) <= 0.001
def test_u_party_wall_known_solid_still_returns_zero_when_is_flat_false() -> None:
# Arrange — `is_flat` is a fallback for the unknown case only; an
# explicit construction code always takes precedence (Solid → 0.0
# regardless of property type, matching Table 15 row 1).
# Act
result = u_party_wall(party_wall_construction=3, is_flat=False)
# Assert
assert abs(result - 0.0) <= 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_stud_wall_rigid_foam_400mm_returns_0p10_per_table_17_col_3b() -> None:
# Arrange — Table 17 column (3b) "Stud wall, PUR or PIR optional",
# 400 mm row → 0.10 W/m²K. Cert 000565 BP[2] Ext2 Summary §8.1
# lodges "Stud Wall 2: 400+ mm PUR or PIR" → Default U=0.10. The
# "rigid_foam" SAP10 insulation-type code is the canonical alias for
# both the Elmhurst "PUR or PIR" string and the API "PUR" / "PIR"
# individual codes; the cascade's `_is_rigid_foam` recognises all
# three to route through column (b) of Table 17.
# Act
result = u_rr_stud_wall(
country=Country.ENG,
age_band="J",
insulation_thickness_mm=400,
insulation_type="rigid_foam",
)
# Assert
assert abs(result - 0.10) <= 1e-4
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 AD — 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
def test_resolve_wall_insulation_lambda_absent_uses_default() -> None:
# Arrange — no lodged conductivity → RdSAP 10 §5.8 default 0.04 W/m·K.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam = _resolve_wall_insulation_lambda_w_per_mk(None)
# Assert
assert abs(lam - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_unknown_string_uses_default() -> None:
# Arrange — a non-numeric "Unknown" lodgement defers to the default.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam = _resolve_wall_insulation_lambda_w_per_mk("Unknown")
# Assert
assert abs(lam - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_code_1_is_default_mineral_wool() -> None:
# Arrange — code 1 = the §5.8 default λ=0.04 (mineral wool / EPS);
# cert 2130 Ext1 lodges this. Numeric-string form resolves identically.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam_int = _resolve_wall_insulation_lambda_w_per_mk(1)
lam_str = _resolve_wall_insulation_lambda_w_per_mk("1")
# Assert
assert abs(lam_int - 0.04) <= 1e-9
assert abs(lam_str - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_unmapped_code_raises() -> None:
# Arrange — an unmapped code must raise (incremental-coverage gate)
# rather than silently mis-factor the R-value.
import pytest as _pytest
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act / Assert
with _pytest.raises(ValueError):
_resolve_wall_insulation_lambda_w_per_mk(2)