"""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