mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-22 08:48:38 +00:00
A SOLID-BRICK or STONE wall lodging External/Internal insulation (wall_insulation_type 1/3) but no measured insulation thickness (gov-API "NI" -> None) was billed at its UNINSULATED §5.6/Table-6 U-value: the §5.8 added-insulation R-value adjustment gates on `insulation_thickness_mm > 0`, so an unknown thickness fell straight through to the raw solid-wall U (~1.4-1.7). This over-counted wall heat loss and under-rated the dwelling. RdSAP 10 §5.4 (PDF p.32): "the U-value with additional insulation is based on the thickness of insulation of mineral wool type (assume 100 mm if thickness is unknown)". Apply that 100 mm default so the §5.8 Table-14 R-value fires. Scoped to solid brick / stone (the only constructions with a §5.8 R-value path in u_wall) so cavity (composite path) and timber / system-built walls are untouched. Localised by deep-diving corpus cert 200004296092 (end-terrace, solid brick 360 mm "with external insulation", NI): walls 101.7 W/K (U~1.40, HLP 3.46 = absurd for an insulated wall) -> ~0.29; PE +59.6 -> ~0; engine 64.35 (Δ -6.65) -> 71.74 = lodged 71. ~8 solid-brick/stone corpus certs carry this NI-thickness-with-insulation shape. Gauge: within-0.5 held at 77.0%, SAP MAE 0.658 -> 0.647, CO2 0.074 -> 0.073, PE 3.2 -> 3.1 (floor ratcheted). Unit-pinned in test_rdsap_uvalues (brick 360 mm external NI -> 0.29) + RealCertExpectation 200004296092 = 72. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2473 lines
89 KiB
Python
2473 lines
89 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_as_built_row() -> None:
|
||
# Arrange — a cavity lodged "Cavity wall, as built, insulated (assumed)"
|
||
# with wall_insulation_type=4 is in its AS-BUILT state, NOT a retrofit
|
||
# cavity fill. Per RdSAP 10 Table 6 (England) the "Filled cavity" row's
|
||
# † footnote ("assumed as built") applies only at bands I-M, where it
|
||
# coincides with "Cavity as built"; at bands A-H the filled row is for a
|
||
# GENUINE fill. So an as-built cavity uses the "Cavity as built" row:
|
||
# band E = 1.5, NOT the filled 0.7.
|
||
#
|
||
# Slice S0380.210 corrected this for the "partial insulation (assumed)"
|
||
# variant but left "insulated (assumed)" on the filled row by a legacy
|
||
# production convention — the SAME latent A-H bug. The API SAP-accuracy
|
||
# cohort over-rated band-G/H "insulated (assumed)" cavities by a clean
|
||
# +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60); bands I-M were
|
||
# unaffected (rows coincide). A genuine fill lodges the distinct "Cavity
|
||
# wall, filled cavity" (wall_insulation_type=2), caught by the
|
||
# explicit-code branch.
|
||
|
||
# Act
|
||
result_e = 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)",
|
||
)
|
||
# Band I: "Cavity as built" and "Filled cavity" rows coincide (0.45),
|
||
# so the routing change is a no-op there — the corpus-confirmed pivot.
|
||
result_i = u_wall(
|
||
country=Country.ENG,
|
||
age_band="I",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=4,
|
||
description="Cavity wall, as built, insulated (assumed)",
|
||
)
|
||
|
||
# Assert — band E → as-built 1.5 (not filled 0.7); band I → 0.45 (rows coincide).
|
||
assert abs(result_e - 1.5) <= 0.001
|
||
assert abs(result_i - 0.45) <= 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 route to the Cavity-as-built row
|
||
# of Table 6 (U=1.5 at band E) — as do ALL as-built cavity variants
|
||
# ("insulated" / "partial insulation" / "no insulation") now that the
|
||
# as-built path no longer special-cases the insulation adjective.
|
||
|
||
# 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).
|
||
# A later slice extended the same fix to the "insulated (assumed)"
|
||
# variant (see the as-built-insulated sibling test above).
|
||
|
||
# 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_scotland_age_band_j_returns_0_30_not_england_0_35_per_table7() -> None:
|
||
# Arrange — RdSAP 10 Table 7 (Scotland, PDF p.35) as-built band J is
|
||
# 0.30 for every uninsulated wall type, vs the England base 0.35
|
||
# (Table 6). The _COUNTRY_KLM_OVERRIDES[SCT] dicts previously listed
|
||
# H/K/L/M but omitted J, so a Scotland band-J cavity wrongly fell
|
||
# through to England's 0.35.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.SCT,
|
||
age_band="J",
|
||
construction=WALL_CAVITY,
|
||
insulation_thickness_mm=0,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.30, abs=0.001)
|
||
|
||
|
||
def test_u_wall_scotland_age_band_j_timber_frame_returns_0_30_per_table7() -> None:
|
||
# Arrange — the J=0.30 override applies to all 7 as-built wall types,
|
||
# including timber frame (England base J = 0.35). Guards the type whose
|
||
# Scotland override has no H entry (timber H already 0.40 in England).
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.SCT,
|
||
age_band="J",
|
||
construction=WALL_TIMBER_FRAME,
|
||
insulation_thickness_mm=0,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.30, 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_gov_api_system_built_code_8_resolves_without_description() -> None:
|
||
# Arrange — the gov-EPC API `wall_construction` enum diverges from the
|
||
# calculator's internal WALL_* code-space: API 8 = "System built" (calc
|
||
# WALL_SYSTEM_BUILT = 6; calc 8 = park home). The 43-cert system-built
|
||
# cohort currently resolves only via the `walls[].description` fallback;
|
||
# with no description, code 8 silently defaulted to cavity (1.5) instead
|
||
# of the system-built U (band E as-built = 1.7).
|
||
|
||
# Act — code 8, NO description.
|
||
result = u_wall(
|
||
country=Country.ENG, age_band="E", construction=8,
|
||
insulation_thickness_mm=0, description=None,
|
||
)
|
||
reference = u_wall(
|
||
country=Country.ENG, age_band="E", construction=WALL_SYSTEM_BUILT,
|
||
insulation_thickness_mm=0,
|
||
)
|
||
|
||
# Assert — code 8 is system-built (1.7), not the cavity default (1.5).
|
||
assert abs(result - reference) <= 1e-9
|
||
assert abs(result - 1.7) <= 1e-9
|
||
|
||
|
||
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_sandstone_with_internal_insulation_applies_5_8_table_14_r_value() -> None:
|
||
# Arrange — RdSAP 10 §5.8 + Table 14 (PDF p.41-42): a stone wall lodging
|
||
# External/Internal insulation (wall_insulation_type 1/3) + a thickness
|
||
# gets the same R-value adjustment as solid brick, applied to the RAW §5.6
|
||
# U₀. Mirrors corpus cert 100052159386 (Sandstone, 520 mm, 100 mm internal):
|
||
# U₀ = 54.876 × 520^(-0.561) = 1.6433
|
||
# R = 0.025 × 100 + 0.25 = 2.75 (Table 14, λ = 0.04)
|
||
# U = 1 / (1/1.6433 + 2.75) = 0.2977 → 0.30 (2 d.p.)
|
||
# Before this branch the wall was billed at its UNINSULATED U (≈1.64),
|
||
# the dominant cause of the wall_insulation_type=3 corpus under-rate cluster.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="A",
|
||
construction=WALL_STONE_SANDSTONE,
|
||
insulation_thickness_mm=100,
|
||
insulation_present=True,
|
||
wall_insulation_type=3,
|
||
dry_lined=False,
|
||
wall_thickness_mm=520,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.30) <= 1e-4
|
||
|
||
|
||
def test_u_wall_stone_sandstone_insulated_feeds_raw_u0_not_table_6_cap() -> None:
|
||
# Arrange — the Table-6 footnote (a) 1.7 cap applies ONLY to the as-built
|
||
# row; the insulated §5.8 path takes the RAW §5.6 U₀ (same rule the brick
|
||
# branch and the dry-lined granite pin 000565 follow). At W=120 mm the raw
|
||
# sandstone U₀ = 3.7408 (> 1.7), so the 100 mm internal result must be
|
||
# 1 / (1/3.7408 + 2.75) = 0.331 → 0.33 (raw),
|
||
# NOT the capped 1 / (1/1.7 + 2.75) = 0.30. The 0.33 vs 0.30 split proves
|
||
# the cap is bypassed on the insulated path.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="A",
|
||
construction=WALL_STONE_SANDSTONE,
|
||
insulation_thickness_mm=100,
|
||
insulation_present=True,
|
||
wall_insulation_type=3,
|
||
dry_lined=False,
|
||
wall_thickness_mm=120,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.33) <= 1e-4
|
||
|
||
|
||
def test_u_wall_stone_granite_with_external_insulation_applies_5_8_table_14_r_value() -> None:
|
||
# Arrange — granite/whinstone §5.6 formula + §5.8 external insulation:
|
||
# U₀ = 45.315 × 120^(-0.513) = 3.8871
|
||
# R = 0.025 × 50 + 0.25 = 1.50 (Table 14, λ = 0.04)
|
||
# U = 1 / (1/3.8871 + 1.50) = 0.567 → 0.57 (2 d.p.)
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="A",
|
||
construction=WALL_STONE_GRANITE,
|
||
insulation_thickness_mm=50,
|
||
insulation_present=True,
|
||
wall_insulation_type=1,
|
||
dry_lined=False,
|
||
wall_thickness_mm=120,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.57) <= 1e-4
|
||
|
||
|
||
def test_u_wall_solid_brick_external_insulation_unknown_thickness_assumes_100mm() -> None:
|
||
# Arrange — RdSAP 10 §5.4 (PDF p.32): "the U-value with additional
|
||
# insulation is based on the thickness of insulation of mineral wool
|
||
# type (assume 100 mm if thickness is unknown)". A solid brick / stone
|
||
# wall lodging External or Internal insulation (wall_insulation_type
|
||
# 1/3) but NO measured insulation thickness (gov-API "NI" → parsed to
|
||
# None) must take the 100 mm §5.4 default so the §5.8 Table-14 R-value
|
||
# fires — NOT fall through to the uninsulated §5.6 U. Corpus cert
|
||
# 200004296092 (age-B solid brick 360 mm, "with external insulation",
|
||
# thickness NI) was billed at the uninsulated 1.40 (walls 101.7 W/K,
|
||
# HLP 3.46) → PE +59.6, SAP −6.65. With 100 mm: U₀(§5.6, 360 mm brick)
|
||
# + R(100 mm, λ=0.04 → 2.5) → 0.29.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=WALL_SOLID_BRICK,
|
||
insulation_thickness_mm=None, # gov-API "NI" — thickness unknown
|
||
insulation_present=True,
|
||
wall_insulation_type=1, # External
|
||
wall_thickness_mm=360,
|
||
)
|
||
|
||
# Assert — 100 mm default applied, not the uninsulated 1.40.
|
||
assert abs(result - 0.29) <= 1e-4
|
||
|
||
|
||
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_uses_table_3_default_500mm() -> None:
|
||
# Arrange — when no documentary wall thickness is lodged, RdSAP 10 §3.5
|
||
# Table 3 (PDF p.20) supplies the default stone thickness (A-D = 500 mm),
|
||
# which feeds the §5.6 formula — NOT a flat 1.7. This matches Elmhurst:
|
||
# an as-built granite/whinstone wall with unknown thickness defaults to
|
||
# 500 mm → U = 45.315 × 500^(-0.513) = 1.8693. (The earlier 1.7
|
||
# expectation was a setup error: Table 6 reads "According to 5.6" for
|
||
# bands A-D, with no 1.7 entry.)
|
||
|
||
# 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 — §5.6 formula at the Table-3 default 500 mm.
|
||
assert abs(result - 1.8693) <= 1e-3
|
||
|
||
|
||
def test_u_wall_stone_sandstone_age_b_without_wall_thickness_uses_table_3_default_500mm() -> None:
|
||
# Arrange — sandstone/limestone variant of the Table-3 default: age B,
|
||
# unknown thickness → 500 mm → 54.876 × 500^(-0.561) = 1.6798. This is
|
||
# the "500 mm → sandstone 1.68" Elmhurst default.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=WALL_STONE_SANDSTONE,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=4,
|
||
wall_thickness_mm=None,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 1.6798) <= 1e-3
|
||
|
||
|
||
def test_u_wall_stone_sandstone_scotland_age_a_without_thickness_adds_200mm_per_table_3() -> None:
|
||
# Arrange — Table 3 Scotland footnote (*): add 200 mm for bands A and B.
|
||
# Age-A Scotland sandstone unknown thickness → 500 + 200 = 700 mm →
|
||
# 54.876 × 700^(-0.561) = 1.3909 (< 1.7, no age-E cap at band A).
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.SCT,
|
||
age_band="A",
|
||
construction=WALL_STONE_SANDSTONE,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=4,
|
||
wall_thickness_mm=None,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 1.3909) <= 1e-3
|
||
|
||
|
||
def test_u_wall_stone_granite_age_e_50mm_caps_at_table6_default_1_7() -> None:
|
||
# Arrange — RdSAP 10 Table 6 (England, PDF p.33-34) stone row reads
|
||
# "1.7 a" at age E, footnote (a) = "Or from equations in 5.6 if the
|
||
# calculated U-value is less than 1.7". A 50 mm granite wall's §5.6
|
||
# formula gives U = 45.315 × 50^(-0.513) = 6.09 (> 1.7), so the age-E
|
||
# default 1.7 stands → min(6.09, 1.7) = 1.70. The cap is age-E ONLY:
|
||
# bands A-D are uncapped (120 mm age-A granite = 3.89). Insulation is
|
||
# Unknown (wall_insulation_type None) — no longer a gate. Cert 000565
|
||
# BP Ext1 is this fixture (U985 worksheet U = 1.70).
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_STONE_GRANITE,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=None,
|
||
dry_lined=False,
|
||
wall_thickness_mm=50,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 1.70) <= 1e-3
|
||
|
||
|
||
def test_u_wall_stone_sandstone_age_e_thick_wall_uses_5_6_formula_below_1_7() -> None:
|
||
# Arrange — footnote (a) at age E: use the §5.6 formula when it gives
|
||
# < 1.7. A 600 mm sandstone wall → U = 54.876 × 600^(-0.561) = 1.5165
|
||
# (< 1.7), so the formula value is used, NOT the 1.7 default.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.ENG,
|
||
age_band="E",
|
||
construction=WALL_STONE_SANDSTONE,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=None,
|
||
dry_lined=False,
|
||
wall_thickness_mm=600,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 1.5165) <= 1e-3
|
||
|
||
|
||
def test_u_wall_stone_sandstone_scotland_age_e_caps_at_1_5_not_1_7() -> None:
|
||
# Arrange — RdSAP 10 Table 7 (Scotland, PDF p.35) sandstone/limestone
|
||
# age E default is "1.5 a" (granite/whinstone stays 1.7). A 500 mm
|
||
# sandstone wall's §5.6 formula = 54.876 × 500^(-0.561) = 1.68 (> 1.5),
|
||
# so the Scotland age-E default 1.5 stands → min(1.68, 1.5) = 1.50.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.SCT,
|
||
age_band="E",
|
||
construction=WALL_STONE_SANDSTONE,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=None,
|
||
dry_lined=False,
|
||
wall_thickness_mm=500,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 1.50) <= 1e-3
|
||
|
||
|
||
def test_u_wall_stone_granite_scotland_age_e_50mm_stays_capped_at_1_7() -> None:
|
||
# Arrange — Scotland granite/whinstone age E default is 1.7 (only
|
||
# sandstone/limestone drops to 1.5, Table 7 PDF p.35). A 50 mm granite
|
||
# wall's formula 6.09 (> 1.7) → min(6.09, 1.7) = 1.70, NOT 1.5.
|
||
|
||
# Act
|
||
result = u_wall(
|
||
country=Country.SCT,
|
||
age_band="E",
|
||
construction=WALL_STONE_GRANITE,
|
||
insulation_thickness_mm=None,
|
||
insulation_present=False,
|
||
wall_insulation_type=None,
|
||
dry_lined=False,
|
||
wall_thickness_mm=50,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 1.70) <= 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_observed_insulated_description_applies_50mm_per_section_5_11_4() -> None:
|
||
# Arrange — a roof lodging thickness "NI" (parsed to 0) whose
|
||
# description signals OBSERVED insulation without the "(assumed)"
|
||
# qualifier ("Pitched, insulated" — RdSAP renders the bare form when
|
||
# the assessor saw the insulation but not its depth). 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 2.30 the Table 16 row-0 lookup would give.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
description="Pitched, insulated",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.68, abs=0.01)
|
||
|
||
|
||
def test_u_roof_ni_thickness_with_assumed_insulated_description_uses_table18_default() -> None:
|
||
# Arrange — RdSAP renders "insulated (assumed)" when insulation
|
||
# presence is ASSUMED (age-band / as-built assumption), not an
|
||
# observed retrofit — so the §5.11.4 "retrofit insulation present of
|
||
# unknown thickness use 50 mm" footnote does NOT apply; the thickness
|
||
# "cannot be determined" and Table 18's age-band default governs
|
||
# (§5.11.4 opening clause). The corpus lodges the SAME description
|
||
# with thickness "ND" (102 certs → already Table 18 via the
|
||
# thickness-None fallthrough) or "NI" (27 certs → previously took the
|
||
# 50 mm row 0.68), purely per lodging software; both must land on the
|
||
# same Table 18 value (band C pitched → 0.40). The loc-4 NI cohort
|
||
# was systematically under-rated by ~1 SAP before this (e.g. corpus
|
||
# cert 10094975827 band B: Δ −1.87).
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
description="Pitched, insulated (assumed)",
|
||
)
|
||
|
||
# Assert — Table 18 column (1) band C.
|
||
assert result == pytest.approx(0.40, 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_unknown_loft_insulation_uses_table18_default_per_section_5_11_4() -> None:
|
||
# Arrange — "Pitched, Unknown loft insulation" lodges
|
||
# roof_insulation_thickness 'NI' (Not Indicated, parsed to 0) — the
|
||
# thickness is UNDETERMINED, not zero. RdSAP 10 §5.11.4 (page 44):
|
||
# "U-values in Table 18 are used when thickness of insulation cannot
|
||
# be determined." So a pitched roof takes the Table 18 column (1)
|
||
# age-band default (age A = 0.40), NOT the uninsulated 2.30 the
|
||
# Table 16 row-0 lookup gives for a parsed-0 thickness. Cert
|
||
# 9836-5829-1500-0803-7206 (top-floor flat, age A).
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="A",
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
description="Pitched, Unknown loft insulation",
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.40) <= 0.01
|
||
|
||
|
||
def test_u_roof_unknown_flat_insulation_uses_table18_flat_column() -> None:
|
||
# Arrange — an "Unknown" flat-roof lodgement with no determinable
|
||
# thickness (None) takes Table 18 column (3) "Flat roof" age-band
|
||
# default (age H = 0.35), per §5.11.4 — not 2.30.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="H",
|
||
insulation_thickness_mm=None,
|
||
description="Flat, Unknown insulation",
|
||
is_flat_roof=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.35) <= 0.01
|
||
|
||
|
||
def test_u_roof_flat_no_insulation_undetermined_thickness_uses_table18_by_age() -> None:
|
||
# Arrange — a flat roof lodged "Flat, no insulation" / "Flat, limited
|
||
# insulation" with an UNDETERMINED thickness (parsed to None from
|
||
# 'ND'/'AB') must take the Table 18 column (3) flat-roof age-band
|
||
# default per RdSAP 10 §5.11.4 (PDF p.44), NOT the uninsulated 2.30.
|
||
# The "no/limited insulation" text is RdSAP's as-built rendering — at
|
||
# old bands the column (3) default IS 2.30 (so they're unchanged), but
|
||
# a newer-band flat roof carries the age-band insulation as built.
|
||
# Cert 0390-2753 (top-floor flat, band H, "Flat, no insulation",
|
||
# thickness 'ND', roof rating 3 = moderate) drove a -31.78 SAP error at
|
||
# the 2.30 value; band H column (3) = 0.35.
|
||
|
||
# Act — band H "no insulation" → 0.35; band F "limited insulation" → 0.68;
|
||
# band C "no insulation" → unchanged 2.30 (column (3) default at C).
|
||
band_h = u_roof(
|
||
country=Country.ENG, age_band="H", insulation_thickness_mm=None,
|
||
description="Flat, no insulation", is_flat_roof=True,
|
||
)
|
||
band_f = u_roof(
|
||
country=Country.ENG, age_band="F", insulation_thickness_mm=None,
|
||
description="Flat, limited insulation", is_flat_roof=True,
|
||
)
|
||
band_c = u_roof(
|
||
country=Country.ENG, age_band="C", insulation_thickness_mm=None,
|
||
description="Flat, no insulation", is_flat_roof=True,
|
||
)
|
||
# A PITCHED roof "no insulation" with undetermined thickness is NOT
|
||
# rerouted — its text is load-bearing (2.30 stays).
|
||
pitched = u_roof(
|
||
country=Country.ENG, age_band="H", insulation_thickness_mm=None,
|
||
description="Pitched, no insulation", is_flat_roof=False,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(band_h - 0.35) <= 0.01
|
||
assert abs(band_f - 0.68) <= 0.01
|
||
assert abs(band_c - 2.30) <= 0.01
|
||
assert abs(pitched - 2.30) <= 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_at_rafters_explicit_thickness_uses_table16_column_2() -> None:
|
||
# Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2)
|
||
# "insulation at rafters". A roof lodged insulated AT RAFTERS
|
||
# (roof_insulation_location == 1, "R Rafters" on the Summary path)
|
||
# takes the rafters thickness ladder, NOT the column (1) joist row:
|
||
# at 200 mm the rafters U is 0.29 W/m²K vs the joists 0.21 — a ~38%
|
||
# heat-loss understatement when the joists column is mis-used. The
|
||
# joists column (1) stays 0.21 for the same thickness.
|
||
|
||
# Act
|
||
at_rafters = u_roof(
|
||
country=Country.ENG, age_band="C", insulation_thickness_mm=200,
|
||
insulation_at_rafters=True,
|
||
)
|
||
at_joists = u_roof(
|
||
country=Country.ENG, age_band="C", insulation_thickness_mm=200,
|
||
insulation_at_rafters=False,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(at_rafters - 0.29) <= 0.001
|
||
assert abs(at_joists - 0.21) <= 0.001
|
||
|
||
|
||
def test_u_roof_at_rafters_thickness_ladder_matches_table16_column_2() -> None:
|
||
# Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2) rows:
|
||
# 50 mm → 0.88, 100 mm → 0.54, 150 mm → 0.39, 270 mm → 0.21. Each is
|
||
# higher than the joists column (1) value at the same thickness (the
|
||
# rafter cavity is shallower so the same insulation depth yields a
|
||
# higher U).
|
||
|
||
# Act / Assert
|
||
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=50, insulation_at_rafters=True) - 0.88) <= 0.001
|
||
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=100, insulation_at_rafters=True) - 0.54) <= 0.001
|
||
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=150, insulation_at_rafters=True) - 0.39) <= 0.001
|
||
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=270, insulation_at_rafters=True) - 0.21) <= 0.001
|
||
|
||
|
||
def test_u_roof_at_rafters_unknown_thickness_uses_table18_rafters_age_band() -> None:
|
||
# Arrange — RdSAP 10 §5.11 Table 18 (PDF p.45) rafters age-band
|
||
# column. A rafter-insulated roof with no determinable thickness
|
||
# ("R Rafters" + "As Built" → thickness None) takes the rafters
|
||
# age-band default. Band F → 0.68 (== the joists value at F), band H
|
||
# → 0.35 (vs joists 0.30), band J → 0.20 (vs joists 0.16). Unlike a
|
||
# loft-joist roof the rafter cavity cannot be topped up, so the
|
||
# optimistic 0.40 "assume modern retrofit" joist floor does NOT apply
|
||
# at old bands — band C stays 2.30 (vs the joists-unknown 0.40).
|
||
# Worksheet-validated by simulated case 41 Ext3 (band F, R Rafters,
|
||
# As Built → P960 §3 (30) U=0.68).
|
||
|
||
# Act
|
||
band_f = u_roof(country=Country.ENG, age_band="F", insulation_thickness_mm=None, insulation_at_rafters=True)
|
||
band_h = u_roof(country=Country.ENG, age_band="H", insulation_thickness_mm=None, insulation_at_rafters=True)
|
||
band_j = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None, insulation_at_rafters=True)
|
||
band_c = u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=None, insulation_at_rafters=True)
|
||
|
||
# Assert
|
||
assert abs(band_f - 0.68) <= 0.001
|
||
assert abs(band_h - 0.35) <= 0.001
|
||
assert abs(band_j - 0.20) <= 0.001
|
||
assert abs(band_c - 2.30) <= 0.001
|
||
|
||
|
||
def test_u_roof_at_rafters_unknown_thickness_age_m_returns_0_15_per_table18() -> None:
|
||
# Arrange — RdSAP 10 Table 18 column (2) "Pitched, insulation at
|
||
# rafters" (PDF p.46): band M = 0.15 (footnote (1) only, no country
|
||
# variation — the whole M row converges to 0.15). The rafters column
|
||
# diverges above the joist column at H-L (0.35/0.35/0.20/0.20/0.18)
|
||
# but rejoins it at M = 0.15; the table previously carried 0.18 here.
|
||
|
||
# Act
|
||
band_m = u_roof(
|
||
country=Country.ENG,
|
||
age_band="M",
|
||
insulation_thickness_mm=None,
|
||
insulation_at_rafters=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(band_m - 0.15) <= 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_observed_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 + an
|
||
# unqualified "insulated" description (observed retrofit, no
|
||
# "(assumed)") must STILL hit the §5.11.4 retrofit-50 mm row
|
||
# (U=0.68). The "(assumed)" form routes to Table 18 instead — see
|
||
# test_u_roof_ni_thickness_with_assumed_insulated_description_uses_table18_default.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="J",
|
||
insulation_thickness_mm=0,
|
||
description="Pitched, insulated",
|
||
)
|
||
|
||
# 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_post_2022_metal_returns_table24_1_6_not_pvc_1_4() -> None:
|
||
# Arrange — Table 24 "2022 or later" row (PDF p.51): PVC/wooden frame
|
||
# 1.4, METAL frame 1.6. The metal frame variant was previously ignored
|
||
# (1.4 returned for both), under-counting metal-frame heat loss.
|
||
|
||
# Act
|
||
result = u_window(installed_year=2023, glazing_type="double", frame_type="metal")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.6, abs=0.001)
|
||
|
||
|
||
def test_u_window_pre_2002_double_glazing_gap_selects_table24_row() -> None:
|
||
# Arrange — RdSAP 10 Table 24 (PDF p.50) pre-2002 double glazing splits
|
||
# by glazing gap (PVC/wooden frame): 6 mm → 3.1, 12 mm → 2.8, 16 mm or
|
||
# more → 2.7. The cert lodges the gap as the int 6/12 or the string
|
||
# "16+"; unknown gap defaults to the 12 mm row.
|
||
|
||
# Act / Assert
|
||
assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=6) == pytest.approx(3.1, abs=0.001)
|
||
assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=12) == pytest.approx(2.8, abs=0.001)
|
||
assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap="16+") == pytest.approx(2.7, abs=0.001)
|
||
assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=None) == pytest.approx(2.8, abs=0.001)
|
||
|
||
|
||
def test_u_window_pre_2002_triple_glazing_gap_and_metal_frame_select_table24_row() -> None:
|
||
# Arrange — Table 24 pre-2002 triple glazing: 6 mm → 2.4, 12 mm → 2.1,
|
||
# 16 mm+ → 2.0 (PVC); metal frame adds +0.5 per the metal column
|
||
# (6 → 2.9, 12 → 2.6, 16+ → 2.5).
|
||
|
||
# Act / Assert
|
||
assert u_window(installed_year=None, glazing_type="triple", frame_type="pvc", glazing_gap="16+") == pytest.approx(2.0, abs=0.001)
|
||
assert u_window(installed_year=None, glazing_type="triple", frame_type="metal", glazing_gap=6) == pytest.approx(2.9, abs=0.001)
|
||
assert u_window(installed_year=None, glazing_type="triple", frame_type="metal", glazing_gap="16+") == pytest.approx(2.5, 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_any_code_uses_default() -> None:
|
||
# Arrange — the RdSAP10 reduced-data method does NOT consume the
|
||
# gov-API `wall_insulation_thermal_conductivity` field: the Elmhurst
|
||
# RdSAP10 tool exposes no conductivity input (a wall is Type +
|
||
# Insulation + thickness only), so SAP 10.2 §5.8 (p.41) default
|
||
# λ=0.04 W/m·K always applies regardless of the lodged code. Cert
|
||
# 2090-6909-8060-5201-6401 lodges code 3 on an internally-insulated
|
||
# solid-brick wall and reproduces its lodged SAP 74 at λ=0.04
|
||
# (continuous 73.97; 0.04/0.03/0.025 all round to 74). Pre-this the
|
||
# helper mapped only code 1 and RAISED on 2/3, blocking the cert.
|
||
from domain.sap10_ml.rdsap_uvalues import (
|
||
_resolve_wall_insulation_lambda_w_per_mk,
|
||
)
|
||
|
||
# Act
|
||
lam_2 = _resolve_wall_insulation_lambda_w_per_mk(2)
|
||
lam_3 = _resolve_wall_insulation_lambda_w_per_mk(3)
|
||
lam_3_str = _resolve_wall_insulation_lambda_w_per_mk("3")
|
||
|
||
# Assert — every code resolves to the §5.8 default 0.04, never raises.
|
||
assert abs(lam_2 - 0.04) <= 1e-9
|
||
assert abs(lam_3 - 0.04) <= 1e-9
|
||
assert abs(lam_3_str - 0.04) <= 1e-9
|