mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
1928 lines
67 KiB
Python
1928 lines
67 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_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 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
|
||
|
||
|
||
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)
|