"""Tests for RdSAP10 U-value cascade-defaulting helpers. Reference values are taken from the RdSAP10 specification (12 February 2024): - Tables 6-9 — wall U-values per country - Table 14 — insulation thickness <-> resistance - Table 15 — party-wall U-values - Table 18 — roof U-values by age band - Table 19 — floor insulation defaults by age band - Table 20 — exposed/semi-exposed upper-floor U-values - Table 21 — thermal-bridging factor y - Table 24 — window U-values - Table 26 — door U-values The functions never raise on missing inputs; they cascade through age-band defaults -> country defaults -> final mid-range value so that callers can treat the envelope as if RdSAP had assigned an as-built default. """ from typing import Optional import pytest from domain.sap10_ml.rdsap_uvalues import ( Country, WALL_CAVITY, WALL_CAVITY_FILLED_PARTY, WALL_CURTAIN, WALL_INSULATION_FILLED_CAVITY, WALL_SOLID_BRICK, WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SYSTEM_BUILT, WALL_TIMBER_FRAME, thermal_bridging_y, u_door, u_exposed_floor, u_floor, u_floor_above_partially_heated_space, u_party_wall, u_roof, u_rr_default_all_elements, u_rr_flat_ceiling, u_rr_slope, u_rr_stud_wall, u_wall, u_window, ) # ----- Walls ----- def test_u_wall_description_with_measured_transmittance_returns_parsed_value() -> None: # Arrange — full SAP (not RdSAP) assessments lodge a measured/calculated # U-value per BS EN ISO 6946 in the wall description string, e.g. # "Average thermal transmittance 0.18 W/m²K". These certs typically # have wall_construction, wall_insulation_type, and age_band all None # because the cascade defaults don't apply — the assessor's measured # value takes precedence (RdSAP 10 §5.3). Affects ~15% of corpus. # Act result = u_wall( country=None, age_band=None, construction=None, insulation_thickness_mm=None, description="Average thermal transmittance 0.18 W/m²K", ) # Assert assert result == pytest.approx(0.18, abs=0.001) def test_u_wall_description_with_malformed_transmittance_falls_through_to_cascade() -> None: # Arrange — a description containing the phrase but a malformed value # (e.g. just a stray dot) should NOT short-circuit to a parse failure; # it should fall through to the construction cascade and return a # spec-defined value. This is the calculator's "trust the cert when # parseable, never raise" contract. # Act result = u_wall( country=Country.ENG, age_band="G", construction=WALL_CAVITY, insulation_thickness_mm=0, description="Average thermal transmittance . W/m²K", ) # Assert — Table 6 cavity-as-built row at band G = 0.60 W/m²K. assert result == pytest.approx(0.60, abs=0.001) def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote() -> None: # Arrange — 685 corpus certs lodge solid-brick walls with # wall_insulation_type ∈ {1 external, 3 internal} and # wall_insulation_thickness="NI" (Not Indicated). RdSAP 10 Table 6 # footnote: "If a wall is known to have additional insulation but # the insulation thickness is unknown, use the row in the table for # 50 mm insulation." Our `_parse_thickness_mm("NI")` returns 0, which # combined with `insulation_present=True` must now route to the 50 mm # bucket (U=0.55 at A-E), not the as-built bucket (U=1.7). # Act result = u_wall( country=Country.ENG, age_band="B", construction=WALL_SOLID_BRICK, insulation_thickness_mm=0, insulation_present=True, ) # Assert — Stone/solid brick with 50 mm row at band B = 0.55. assert result == pytest.approx(0.55, abs=0.001) def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() -> None: # Arrange — 1 171 corpus certs (~4% of scanned bulk) lodge # wall_insulation_type=4 ("as-built / assumed") together with the # description "Cavity wall, as built, insulated (assumed)". The # assessor is saying: this cavity is filled, but I haven't measured # the thickness. Spec footnote on Table 6 covers this: "If a wall # is known to have additional insulation but the insulation thickness # is unknown, use the row in the table for 50 mm insulation" — but # legacy convention (used by the production recommendation engine) # is to route this to the Filled-cavity row, U = 0.7 at A-E. We # follow the legacy convention here for parity with the cert assessor. # Act result = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, insulation_thickness_mm=None, insulation_present=False, # type=4 maps to wall_ins_present=False wall_insulation_type=4, description="Cavity wall, as built, insulated (assumed)", ) # Assert assert result == pytest.approx(0.7, abs=0.001) def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_row() -> None: # Arrange — the same wall_insulation_type=4 ("as-built / assumed") # cert population also contains 686 "Cavity wall, as built, no # insulation (assumed)" entries which must continue to route to the # Cavity-as-built row of Table 6 (U=1.5 at band E). The "no # insulation" substring marker takes precedence over the # "insulated"-substring filled-cavity rule, so this case is # disambiguated from "Cavity wall, as built, insulated (assumed)". # Act result = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, description="Cavity wall, as built, no insulation (assumed)", ) # Assert assert result == pytest.approx(1.5, abs=0.001) def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() -> None: # Arrange — 147 corpus certs lodge "Cavity wall, as built, partial # insulation (assumed)" with wall_insulation_type=4. The legacy # production map (recommendations/rdsap_tables.py:753) routes these # to "Filled cavity" — same destination as the "insulated (assumed)" # case. We match that interpretation for parity with the cert # assessor and the production recommendation engine. # Act result = u_wall( country=Country.ENG, age_band="D", # 1950-1966 — typical partial-fill retrofit cohort construction=WALL_CAVITY, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, description="Cavity wall, as built, partial insulation (assumed)", ) # Assert — Filled-cavity row at band D = 0.7 W/m²K. assert result == pytest.approx(0.7, abs=0.001) def test_u_wall_description_without_transmittance_phrase_routes_through_cascade() -> None: # Arrange — the measured-U dispatcher must only fire when the # description contains the "thermal transmittance" phrase. The # ordinary surveyor-text descriptions (e.g. "Cavity wall, filled # cavity") must still route through the construction cascade. # Act result = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, insulation_thickness_mm=0, insulation_present=True, wall_insulation_type=WALL_INSULATION_FILLED_CAVITY, description="Cavity wall, filled cavity", ) # Assert — should return the Filled-cavity row value, not anything # parsed out of the description. assert result == pytest.approx(0.7, abs=0.001) def test_u_wall_filled_cavity_england_age_band_e_returns_table6_value() -> None: # Arrange — RdSAP 10 Table 6 (England) row "Filled cavity", age band E # (1967-1975) -> 0.7 W/m^2K. The cert records this as the triple # (wall_construction=4 cavity, wall_insulation_type=2 filled, # wall_insulation_thickness="NI"). Spec: domain/sap10_calculator/docs/specs/rdsap-10- # specification-2025-06-10.pdf page 33. # Act result = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, insulation_thickness_mm=0, insulation_present=True, wall_insulation_type=WALL_INSULATION_FILLED_CAVITY, ) # Assert assert result == pytest.approx(0.7, abs=0.001) @pytest.mark.parametrize( "age_band,expected_u", [ # RdSAP 10 Table 6 (England) "Filled cavity" row sampled at three bands: # A (pre-1900) = 0.7 — early cavity dwellings, retro-filled. # F (1976-1982) = 0.40 — first cavity-insulation era. # K (2007+) = 0.30 — "assumed as built" (†) — matches Cavity-as-built K. ("A", 0.7), ("F", 0.40), ("K", 0.30), ], ) def test_u_wall_filled_cavity_england_row_matches_table6_across_age_bands( age_band: str, expected_u: float ) -> None: # Arrange — the dispatcher must return the right cell of the # "Filled cavity" row, not just the band-E value used by the tracer. # Act result = u_wall( country=Country.ENG, age_band=age_band, construction=WALL_CAVITY, insulation_thickness_mm=0, insulation_present=True, wall_insulation_type=WALL_INSULATION_FILLED_CAVITY, ) # Assert assert result == pytest.approx(expected_u, abs=0.001) def test_u_wall_unfilled_cavity_england_age_band_e_unchanged_at_1_5() -> None: # Arrange — adding the filled-cavity dispatcher must not regress the # existing as-built path. Band E + cavity construction + no insulation # type set -> the "Cavity as built" row of Table 6, U = 1.5 W/m^2K. # Act result = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, insulation_thickness_mm=0, insulation_present=False, wall_insulation_type=None, ) # Assert assert result == pytest.approx(1.5, abs=0.001) def test_u_wall_dry_lined_cavity_as_built_age_c_applies_rdsap_5_8_r_0_17_adjustment() -> None: # Arrange — RdSAP10 §5.8 final note + Table 14 page 41: "For drylining # including laths and plaster use Rinsulation = 0.17 m²K/W." Applied # additively to the base U-value of an otherwise-uninsulated wall. # Cohort fixture: cert 7700-3362-0922-7022-3563 Alt 1 lodges Cavity, # As-Built, Dry-lining: Yes, age band C → worksheet # `CavityWallPlasterOnDabsDenseBlock` U-value = 1.20 W/m²K. # Closed form: 1 / (1/1.5 + 0.17) = 1.19522... → 2 d.p. half-up = 1.20. # Act result = u_wall( country=Country.ENG, age_band="C", construction=WALL_CAVITY, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, dry_lined=True, ) # Assert — adjusted U is rounded to 2 d.p. matching the dr87 worksheet's # `UValueFinal` column for this construction. assert abs(result - 1.20) <= 1e-9 def test_u_wall_not_dry_lined_cavity_as_built_age_c_returns_unadjusted_1_5() -> None: # Arrange — same age + construction as the dry-lined case above but # without the dry-lining flag. Cascade must return the bare Table 6 # "Cavity as built" row value (no R = 0.17 added). # Act result = u_wall( country=Country.ENG, age_band="C", construction=WALL_CAVITY, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, dry_lined=False, ) # Assert assert abs(result - 1.50) <= 1e-9 def test_u_wall_dry_lined_with_measured_insulation_thickness_no_adjustment() -> None: # Arrange — once a measured insulation thickness is lodged, Table 6's # insulated buckets already incorporate the dry-lining R via Table 14. # Applying R = 0.17 on top would double-count. Cavity + 100 mm # insulation, age band E → Table 6 cavity-100mm row = 0.32 W/m²K # regardless of the dry-lining flag. # Act result = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, insulation_thickness_mm=100, insulation_present=True, wall_insulation_type=4, dry_lined=True, ) # Assert assert abs(result - 0.32) <= 1e-9 def test_u_wall_cavity_as_built_england_age_band_g_returns_table6_value() -> None: # Arrange — Table 6, England, Cavity as built, age band G -> 0.60 W/m^2K. # Act result = u_wall( country=Country.ENG, age_band="G", construction=WALL_CAVITY, insulation_thickness_mm=0, ) # Assert assert result == pytest.approx(0.60, abs=0.001) def test_u_wall_solid_brick_with_100mm_insulation_age_band_e_returns_table6_value() -> None: # Arrange — Table 6, England, Solid brick with 100mm insulation, age band E -> 0.32 W/m^2K. # Act result = u_wall( country=Country.ENG, age_band="E", construction=WALL_SOLID_BRICK, insulation_thickness_mm=100, ) # Assert assert result == pytest.approx(0.32, abs=0.001) def test_u_wall_scotland_age_band_m_returns_country_specific_table7_value() -> None: # Arrange — Scotland's Table 7 has tighter age-M U-values (0.17 vs England's 0.26). # Act result = u_wall( country=Country.SCT, age_band="M", construction=WALL_CAVITY, insulation_thickness_mm=0, ) # Assert assert result == pytest.approx(0.17, abs=0.001) def test_u_wall_timber_frame_as_built_age_band_a_returns_table6_value() -> None: # Arrange — Timber frame as built, age A, England -> 2.5 W/m^2K. # Act result = u_wall( country=Country.ENG, age_band="A", construction=WALL_TIMBER_FRAME, insulation_thickness_mm=0, ) # Assert assert result == pytest.approx(2.5, abs=0.001) def test_u_wall_falls_back_to_age_band_default_when_construction_unknown() -> None: # Arrange — construction missing; falls back to cavity-typical for age band G. # Act result = u_wall( country=Country.ENG, age_band="G", construction=None, insulation_thickness_mm=0, ) # Assert — cavity-as-built for G is 0.60 (matches RdSAP "assume as-built" rule). assert result == pytest.approx(0.60, abs=0.001) def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> None: # Arrange — no signal at all. # Act result = u_wall( country=None, age_band=None, construction=None, insulation_thickness_mm=None, ) # Assert — mid-range fallback ~1.5 (Cavity-as-built mid-band E typical). assert result == pytest.approx(1.5, abs=0.001) def test_u_wall_description_sandstone_overrides_cavity_default_for_age_e() -> None: # Arrange — construction integer is missing on the cert. _DEFAULT_WALL_BY_AGE # would pick cavity for age E (1.0 W/m^2K uninsulated), but the surveyor's # walls[i].description clearly identifies sandstone -> 1.7 W/m^2K. # Act result = u_wall( country=Country.ENG, age_band="E", construction=None, insulation_thickness_mm=None, description="Sandstone or limestone, as built, no insulation (assumed)", ) # Assert assert result == pytest.approx(1.7, abs=0.001) def test_u_wall_description_granite_or_whinstone_picks_stone_default() -> None: # Arrange — Scotland whinstone (granite-family) walls, age D, construction # null. Should resolve to STONE_GRANITE uninsulated -> 1.7 (age D index 3). # Act result = u_wall( country=Country.ENG, age_band="D", construction=None, insulation_thickness_mm=None, description="Granite or whinstone, as built", ) # Assert assert result == pytest.approx(1.7, abs=0.001) def test_u_wall_description_solid_brick_picks_solid_brick_default() -> None: # Arrange — construction null, description names solid brick. For age E # uninsulated solid brick -> 1.7 W/m^2K (vs cavity 1.0). # Act result = u_wall( country=Country.ENG, age_band="E", construction=None, insulation_thickness_mm=None, description="Solid brick, as built, no insulation (assumed)", ) # Assert assert result == pytest.approx(1.7, abs=0.001) def test_u_wall_explicit_construction_beats_description() -> None: # Arrange — wall_construction integer is cavity (4); ignore any conflicting # description text. Cavity-as-built age E -> 1.5 W/m^2K, NOT stone's 1.7. # Act result = u_wall( country=Country.ENG, age_band="E", construction=WALL_CAVITY, insulation_thickness_mm=None, description="Granite, as built", # ignored ) # Assert assert result == pytest.approx(1.5, abs=0.001) def test_u_wall_description_unmatched_falls_back_to_age_band_default() -> None: # Arrange — construction null, description says nothing recognisable. # Act result = u_wall( country=Country.ENG, age_band="E", construction=None, insulation_thickness_mm=None, description="something unparseable", ) # Assert — cavity default for age E uninsulated -> 1.5 W/m^2K. assert result == pytest.approx(1.5, abs=0.001) def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_unknown() -> None: # Arrange — RdSAP10 footnote: if wall is known insulated but thickness unknown, use 50mm row. # System built with 50mm insulation, England, age band G -> 0.35 W/m^2K. # Act result = u_wall( country=Country.ENG, age_band="G", construction=WALL_SYSTEM_BUILT, insulation_thickness_mm=None, # unknown but insulation present insulation_present=True, ) # Assert assert result == pytest.approx(0.35, abs=0.001) def test_u_wall_curtain_wall_post_2023_routes_to_window_table_24_u_1p4_per_rdsap_5_18() -> None: # Arrange — RdSAP 10 §5.18 (PDF p.48): "Otherwise for the purpose of # RdSAP, U= 2.0 W/m²K for pre-2023 curtain walls, And for post-2023 # (2024 in Scotland) U-values as for windows given in Notes below # Table 24." Table 24 row "Double or triple glazed England/Wales: # 2022 or later" PVC/wood column = 1.4 W/m²K. Cert 000565 BP[2] # Ext2 lodges `Type: CW Curtain Wall` + `Curtain Wall Age: Post 2023` # — worksheet pins U=1.40 for this BP. # # Pre-S0380.85: `WALL_CURTAIN=9` was defined but not in `known_types` # at u_wall:373-376, so the dispatch fell through to # `_DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)` → cavity table at age # H = 0.60. Cascade walls subtotal under-counted by ~112 W/K on # this BP. # Act result = u_wall( country=Country.ENG, age_band="H", construction=WALL_CURTAIN, insulation_thickness_mm=None, curtain_wall_age="Post 2023", ) # Assert assert abs(result - 1.4) <= 1e-9 def test_u_wall_curtain_wall_pre_2023_uses_rdsap_5_18_default_u_2p0() -> None: # Arrange — RdSAP 10 §5.18 (PDF p.48) fallback for curtain walls # built before 2023 (or installed-age unknown): U = 2.0 W/m²K. # Independent of construction age band — §5.18 keys solely on the # curtain-wall-age lodging (Post 2023 vs everything else), not on # the dwelling-wide `construction_age_band`. # Act result = u_wall( country=Country.ENG, age_band="H", construction=WALL_CURTAIN, insulation_thickness_mm=None, curtain_wall_age="Pre 2023", ) # Assert assert abs(result - 2.0) <= 1e-9 def test_u_wall_stone_granite_thin_wall_age_a_120mm_dry_lined_applies_5_6_formula_with_5_8_adjustment() -> None: # Arrange — RdSAP 10 §5.6 (PDF p.40) "U-values of uninsulated stone # walls, age bands A to E": # # Table 12: Default U-values of stone walls # Granite or whinstone: U = 45.315 × W^(-0.513) # Where W is wall thickness in mm. # # Then RdSAP 10 §5.8 (PDF p.40) + Table 14 (PDF p.41) — for # dry-lining (including laths and plaster) apply R = 0.17 m²K/W # additively to U₀: # # U = 1 / (1/U₀ + R_insulation) # # Cert 000565 BP[0] Main alt1 is the cohort fixture: stone granite, # age band A (inherited from Main), wall thickness 120 mm, dry-lined. # §5.6 formula: U₀ = 45.315 × 120^(-0.513) ≈ 3.8871 # §5.8 + Table 14 dry-line: U = 1/(1/3.8871 + 0.17) ≈ 2.3405 # → matches worksheet U985-0001-000565 line (29a) pin U=2.34. # # Pre-S0380.86: the cert lodged its alt-wall thickness via the # misnamed `wall_insulation_thickness="120"` field, which routed # through `_insulation_bucket(120, ins_present=False)` → 100 → # _BRICK_INS_100 (the stone-insulated-100mm row) → 0.32 W/m²K at # age A. Δ contribution −46.5 W/K on the 23 m² alt area. # Act result = u_wall( country=Country.ENG, age_band="A", construction=WALL_STONE_GRANITE, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, dry_lined=True, wall_thickness_mm=120, ) # Assert — worksheet 2.34 (4 d.p. tolerance for round-half-up) assert abs(result - 2.34) <= 1e-2 def test_u_wall_stone_granite_thin_wall_age_a_120mm_no_dry_line_returns_raw_5_6_formula() -> None: # Arrange — same wall + thickness as above but without dry-lining. # §5.6 formula returns U₀ directly (no §5.8 adjustment applied). # U₀ = 45.315 × 120^(-0.513) ≈ 3.8871 # Act result = u_wall( country=Country.ENG, age_band="A", construction=WALL_STONE_GRANITE, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, dry_lined=False, wall_thickness_mm=120, ) # Assert assert abs(result - 3.8871) <= 1e-3 def test_u_wall_stone_sandstone_thin_wall_age_a_120mm_uses_5_6_sandstone_formula() -> None: # Arrange — §5.6 (PDF p.40) Table 12: sandstone/limestone formula # is distinct from granite/whinstone: # Sandstone or limestone: U = 54.876 × W^(-0.561) # At W=120 mm: U₀ ≈ 3.7408. The dispatch must pick the formula # by construction code (WALL_STONE_SANDSTONE vs WALL_STONE_GRANITE). # Act result = u_wall( country=Country.ENG, age_band="A", construction=WALL_STONE_SANDSTONE, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, dry_lined=False, wall_thickness_mm=120, ) # Assert assert abs(result - 3.7408) <= 1e-3 def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_age_a_to_e_gate() -> None: # Arrange — §5.6 (PDF p.40) heading explicitly scopes the formula # to "age bands A to E". For age F onwards Table 6 gives literal # U-values that already encode typical-thickness stone wall heat # loss — applying §5.6 outside the A-E gate would over-estimate U # for modern stone walls. Cert 000565 alt1 happens to be age A, # but this test guards against §5.6 leaking into post-1976 stone # constructions. # # At age G stone granite, Table 6 gives U=0.60 (cohort-typical row). # The §5.6 formula at 120 mm would return 3.89 — wildly over. # Act result = u_wall( country=Country.ENG, age_band="G", construction=WALL_STONE_GRANITE, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, wall_thickness_mm=120, ) # Assert — Table 6 row at age G, NOT §5.6 formula. assert abs(result - 0.60) <= 1e-3 def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a_default() -> None: # Arrange — §5.6 formula only fires when a wall thickness is # lodged. Without documentary wall-thickness evidence, fall back # to the Table 6 row (which represents typical thickness). For # age A stone granite without thickness, the cascade preserves # its existing "as-built typical" U value rather than the formula # extrapolation. # Act result = u_wall( country=Country.ENG, age_band="A", construction=WALL_STONE_GRANITE, insulation_thickness_mm=None, insulation_present=False, wall_insulation_type=4, wall_thickness_mm=None, ) # Assert — _TYPICAL_STONE_UNINSULATED at age A = 1.7 (cohort default). assert abs(result - 1.7) <= 1e-3 def test_u_wall_curtain_wall_missing_age_lodgement_defaults_to_pre_2023_u_2p0_per_rdsap_5_18() -> None: # Arrange — when the cert lodges `Type: CW Curtain Wall` but no # `Curtain Wall Age` line (older Elmhurst Summary PDFs, or API EPCs # without the per-BP curtain_wall_age field), apply the §5.18 # default. The §5.18 sentence "U= 2.0 W/m²K for pre-2023 curtain # walls" applies as the unknown-age fallback — matches the spec's # "assume as-built" convention elsewhere in the cascade. # Act result = u_wall( country=Country.ENG, age_band="H", construction=WALL_CURTAIN, insulation_thickness_mm=None, curtain_wall_age=None, ) # Assert assert abs(result - 2.0) <= 1e-9 # ----- Roofs ----- def test_u_roof_description_with_measured_transmittance_returns_parsed_value() -> None: # Arrange — ~1 140 corpus certs lodge a full-SAP measured roof # U-value in the description, e.g. "Average thermal transmittance # 0.11 W/m²K". The age-band cascade is bypassed: the assessor's # measured/calculated value is used directly. Same contract as # `u_wall` (S-B24) and `u_floor` (S-B29 cycle 1). # Act result = u_roof( country=Country.ENG, age_band="C", insulation_thickness_mm=None, description="Average thermal transmittance 0.11 W/m²K", ) # Assert assert result == pytest.approx(0.11, abs=0.001) def test_u_roof_ni_thickness_with_insulated_description_applies_50mm_per_section_5_11_4() -> None: # Arrange — 346 corpus certs lodge roof_insulation_thickness="NI" # (Not Indicated, parsed to 0 by _parse_thickness_mm). When the # description also signals retrofit insulation ("Pitched, insulated # (assumed)" / "Flat, insulated" / "Roof room(s), insulated # (assumed)"), RdSAP 10 §5.11.4 (page 44) footnote applies: # "If retrofit insulation present of unknown thickness use 50 mm". # That maps to Table 16 row "50 mm at joists at ceiling level" = 0.68 # W/m²K — vs the current 2.30 we return when thickness=0 hits the # Table 16 row-0 lookup. # Act result = u_roof( country=Country.ENG, age_band="C", insulation_thickness_mm=0, # parsed from "NI" description="Pitched, insulated (assumed)", ) # Assert assert result == pytest.approx(0.68, abs=0.01) def test_u_roof_ni_thickness_with_no_insulation_description_stays_at_2_30() -> None: # Arrange — 706 corpus certs lodge "Pitched, no insulation # (assumed)" which can co-occur with thickness="NI". The # description-based override for retrofit-insulated roofs must # respect the "no insulation" negation: `_described_as_insulated` # returns False on "no insulation" substring, so the Table 16 # row-0 lookup applies and U = 2.30 W/m²K stays. # Act result = u_roof( country=Country.ENG, age_band="C", insulation_thickness_mm=0, description="Pitched, no insulation (assumed)", ) # Assert assert result == pytest.approx(2.30, abs=0.01) def test_u_roof_age_band_j_pitched_returns_table18_value() -> None: # Arrange — Table 18, pitched insulation between joists, age J -> 0.16 W/m^2K. # Act result = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None) # Assert assert result == pytest.approx(0.16, abs=0.001) def test_u_roof_with_explicit_insulation_thickness_uses_table16() -> None: # Arrange — Table 16 joist insulation 200mm -> 0.21 W/m^2K. # Act result = u_roof(country=Country.ENG, age_band="G", insulation_thickness_mm=200) # Assert assert result == pytest.approx(0.21, abs=0.001) def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None: # Arrange — nothing known. # Act result = u_roof(country=None, age_band=None, insulation_thickness_mm=None) # Assert — mid-range default ~0.4 (Table 18 age G typical). assert result == pytest.approx(0.4, abs=0.001) def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None: # Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof": # age band D, thickness unknown → U = 2.30 W/m²K. Column (1) # (pitched-between-joists default) returns 0.40 for the same age # band; routing must pick column (3) when the per-bp roof # construction lodges as flat. # Act result = u_roof( country=Country.ENG, age_band="D", insulation_thickness_mm=None, is_flat_roof=True, ) # Assert assert abs(result - 2.30) <= 1e-4 def test_u_roof_flat_age_band_g_returns_table18_col3_value() -> None: # Arrange — Table 18 column (3) flat-roof default is 0.40 for age G, # the cross-over point where the flat-roof and pitched-roof columns # agree. Confirms the dict is populated across the full age range. # Act result = u_roof( country=Country.ENG, age_band="G", insulation_thickness_mm=None, is_flat_roof=True, ) # Assert assert abs(result - 0.40) <= 1e-4 def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None: # Arrange — Table 18 column (3) flat-roof default is 0.18 for age L, # the modern band where both columns agree. # Act result = u_roof( country=Country.ENG, age_band="L", insulation_thickness_mm=None, is_flat_roof=True, ) # Assert assert abs(result - 0.18) <= 1e-4 def test_u_roof_description_no_insulation_overrides_age_band_default() -> None: # Arrange — surveyor description on a Victorian roof says uninsulated; # Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm # joist insulation is 2.30 W/m^2K. # Act result = u_roof( country=Country.ENG, age_band="B", insulation_thickness_mm=None, description="Pitched, no insulation (assumed)", ) # Assert assert result == pytest.approx(2.30, abs=0.001) def test_u_roof_description_limited_insulation_overrides_age_band_default() -> None: # Arrange — "limited insulation" maps to Table 16 row 12mm -> 1.50 W/m^2K. # Act result = u_roof( country=Country.ENG, age_band="D", insulation_thickness_mm=None, description="Roof room(s), limited insulation", ) # Assert assert result == pytest.approx(1.50, abs=0.001) def test_u_roof_description_uninsulated_synonym_also_triggers_high_u() -> None: # Arrange — surveyor writes "uninsulated" (no space) instead of "no insulation". # Act result = u_roof( country=Country.ENG, age_band="C", insulation_thickness_mm=None, description="Flat, uninsulated", ) # Assert assert result == pytest.approx(2.30, abs=0.001) def test_u_roof_description_well_insulated_does_not_override_default() -> None: # Arrange — description says "insulated"; do NOT override the Table 18 # age-G default of 0.40 with a penalty. # Act result = u_roof( country=Country.ENG, age_band="G", insulation_thickness_mm=None, description="Pitched, insulated at rafters", ) # Assert assert result == pytest.approx(0.40, abs=0.001) def test_u_roof_explicit_thickness_beats_description() -> None: # Arrange — when surveyor measured 200mm joist insulation, Table 16 wins # regardless of any description text. 200mm -> 0.21 W/m^2K. # Act result = u_roof( country=Country.ENG, age_band="B", insulation_thickness_mm=200, description="No insulation", # ignored because thickness is explicit ) # Assert assert result == pytest.approx(0.21, abs=0.001) # ----- Floors ----- def test_u_floor_description_with_measured_transmittance_returns_parsed_value() -> None: # Arrange — ~1 391 corpus certs lodge a full-SAP measured floor # U-value in the description, e.g. "Average thermal transmittance # 0.18 W/m²K". The BS EN ISO 13370 calculation is bypassed: the # assessor's measured/calculated value is used directly. Same # contract as `u_wall` (S-B24). # Act result = u_floor( country=Country.ENG, age_band="B", construction=None, insulation_thickness_mm=None, area_m2=100.0, perimeter_m=40.0, wall_thickness_mm=300, description="Average thermal transmittance 0.18 W/m²K", ) # Assert assert result == pytest.approx(0.18, abs=0.001) def test_u_floor_ni_thickness_with_insulated_description_applies_50mm_per_table19_footnote() -> None: # Arrange — 2 413 corpus certs (~12%) lodge floors with # floor_insulation_thickness="NI" (Not Indicated, which our # _parse_thickness_mm returns as 0) AND a description "Solid, # insulated (assumed)" or "Suspended, insulated (assumed)". The # assessor sees insulation but hasn't measured the thickness. # RdSAP 10 §5.12 Table 19 footnote (2): # "For floors which have retrofitted insulation, use the greater # of 50 mm and the thickness according to the age band." # Band B's age-band default is 0 mm, so max(50, 0) = 50 mm applies. # Geometry: 100 m² × 40 m perimeter, w=0.3, gives B=5, d_t=2.758 # (with R_f from 50 mm/0.035 = 1.429); U = 2 × 1.5 × ln(π×5/2.758 + 1) # / (π×5 + 2.758) ≈ 0.31 W/m²K. # Act result = u_floor( country=Country.ENG, age_band="B", construction=None, insulation_thickness_mm=0, # parsed from "NI" area_m2=100.0, perimeter_m=40.0, wall_thickness_mm=300, description="Solid, insulated (assumed)", ) # Assert assert result == pytest.approx(0.31, abs=0.02) def test_u_floor_ni_thickness_with_no_insulation_description_stays_uninsulated() -> None: # Arrange — 8 221 corpus certs lodge "Solid, no insulation # (assumed)" with thickness="NI". The Table 19 footnote (2) override # must not fire on these: the "no insulation" substring takes # precedence over the "insulated" substring per # `_described_as_insulated`. Same geometry as the cycle-1 test; # uninsulated U should be ~0.60 W/m²K (B=5, d_t=0.615 with R_f=0). # Act result = u_floor( country=Country.ENG, age_band="B", construction=None, insulation_thickness_mm=0, # parsed from "NI" area_m2=100.0, perimeter_m=40.0, wall_thickness_mm=300, description="Solid, no insulation (assumed)", ) # Assert assert result == pytest.approx(0.60, abs=0.02) def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() -> None: # Arrange — solid floor, age C, England. # BS EN ISO 13370 with A=80, P=36, w=0.22m, soil g=1.5, Rsi=0.17, Rse=0.04, Rf=0 # d_t = 0.22 + 1.5 * (0.17 + 0 + 0.04) = 0.535 # B = 2 * 80 / 36 = 4.444 # d_t < B so U = 2 * 1.5 * ln(pi*B/d_t + 1) / (pi*B + d_t) # = 3 * ln(pi*4.444/0.535 + 1) / (pi*4.444 + 0.535) # = 3 * ln(27.10) / (14.49) # = 3 * 3.300 / 14.49 = 0.683 -> rounds to 0.68 # Act result = u_floor( country=Country.ENG, age_band="C", construction=None, insulation_thickness_mm=None, area_m2=80.0, perimeter_m=36.0, wall_thickness_mm=220, ) # Assert assert result == pytest.approx(0.68, abs=0.05) def test_u_floor_age_b_unknown_construction_uses_suspended_timber_per_table_19_footnote_1() -> None: # Arrange — RdSAP10 §5.12 Table 19 footnote (1) routes age A, B with # unknown floor_construction to the suspended-timber branch. Geometry # is taken from Elmhurst worksheet U985-0001-000490 Main Dwelling # (A=14.85, P=7.42, w=0.400) — the worksheet records U=0.71 W/m²K, # confirming the suspended-floor formula on §5.12 (page 46) is the # one Elmhurst applies for this fixture. # Act result = u_floor( country=Country.ENG, age_band="B", construction=None, insulation_thickness_mm=None, area_m2=14.85, perimeter_m=7.42, wall_thickness_mm=400, ) # Assert assert result == pytest.approx(0.71, abs=0.01) def test_u_floor_with_insulation_lowers_u_value() -> None: # Arrange — same geometry but with 100mm insulation -> R_f = 0.1/0.035 = 2.857. # Act insulated = u_floor( country=Country.ENG, age_band="K", construction=None, insulation_thickness_mm=100, area_m2=80.0, perimeter_m=36.0, wall_thickness_mm=220, ) # Assert — well below uninsulated case (~0.27 W/m^2K). assert insulated < 0.3 def test_u_exposed_floor_age_b_unknown_insulation_uses_table_20_row_a_to_g() -> None: # Arrange — RdSAP10 §5.13 Table 20 (page 47) gives U-values for # exposed and semi-exposed upper floors keyed on age band + # insulation thickness. The "Insulation unknown or as built" # column at age band A-G = 1.20 W/m²K. Elmhurst worksheet # U985-0001-000490 Extension 1 records U=1.20 for its exposed # timber floor (1900-1929, no insulation lodged) — this lookup # reproduces that exact value without any geometry input. # Act result = u_exposed_floor(age_band="B", insulation_thickness_mm=None) # Assert assert result == pytest.approx(1.20, abs=0.001) def test_u_floor_above_partially_heated_space_returns_0p7_per_rdsap_10_section_5_14() -> None: # Arrange — RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a # partially heated space": # "The U-value of a floor above partially heated premises is # taken as 0.7 W/m²K. This applies typically for a flat above # non-domestic premises that are not heated to the same extent # or duration as the flat." # Verbatim constant — no age-band or insulation-thickness inputs. # Cert 000565 Ext1 (Summary §9: "P Above partially heated space", # Default U-value 0.70) exercises this branch. # Act result = u_floor_above_partially_heated_space() # Assert assert abs(result - 0.7) <= 1e-4 def test_u_floor_falls_back_to_mid_range_when_geometry_unknown() -> None: # Arrange — geometry missing. # Act result = u_floor( country=None, age_band=None, construction=None, insulation_thickness_mm=None, area_m2=None, perimeter_m=None, wall_thickness_mm=None, ) # Assert — mid-range fallback ~0.7 W/m^2K (solid-uninsulated mid-band typical). assert result == pytest.approx(0.7, abs=0.05) # ----- Windows ----- def test_u_window_single_glazed_pvc_returns_table24_value() -> None: # Arrange — Table 24: single glazing, any period, PVC/wooden frame -> 4.8 W/m^2K. # Act result = u_window(installed_year=None, glazing_type="single", frame_type="pvc") # Assert assert result == pytest.approx(4.8, abs=0.001) def test_u_window_post_2022_pvc_returns_low_table24_value() -> None: # Arrange — Table 24: double or triple glazed, 2022 or later, PVC -> 1.4 W/m^2K. # Act result = u_window(installed_year=2023, glazing_type="double", frame_type="pvc") # Assert assert result == pytest.approx(1.4, abs=0.001) def test_u_window_falls_back_to_mid_range_when_unknown() -> None: # Arrange — nothing known. # Act result = u_window(installed_year=None, glazing_type=None, frame_type=None) # Assert — mid-range default ~2.5 (pre-2002 double glazed PVC typical). assert result == pytest.approx(2.5, abs=0.5) # ----- Doors ----- def test_u_door_age_band_a_uninsulated_returns_table26_value() -> None: # Arrange — Table 26: age A-J unisulated -> 3.0 W/m^2K. # Act result = u_door(country=Country.ENG, age_band="A", insulated=False, insulated_u_value=None) # Assert assert result == pytest.approx(3.0, abs=0.001) def test_u_door_age_band_m_uninsulated_returns_lower_table26_value() -> None: # Arrange — Table 26: age M -> 1.4 W/m^2K. # Act result = u_door(country=Country.ENG, age_band="M", insulated=False, insulated_u_value=None) # Assert assert result == pytest.approx(1.4, abs=0.001) def test_u_door_insulated_uses_explicit_u_value_when_supplied() -> None: # Arrange — door declared insulated with U-value 1.0 from cert. # Act result = u_door(country=Country.ENG, age_band="C", insulated=True, insulated_u_value=1.0) # Assert assert result == pytest.approx(1.0, abs=0.001) # ----- Party walls ----- def test_u_party_wall_solid_masonry_returns_zero() -> None: # Arrange — Table 15: solid masonry / timber frame / system built -> 0.0 W/m^2K. # Act result = u_party_wall(party_wall_construction=WALL_SOLID_BRICK) # Assert assert result == pytest.approx(0.0, abs=0.001) def test_u_party_wall_unfilled_cavity_returns_table15_value() -> None: # Arrange — Table 15: cavity masonry unfilled -> 0.5 W/m^2K. # Act result = u_party_wall(party_wall_construction=WALL_CAVITY) # Assert assert result == pytest.approx(0.5, abs=0.001) def test_u_party_wall_cavity_masonry_filled_returns_0p2_per_rdsap_10_table_15_row_3() -> None: # Arrange — RdSAP 10 §5.10 Table 15 row 3 (PDF p.42) "Cavity masonry # filled -> 0.2 W/m²K". Before slice S0380.91 the `u_party_wall` # cascade only resolved 0.0 / 0.5 / 0.25 for code 4 so Elmhurst # "CF" lodgements rounded up to the conservative cavity-unfilled # U=0.5 — over-counting party-wall heat loss by (0.5 - 0.2) × area. # New synthetic code `WALL_CAVITY_FILLED_PARTY = 11` distinguishes # filled cavity from the construction-class-shared code 4. # Act result = u_party_wall(party_wall_construction=WALL_CAVITY_FILLED_PARTY) # Assert assert abs(result - 0.2) <= 1e-4 def test_u_party_wall_unknown_returns_table15_house_default() -> None: # Arrange — Table 15: unable to determine, house -> 0.25 W/m^2K. # Act result = u_party_wall(party_wall_construction=None) # Assert assert result == pytest.approx(0.25, abs=0.001) def test_u_party_wall_unknown_for_flat_returns_table15_footnote_zero() -> None: # Arrange — RdSAP 10 Table 15 footnote *: "for flats and maisonettes # with unknown party-wall construction, U = 0.0" (both sides of the # party wall are heated dwellings, so no heat loss). # Act result = u_party_wall(party_wall_construction=None, is_flat=True) # Assert assert abs(result - 0.0) <= 0.001 def test_u_party_wall_unknown_sentinel_zero_treated_as_unknown_for_flat() -> None: # Arrange — the Elmhurst mapper lodges `0` as the explicit "unknown" # sentinel (per `datatypes/epc/domain/mapper.py:_ELMHURST_PARTY_WALL_ # CODE_TO_SAP10` cross-mapper-parity comment) where the API mapper # would have lodged `None`. The cascade must treat both equivalently # so a flat cert from either source surfaces Table 15 footnote *. # Act result = u_party_wall(party_wall_construction=0, is_flat=True) # Assert assert abs(result - 0.0) <= 0.001 def test_u_party_wall_known_solid_still_returns_zero_when_is_flat_false() -> None: # Arrange — `is_flat` is a fallback for the unknown case only; an # explicit construction code always takes precedence (Solid → 0.0 # regardless of property type, matching Table 15 row 1). # Act result = u_party_wall(party_wall_construction=3, is_flat=False) # Assert assert abs(result - 0.0) <= 0.001 # ----- Thermal bridging ----- def test_thermal_bridging_y_age_band_g_returns_table21_value() -> None: # Arrange — Table 21: ages A-I -> 0.15. # Act result = thermal_bridging_y(age_band="G") # Assert assert result == pytest.approx(0.15, abs=0.001) def test_thermal_bridging_y_age_band_j_returns_table21_value() -> None: # Arrange — Table 21: age J -> 0.11. # Act result = thermal_bridging_y(age_band="J") # Assert assert result == pytest.approx(0.11, abs=0.001) def test_thermal_bridging_y_age_band_l_returns_table21_value() -> None: # Arrange — Table 21: ages K, L, M -> 0.08. # Act result = thermal_bridging_y(age_band="L") # Assert assert result == pytest.approx(0.08, abs=0.001) def test_thermal_bridging_y_unknown_age_band_returns_mid_range() -> None: # Arrange — age unknown. # Act result = thermal_bridging_y(age_band=None) # Assert — mid-range fallback ~0.15 (the most common value across age bands). assert result == pytest.approx(0.15, abs=0.001) def test_country_unknown_string_falls_back_to_england() -> None: # Arrange — Country.from_code('XX') -> Country.ENG. # Act result = Country.from_code("XX") # Assert assert result is Country.ENG def test_country_from_code_recognises_known_codes() -> None: # Arrange / Act / Assert assert Country.from_code("ENG") is Country.ENG assert Country.from_code("WAL") is Country.WAL assert Country.from_code("SCT") is Country.SCT assert Country.from_code("NIR") is Country.NIR assert Country.from_code("EAW") is Country.ENG # England-and-Wales aggregate maps to ENG def test_u_rr_default_all_elements_age_band_b_returns_table18_col4_value() -> None: """RdSAP10 §5.11.4 + Table 18 column (4) — "Room-in-roof, all elements" as-built / unknown default. Age band B (1900-1929) → 2.30 W/m²K (the uninsulated row carries footnote (1): "value from the table applies for unknown and as built").""" # Arrange / Act result = u_rr_default_all_elements(country=Country.ENG, age_band="B") # Assert assert result == pytest.approx(2.30, abs=0.001) def test_u_rr_default_all_elements_table18_col4_matches_spec_across_age_bands() -> None: """Table 18 column (4) per RdSAP10 spec page 45: A-D 2.30, E 1.50, F 0.80, G 0.50, H 0.35, I 0.35, J 0.30, K 0.25, L 0.18, M 0.15. """ # Arrange — expected RR-all-elements U-values for England. expected = { "A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50, "F": 0.80, "G": 0.50, "H": 0.35, "I": 0.35, "J": 0.30, "K": 0.25, "L": 0.18, "M": 0.15, } # Act / Assert for age_band, want in expected.items(): got = u_rr_default_all_elements(country=Country.ENG, age_band=age_band) assert got == pytest.approx(want, abs=0.001), ( f"age={age_band}: got {got}, want {want}" ) def test_u_rr_default_all_elements_scotland_age_band_k_returns_0_20_per_footnote() -> None: """Table 18 footnote (2): "0.20 W/m²K in Scotland" applies to the age band K row of column (4). Other age bands unchanged.""" # Arrange / Act result = u_rr_default_all_elements(country=Country.SCT, age_band="K") # Assert assert result == pytest.approx(0.20, abs=0.001) def test_u_rr_default_all_elements_unknown_age_band_falls_back_to_mid_range() -> None: """Robustness: no age band → return the mid-range default rather than raising. Picks the column (4) value at age G (0.50) as a sensible middle estimate, matching the cascade convention used by `u_roof`.""" # Arrange / Act result = u_rr_default_all_elements(country=None, age_band=None) # Assert assert result == pytest.approx(0.50, abs=0.001) # ----- Room-in-roof Table 17 lookups (insulation thickness known) ----- def test_u_rr_slope_table17_col1a_mineral_wool_100mm_returns_0_40() -> None: """RdSAP10 §5.11.3 + Table 17 column (1a): "Insulated slope - sloping ceiling, mineral wool or EPS slab" 100 mm row → 0.40 W/m²K.""" # Arrange / Act result = u_rr_slope( country=Country.ENG, age_band="B", insulation_thickness_mm=100, insulation_type="mineral_wool", ) # Assert assert result == pytest.approx(0.40, abs=0.001) def test_u_rr_slope_table17_col1b_pur_pir_100mm_returns_0_30() -> None: """Table 17 column (1b): "Insulated slope - sloping ceiling, PUR or PIR optional" 100 mm row → 0.30 W/m²K. The PUR/PIR rigid foam route gives a tighter U than mineral wool at the same thickness.""" # Arrange / Act result = u_rr_slope( country=Country.ENG, age_band="B", insulation_thickness_mm=100, insulation_type="pir", ) # Assert assert result == pytest.approx(0.30, abs=0.001) def test_u_rr_flat_ceiling_table17_col2a_mineral_wool_100mm_returns_0_54() -> None: """Table 17 column (2a): "Insulated slope - flat ceiling, mineral wool or EPS slab" 100 mm row → 0.54 W/m²K.""" # Arrange / Act result = u_rr_flat_ceiling( country=Country.ENG, age_band="B", insulation_thickness_mm=100, insulation_type="mineral_wool", ) # Assert assert result == pytest.approx(0.54, abs=0.001) def test_u_rr_stud_wall_table17_col3a_mineral_wool_100mm_returns_0_36() -> None: """Table 17 column (3a): "Stud wall u-value For Room in Roof, mineral wool or EPS slab" 100 mm row → 0.36 W/m²K. (Used by the U985 worksheet for 000477's RR stud walls.)""" # Arrange / Act result = u_rr_stud_wall( country=Country.ENG, age_band="B", insulation_thickness_mm=100, insulation_type="mineral_wool", ) # Assert assert result == pytest.approx(0.36, abs=0.001) def test_u_rr_stud_wall_rigid_foam_400mm_returns_0p10_per_table_17_col_3b() -> None: # Arrange — Table 17 column (3b) "Stud wall, PUR or PIR optional", # 400 mm row → 0.10 W/m²K. Cert 000565 BP[2] Ext2 Summary §8.1 # lodges "Stud Wall 2: 400+ mm PUR or PIR" → Default U=0.10. The # "rigid_foam" SAP10 insulation-type code is the canonical alias for # both the Elmhurst "PUR or PIR" string and the API "PUR" / "PIR" # individual codes; the cascade's `_is_rigid_foam` recognises all # three to route through column (b) of Table 17. # Act result = u_rr_stud_wall( country=Country.ENG, age_band="J", insulation_thickness_mm=400, insulation_type="rigid_foam", ) # Assert assert abs(result - 0.10) <= 1e-4 def test_u_rr_slope_table17_none_row_uninsulated_returns_2_30() -> None: """Table 17 "none" row (every column collapses to 2.3 when no insulation). Used by the U985 worksheet for 000477's RR slope panels that lodge as uninsulated.""" # Arrange / Act result = u_rr_slope( country=Country.ENG, age_band="B", insulation_thickness_mm=0, insulation_type="mineral_wool", ) # Assert assert result == pytest.approx(2.30, abs=0.001) def test_u_rr_flat_ceiling_table17_col2b_pir_over_400mm_returns_0_09() -> None: """Table 17 row ">400 mm" column (2b) PUR/PIR → 0.09 W/m²K. The U985 worksheet for 000477 lodges 0.14 for "External roof Main" which is Table 17 col (2a) row >400 (mineral wool) — but this test uses the PIR column for completeness.""" # Arrange / Act result = u_rr_flat_ceiling( country=Country.ENG, age_band="B", insulation_thickness_mm=450, insulation_type="pir", ) # Assert assert result == pytest.approx(0.09, abs=0.001) def test_u_rr_slope_unknown_thickness_falls_back_to_table18_all_elements() -> None: """When `insulation_thickness_mm is None`, Table 17 doesn't apply and we cascade to Table 18 col (4) "Room-in-roof, all elements" by age band — same fallback as the spec text at §5.11.3 / §5.11.4. For age band B, that's the 2.30 W/m²K uninsulated default.""" # Arrange / Act result = u_rr_slope( country=Country.ENG, age_band="B", insulation_thickness_mm=None, insulation_type="mineral_wool", ) # Assert assert result == pytest.approx(2.30, abs=0.001) def test_u_rr_stud_wall_thickness_125mm_takes_nearest_tabulated_row_below() -> None: """Table 17 row alignment: an arbitrary thickness picks the nearest tabulated row ≤ supplied (the same convention `u_roof` uses against Table 16). 125 mm matches the exact row → col (3a) = 0.31 W/m²K.""" # Arrange / Act result = u_rr_stud_wall( country=Country.ENG, age_band="B", insulation_thickness_mm=125, insulation_type="mineral_wool", ) # Assert assert result == pytest.approx(0.31, abs=0.001) # ----- Description-cascade cohort pins (Walls) ----- # # The Elmhurst worksheet fixtures lodge `walls=[]` and so cannot exercise # the description-driven branches of `u_wall`. The 8 golden API certs DO # carry `walls[0].description` strings that route through `_described_as_ # insulated`, `_wall_type_from_description`, and the Table 6 footnote # 50 mm-bucket override. These tests pin every (description, age) pair # seen in that cohort against the RdSAP10 Table 6 (England) value the # spec mandates — closing the cascade-coverage gap identified during the # 2026-05-24 audit (description cascade was 100% spec-correct on clean # Table 6 rows; this test locks that in for regression). # # Cases routing through §5.7 (solid brick from wall thickness) or §5.8 # (stone/brick with insulation, ages A–D — formula not table) are # intentionally excluded — they need separate pinning when those # formulas land. _TABLE_6_ENG_WALL_COHORT_PINS: tuple[tuple[str, str, Optional[int], Optional[int], Optional[int], bool, float], ...] = ( # (description, age_band, wall_construction, wall_insulation_type, # insulation_thickness_mm, insulation_present, expected_u_w_per_m2k) # `insulation_present` mirrors the heat_transmission cascade: type != 4 (NONE) # OR description asserts insulation per _described_as_insulated. ("Sandstone, as built, insulated (assumed)", "J", 2, 4, 0, True, 0.25), # cert 0240 (50 mm bucket per footnote) ("Cavity wall, filled cavity", "C", 4, 2, 0, True, 0.7), # cert 8135 bp0 ("Cavity wall, filled cavity", "D", 4, 2, 0, True, 0.7), # cert 0300, 7536 bp0 ("Cavity wall, filled cavity", "F", 4, 2, 0, True, 0.40), # cert 7536 bp2 ("Cavity wall, filled cavity", "G", 4, 2, 0, True, 0.35), # cert 8135 bp1 ("Cavity wall, filled cavity", "L", 4, 4, 0, False, 0.28), # cert 7536 bp1 (assumed-as-built †) ("Cavity wall, as built, no insulation (assumed)", "D", 4, 4, 0, False, 1.5), # cert 0390-2954, 9390 ) @pytest.mark.parametrize( "description, age_band, construction, insulation_type, thickness_mm, insulation_present, expected_u", _TABLE_6_ENG_WALL_COHORT_PINS, ) def test_u_wall_matches_table6_for_every_cohort_description_age_pair( description: str, age_band: str, construction: Optional[int], insulation_type: Optional[int], thickness_mm: Optional[int], insulation_present: bool, expected_u: float, ) -> None: # Arrange — inputs replicate what `heat_transmission_from_cert` feeds # `u_wall` for the corresponding building part in the golden cert cohort. # Act u = u_wall( country=Country.ENG, age_band=age_band, construction=construction, insulation_thickness_mm=thickness_mm, insulation_present=insulation_present, description=description, wall_insulation_type=insulation_type, ) # Assert assert abs(u - expected_u) < 1e-4 # ----- Description-cascade cohort pins (Roofs) ----- # # Mirror of the wall cohort pins above. The Elmhurst worksheet fixtures # lodge `roofs=[]`, so the cascade-pin tests do not exercise u_roof's # description path either. The 8 golden API certs lodge `roofs[]. # description` strings — these tests pin each (description, age, # thickness) tuple seen in that cohort against the RdSAP10 Table 16 # (loft-insulation-thickness-known) value the spec mandates. # # Excluded: ambiguous joined-description cases where one bp lodges no # thickness and another lodges a value — the calc routes through Table # 18 defaults whose interaction with the description cascade needs # separate pinning. "(another dwelling above)" is also excluded — its # u_roof value is ignored by heat_transmission once roof_area is zeroed. _TABLE_16_ENG_ROOF_COHORT_PINS: tuple[tuple[str, str, int, float], ...] = ( # (joined_description, age_band, thickness_mm, expected_u_w_per_m2k) # Table 16 col 1 — thickness-known path, U independent of age band. ("Pitched, 100 mm loft insulation", "D", 100, 0.40), # cert 7536 bp0 ("Pitched, 100 mm loft insulation | Pitched, insulated (assumed)", "D", 100, 0.40), # cert 7536 bp0 (joined) ("Pitched, 270 mm loft insulation", "D", 270, 0.16), # cert 0300 bp0 ("Pitched, 300 mm loft insulation | Flat, no insulation", "D", 300, 0.14), # cert 0390-2954 bp0 ("Pitched, 300 mm loft insulation | Roof room(s), limited insulation (assumed)", "A", 300, 0.14), # cert 6035 bp0 ("Pitched, 300 mm loft insulation | Flat, insulated", "C", 300, 0.14), # cert 8135 bp0 ("Pitched, 300 mm loft insulation | Pitched, 100 mm loft insulation", "B", 300, 0.14), # cert 2130 bp0 ("Pitched, 400+ mm loft insulation | Pitched, insulated (assumed)", "J", 400, 0.11), # cert 0240 bp0 ) @pytest.mark.parametrize( "description, age_band, thickness_mm, expected_u", _TABLE_16_ENG_ROOF_COHORT_PINS, ) def test_u_roof_matches_table16_for_every_cohort_description_thickness_pair( description: str, age_band: str, thickness_mm: int, expected_u: float, ) -> None: # Arrange — inputs replicate what `heat_transmission_from_cert` feeds # `u_roof` for the main building part in the golden cert cohort. # Act u = u_roof( country=Country.ENG, age_band=age_band, insulation_thickness_mm=thickness_mm, description=description, ) # Assert assert abs(u - expected_u) < 1e-4 # ----- §5.12 formula cascade cohort pins (Floors) ----- # # u_floor is formula-driven (BS EN ISO 13370 + RdSAP10 §5.12) rather # than table-lookup, so each pin asserts a per-geometry value derived # by hand from the spec formula. Two cases from cert 0240 (main + # extension) cover the dt < B and dt > B branches of the solid-floor # branch; suspended-floor + Table 19 footnote (2) overrides land in # follow-on slices when cohort coverage demands them. # # Hand-derivation for the first row (cert 0240 bp0): # age J → Table 19 default insulation = 75 mm # w = 0.3 m (default), g = 1.5, Rsi+Rse = 0.21, Rf = 0.001×75/0.035 = 2.143 # dt = 0.3 + 1.5×(0.21 + 2.143) = 3.829 # B = 2×97.72/36.45 = 5.362 → dt < B branch # U = 2g·ln(πB/dt + 1)/(πB + dt) = 0.2447 → rounds to 0.24 _FLOOR_FORMULA_COHORT_PINS: tuple[tuple[str, str, Optional[int], float, float, Optional[int], float], ...] = ( # (description, age, construction, area_m2, perimeter_m, wall_thickness_mm, expected_u) ("Solid, insulated (assumed)", "J", 1, 97.72, 36.45, None, 0.24), # cert 0240 bp0 (dt < B) ("Solid, insulated (assumed)", "J", 1, 20.61, 13.45, None, 0.29), # cert 0240 bp1 (dt > B) ) @pytest.mark.parametrize( "description, age_band, construction, area_m2, perimeter_m, wall_thickness_mm, expected_u", _FLOOR_FORMULA_COHORT_PINS, ) def test_u_floor_matches_section_5_12_formula_for_cohort_geometry( description: str, age_band: str, construction: Optional[int], area_m2: float, perimeter_m: float, wall_thickness_mm: Optional[int], expected_u: float, ) -> None: # Arrange — inputs replicate what `heat_transmission_from_cert` feeds # `u_floor` for the corresponding building part in the cohort. # Act u = u_floor( country=Country.ENG, age_band=age_band, construction=construction, insulation_thickness_mm=None, area_m2=area_m2, perimeter_m=perimeter_m, wall_thickness_mm=wall_thickness_mm, description=description, ) # Assert assert abs(u - expected_u) < 1e-4