mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
`u_window` hard-coded the 12 mm gap row for pre-2002 double/triple glazing (double 2.8, triple 2.1), ignoring the lodged glazing gap. Table 24 splits the pre-2002 rows by gap: double 6mm=3.1 / 12mm=2.8 / 16mm+=2.7; triple 6mm=2.4 / 12mm=2.1 / 16mm+=2.0 (PVC/wooden), with a metal-frame column (+0.5/+0.5/+0.5 ish). Added a `glazing_gap` parameter + `_glazing_gap_row` helper and wired `w.glazing_gap` through the synthesised-window caller in heat_transmission. Corpus impact nil by design: the gov-API mapper already resolves per-window U gap-aware via `_API_GLAZING_TYPE_GAP_TO_TRANSMISSION` (e.g. code 3 + gap "16+" → 2.7), so corpus certs use that lodged per-window U, not this fallback. This aligns the reduced-field / worksheet fallback path with the mapper and Table 24. Unknown gap still defaults to the 12 mm row. (Metal frames are not distinguishable on the gov-API path — only a `pvc_frame` boolean exists and Table 24 groups PVC+wooden — so the PVC/wooden U stands there; the metal column applies only where frame material is lodged.) Spec-pinned: pre-2002 double + triple gap-row tests. pyright not installed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2418 lines
86 KiB
Python
2418 lines
86 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_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_insulated_description_applies_50mm_per_section_5_11_4() -> None:
|
||
# Arrange — 346 corpus certs lodge roof_insulation_thickness="NI"
|
||
# (Not Indicated, parsed to 0 by _parse_thickness_mm). When the
|
||
# description also signals retrofit insulation ("Pitched, insulated
|
||
# (assumed)" / "Flat, insulated" / "Roof room(s), insulated
|
||
# (assumed)"), RdSAP 10 §5.11.4 (page 44) footnote applies:
|
||
# "If retrofit insulation present of unknown thickness use 50 mm".
|
||
# That maps to Table 16 row "50 mm at joists at ceiling level" = 0.68
|
||
# W/m²K — vs the current 2.30 we return when thickness=0 hits the
|
||
# Table 16 row-0 lookup.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
description="Pitched, insulated (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.68, abs=0.01)
|
||
|
||
|
||
def test_u_roof_ni_thickness_with_no_insulation_description_stays_at_2_30() -> None:
|
||
# Arrange — 706 corpus certs lodge "Pitched, no insulation
|
||
# (assumed)" which can co-occur with thickness="NI". The
|
||
# description-based override for retrofit-insulated roofs must
|
||
# respect the "no insulation" negation: `_described_as_insulated`
|
||
# returns False on "no insulation" substring, so the Table 16
|
||
# row-0 lookup applies and U = 2.30 W/m²K stays.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=0,
|
||
description="Pitched, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.01)
|
||
|
||
|
||
def test_u_roof_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_insulated_still_returns_50mm_per_5_11_4() -> None:
|
||
# Arrange — regression guard: the is_sloping_ceiling flag defaults
|
||
# False, so a NORMAL pitched roof (with loft) lodged NI + "insulated
|
||
# (assumed)" must STILL hit the §5.11.4 retrofit-50 mm row (U=0.68).
|
||
# Same inputs as the sloping test above minus is_sloping_ceiling.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="J",
|
||
insulation_thickness_mm=0,
|
||
description="Pitched, insulated (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.68) <= 1e-4
|
||
|
||
|
||
def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None:
|
||
# Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof":
|
||
# age band D, thickness unknown → U = 2.30 W/m²K. Column (1)
|
||
# (pitched-between-joists default) returns 0.40 for the same age
|
||
# band; routing must pick column (3) when the per-bp roof
|
||
# construction lodges as flat.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="D", insulation_thickness_mm=None,
|
||
is_flat_roof=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 2.30) <= 1e-4
|
||
|
||
|
||
def test_u_roof_flat_age_band_g_returns_table18_col3_value() -> None:
|
||
# Arrange — Table 18 column (3) flat-roof default is 0.40 for age G,
|
||
# the cross-over point where the flat-roof and pitched-roof columns
|
||
# agree. Confirms the dict is populated across the full age range.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="G", insulation_thickness_mm=None,
|
||
is_flat_roof=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.40) <= 1e-4
|
||
|
||
|
||
def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None:
|
||
# Arrange — Table 18 column (3) flat-roof default is 0.18 for age L,
|
||
# the modern band where both columns agree.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="L", insulation_thickness_mm=None,
|
||
is_flat_roof=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.18) <= 1e-4
|
||
|
||
|
||
def test_u_roof_pitched_sloping_ceiling_as_built_band_f_uses_col3() -> None:
|
||
# Arrange — RdSAP 10 §5.11 Table 18 page 45 + roof-input item 5-5
|
||
# ("Sloping ceiling insulation ... unknown / as built → Table 18").
|
||
# A "Pitched, sloping ceiling" roof (roof_construction code 8) with an
|
||
# "As Built" insulation lodgement (no measured thickness → None) takes
|
||
# the Table 18 column (3) age-band default, NOT the column (1)
|
||
# "insulation between joists" default. Note (b) on column (3) states it
|
||
# "applies also to roof with sloping ceiling". For age band F the
|
||
# column (3) value is 0.68 W/m²K (vs column (1) 0.40 — the loft-joist
|
||
# assumption that is wrong for a sloping ceiling with no joist void).
|
||
#
|
||
# Worksheet-validated: simulated case 15 (7536 replica) lodges Ext2 as
|
||
# band F "PS Pitched, sloping ceiling, As Built"; its P960 worksheet
|
||
# pins `External roof Ext2 … 0.68`, and the full-cascade roof HLC and
|
||
# SAP match Elmhurst exactly only with column (3).
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="F", insulation_thickness_mm=None,
|
||
is_pitched_sloping_ceiling=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.68) <= 1e-4
|
||
|
||
|
||
def test_u_roof_pitched_sloping_ceiling_as_built_band_l_uses_col3() -> None:
|
||
# Arrange — same rule at band L (2012-2022): Table 18 column (3) gives
|
||
# 0.18 W/m²K, where columns (2)/(3) coincide. Simulated case 15's Ext1
|
||
# (band L PS sloping ceiling, As Built) pins worksheet U=0.18 (vs the
|
||
# column (1) value 0.16 the cascade returned pre-fix).
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="L", insulation_thickness_mm=None,
|
||
is_pitched_sloping_ceiling=True,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.18) <= 1e-4
|
||
|
||
|
||
def test_u_roof_vaulted_nd_unknown_band_d_still_col1_not_col3() -> None:
|
||
# Arrange — regression guard for the discriminator: a code-5 "vaulted"
|
||
# roof lodged "ND" (thickness None) is the UNKNOWN-insulation case and
|
||
# must stay on Table 18 column (1) — band D = 0.40 — per the 33
|
||
# cohort-2 vaulted certs (S0380.211). The col (3) routing fires only
|
||
# for code-8 "Pitched, sloping ceiling" (is_pitched_sloping_ceiling),
|
||
# NOT for vaulted ceilings, so this defaults False here and resolves
|
||
# to column (1) 0.40, NOT column (3) 2.30.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG, age_band="D", insulation_thickness_mm=None,
|
||
)
|
||
|
||
# Assert
|
||
assert abs(result - 0.40) <= 1e-4
|
||
|
||
|
||
def test_u_roof_description_no_insulation_overrides_age_band_default() -> None:
|
||
# Arrange — surveyor description on a Victorian roof says uninsulated;
|
||
# Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm
|
||
# joist insulation is 2.30 W/m^2K.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=None,
|
||
description="Pitched, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.001)
|
||
|
||
|
||
def test_u_roof_description_limited_insulation_overrides_age_band_default() -> None:
|
||
# Arrange — "limited insulation" maps to Table 16 row 12mm -> 1.50 W/m^2K.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="D",
|
||
insulation_thickness_mm=None,
|
||
description="Roof room(s), limited insulation",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.50, abs=0.001)
|
||
|
||
|
||
def test_u_roof_description_uninsulated_synonym_also_triggers_high_u() -> None:
|
||
# Arrange — surveyor writes "uninsulated" (no space) instead of "no insulation".
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
insulation_thickness_mm=None,
|
||
description="Flat, uninsulated",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(2.30, abs=0.001)
|
||
|
||
|
||
def test_u_roof_description_well_insulated_does_not_override_default() -> None:
|
||
# Arrange — description says "insulated"; do NOT override the Table 18
|
||
# age-G default of 0.40 with a penalty.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="G",
|
||
insulation_thickness_mm=None,
|
||
description="Pitched, insulated at rafters",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.40, abs=0.001)
|
||
|
||
|
||
def test_u_roof_explicit_thickness_beats_description() -> None:
|
||
# Arrange — when surveyor measured 200mm joist insulation, Table 16 wins
|
||
# regardless of any description text. 200mm -> 0.21 W/m^2K.
|
||
|
||
# Act
|
||
result = u_roof(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
insulation_thickness_mm=200,
|
||
description="No insulation", # ignored because thickness is explicit
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.21, abs=0.001)
|
||
|
||
|
||
# ----- Floors -----
|
||
|
||
|
||
def test_u_floor_description_with_measured_transmittance_returns_parsed_value() -> None:
|
||
# Arrange — ~1 391 corpus certs lodge a full-SAP measured floor
|
||
# U-value in the description, e.g. "Average thermal transmittance
|
||
# 0.18 W/m²K". The BS EN ISO 13370 calculation is bypassed: the
|
||
# assessor's measured/calculated value is used directly. Same
|
||
# contract as `u_wall` (S-B24).
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
area_m2=100.0,
|
||
perimeter_m=40.0,
|
||
wall_thickness_mm=300,
|
||
description="Average thermal transmittance 0.18 W/m²K",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.18, abs=0.001)
|
||
|
||
|
||
def test_u_floor_ni_thickness_with_insulated_description_applies_50mm_per_table19_footnote() -> None:
|
||
# Arrange — 2 413 corpus certs (~12%) lodge floors with
|
||
# floor_insulation_thickness="NI" (Not Indicated, which our
|
||
# _parse_thickness_mm returns as 0) AND a description "Solid,
|
||
# insulated (assumed)" or "Suspended, insulated (assumed)". The
|
||
# assessor sees insulation but hasn't measured the thickness.
|
||
# RdSAP 10 §5.12 Table 19 footnote (2):
|
||
# "For floors which have retrofitted insulation, use the greater
|
||
# of 50 mm and the thickness according to the age band."
|
||
# Band B's age-band default is 0 mm, so max(50, 0) = 50 mm applies.
|
||
# Geometry: 100 m² × 40 m perimeter, w=0.3, gives B=5, d_t=2.758
|
||
# (with R_f from 50 mm/0.035 = 1.429); U = 2 × 1.5 × ln(π×5/2.758 + 1)
|
||
# / (π×5 + 2.758) ≈ 0.31 W/m²K.
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=None,
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
area_m2=100.0,
|
||
perimeter_m=40.0,
|
||
wall_thickness_mm=300,
|
||
description="Solid, insulated (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.31, abs=0.02)
|
||
|
||
|
||
def test_u_floor_ni_thickness_with_no_insulation_description_stays_uninsulated() -> None:
|
||
# Arrange — 8 221 corpus certs lodge "Solid, no insulation
|
||
# (assumed)" with thickness="NI". The Table 19 footnote (2) override
|
||
# must not fire on these: the "no insulation" substring takes
|
||
# precedence over the "insulated" substring per
|
||
# `_described_as_insulated`. Same geometry as the cycle-1 test;
|
||
# uninsulated U should be ~0.60 W/m²K (B=5, d_t=0.615 with R_f=0).
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=None,
|
||
insulation_thickness_mm=0, # parsed from "NI"
|
||
area_m2=100.0,
|
||
perimeter_m=40.0,
|
||
wall_thickness_mm=300,
|
||
description="Solid, no insulation (assumed)",
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.60, abs=0.02)
|
||
|
||
|
||
def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() -> None:
|
||
# Arrange — solid floor, age C, England.
|
||
# BS EN ISO 13370 with A=80, P=36, w=0.22m, soil g=1.5, Rsi=0.17, Rse=0.04, Rf=0
|
||
# d_t = 0.22 + 1.5 * (0.17 + 0 + 0.04) = 0.535
|
||
# B = 2 * 80 / 36 = 4.444
|
||
# d_t < B so U = 2 * 1.5 * ln(pi*B/d_t + 1) / (pi*B + d_t)
|
||
# = 3 * ln(pi*4.444/0.535 + 1) / (pi*4.444 + 0.535)
|
||
# = 3 * ln(27.10) / (14.49)
|
||
# = 3 * 3.300 / 14.49 = 0.683 -> rounds to 0.68
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="C",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
area_m2=80.0,
|
||
perimeter_m=36.0,
|
||
wall_thickness_mm=220,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.68, abs=0.05)
|
||
|
||
|
||
def test_u_floor_age_b_unknown_construction_uses_suspended_timber_per_table_19_footnote_1() -> None:
|
||
# Arrange — RdSAP10 §5.12 Table 19 footnote (1) routes age A, B with
|
||
# unknown floor_construction to the suspended-timber branch. Geometry
|
||
# is taken from Elmhurst worksheet U985-0001-000490 Main Dwelling
|
||
# (A=14.85, P=7.42, w=0.400) — the worksheet records U=0.71 W/m²K,
|
||
# confirming the suspended-floor formula on §5.12 (page 46) is the
|
||
# one Elmhurst applies for this fixture.
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=Country.ENG,
|
||
age_band="B",
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
area_m2=14.85,
|
||
perimeter_m=7.42,
|
||
wall_thickness_mm=400,
|
||
)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(0.71, abs=0.01)
|
||
|
||
|
||
def test_u_floor_with_insulation_lowers_u_value() -> None:
|
||
# Arrange — same geometry but with 100mm insulation -> R_f = 0.1/0.035 = 2.857.
|
||
|
||
# Act
|
||
insulated = u_floor(
|
||
country=Country.ENG,
|
||
age_band="K",
|
||
construction=None,
|
||
insulation_thickness_mm=100,
|
||
area_m2=80.0,
|
||
perimeter_m=36.0,
|
||
wall_thickness_mm=220,
|
||
)
|
||
|
||
# Assert — well below uninsulated case (~0.27 W/m^2K).
|
||
assert insulated < 0.3
|
||
|
||
|
||
def test_u_exposed_floor_age_b_unknown_insulation_uses_table_20_row_a_to_g() -> None:
|
||
# Arrange — RdSAP10 §5.13 Table 20 (page 47) gives U-values for
|
||
# exposed and semi-exposed upper floors keyed on age band +
|
||
# insulation thickness. The "Insulation unknown or as built"
|
||
# column at age band A-G = 1.20 W/m²K. Elmhurst worksheet
|
||
# U985-0001-000490 Extension 1 records U=1.20 for its exposed
|
||
# timber floor (1900-1929, no insulation lodged) — this lookup
|
||
# reproduces that exact value without any geometry input.
|
||
|
||
# Act
|
||
result = u_exposed_floor(age_band="B", insulation_thickness_mm=None)
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.20, abs=0.001)
|
||
|
||
|
||
def test_u_floor_above_partially_heated_space_returns_0p7_per_rdsap_10_section_5_14() -> None:
|
||
# Arrange — RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a
|
||
# partially heated space":
|
||
# "The U-value of a floor above partially heated premises is
|
||
# taken as 0.7 W/m²K. This applies typically for a flat above
|
||
# non-domestic premises that are not heated to the same extent
|
||
# or duration as the flat."
|
||
# Verbatim constant — no age-band or insulation-thickness inputs.
|
||
# Cert 000565 Ext1 (Summary §9: "P Above partially heated space",
|
||
# Default U-value 0.70) exercises this branch.
|
||
|
||
# Act
|
||
result = u_floor_above_partially_heated_space()
|
||
|
||
# Assert
|
||
assert abs(result - 0.7) <= 1e-4
|
||
|
||
|
||
def test_u_floor_falls_back_to_mid_range_when_geometry_unknown() -> None:
|
||
# Arrange — geometry missing.
|
||
|
||
# Act
|
||
result = u_floor(
|
||
country=None,
|
||
age_band=None,
|
||
construction=None,
|
||
insulation_thickness_mm=None,
|
||
area_m2=None,
|
||
perimeter_m=None,
|
||
wall_thickness_mm=None,
|
||
)
|
||
|
||
# Assert — mid-range fallback ~0.7 W/m^2K (solid-uninsulated mid-band typical).
|
||
assert result == pytest.approx(0.7, abs=0.05)
|
||
|
||
|
||
# ----- Windows -----
|
||
|
||
|
||
def test_u_window_single_glazed_pvc_returns_table24_value() -> None:
|
||
# Arrange — Table 24: single glazing, any period, PVC/wooden frame -> 4.8 W/m^2K.
|
||
|
||
# Act
|
||
result = u_window(installed_year=None, glazing_type="single", frame_type="pvc")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(4.8, abs=0.001)
|
||
|
||
|
||
def test_u_window_post_2022_pvc_returns_low_table24_value() -> None:
|
||
# Arrange — Table 24: double or triple glazed, 2022 or later, PVC -> 1.4 W/m^2K.
|
||
|
||
# Act
|
||
result = u_window(installed_year=2023, glazing_type="double", frame_type="pvc")
|
||
|
||
# Assert
|
||
assert result == pytest.approx(1.4, abs=0.001)
|
||
|
||
|
||
def test_u_window_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
|