diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 26e4f611..fae4a9ff 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -567,30 +567,43 @@ def u_wall( ctry = country if country is not None else Country.ENG age_idx = _age_index(age_band) band = _AGE_BANDS[age_idx] - # RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall formula, age - # bands A-E. Fires only when (a) a documentary wall thickness is lodged - # (per §5.3 documentary-evidence rule) AND (b) the insulation STATE is - # known (`wall_insulation_type` not None — As Built / external / internal). - # When either is absent the cascade falls through to the Table-6 - # typical-thickness default (1.7) below: an "insulation Unknown" lodgement - # is NOT treated as bare stone of the lodged thickness (cert 000565 Ext1: - # granite 50 mm + insulation Unknown → Table-6 1.70 in the worksheet, NOT - # the §5.6 formula's 6.09), and the footnote (a) "use §5.6 if the - # calculated U-value is less than 1.7" clause governs the unknown path. When the thickness IS - # lodged the raw §5.6 U is the spec target — it is NOT capped at 1.7, - # because a thin/standard solid stone wall genuinely loses more than the - # typical-thickness default (sandstone 400 mm → 1.90, granite 120 mm → - # 3.89). §5.8 + Table 14 insulation / dry-line adjustments apply on top - # of the raw §5.6 U₀ (cert 000565 Main alt_wall_1: granite W=120 mm - # dry-lined → U₀=3.88 + dry-line → 2.34, matches worksheet). + # RdSAP 10 Tables 6-10 stone rows + footnote (a) (PDF p.33-39), §5.6 + # formula (PDF p.40), §5.8 + Table 14 (PDF p.41-42). A documentary wall + # thickness (per §5.3) routes stone in age bands A-E off the §5.6 formula, + # NOT the flat Table-6 typical-thickness default: + # - Bands A-D: pure §5.6 formula, UNCAPPED. The stone rows read + # "According to 5.6" with NO 1.7 entry, because a thin/standard solid + # stone wall genuinely loses more than the typical default (sandstone + # 400 mm → 1.90, granite 120 mm → 3.89). + # - Band E: the stone row reads "1.7 a"; footnote (a) = "Or from + # equations in 5.6 if the calculated U-value is less than 1.7" → + # U_E = min(formula, 1.7). Scotland sandstone/limestone age E defaults + # to 1.5 (Table 7), granite/whinstone stays 1.7. The 1.7 (1.5) cap + # belongs ONLY at age E, never A-D. + # The insulation STATE is NOT a gate: an "as built / insulation Unknown" + # lodgement (`wall_insulation_type` None or 4) takes the formula too. Cert + # 000565 Ext1 (granite 50 mm, age E, insulation Unknown) → min(6.09, 1.7) + # = 1.70, matching the U985 worksheet WITHOUT a flat-table detour — the + # age-E cap, not an insulation gate, is what produces the 1.70. if ( wall_thickness_mm is not None - and wall_insulation_type is not None and band in _STONE_AGE_A_TO_E and construction in (WALL_STONE_GRANITE, WALL_STONE_SANDSTONE) ): u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm) if u0 is not None: + # Footnote (a) cap is age-E only: clamp the as-built U to the + # Table-6/7 age-E default (1.7, or 1.5 for Scotland sandstone/ + # limestone) when the §5.6 formula exceeds it. A-D stay uncapped. + if band == "E": + e_default = ( + 1.5 + if ctry == Country.SCT + and construction == WALL_STONE_SANDSTONE + else 1.7 + ) + if u0 >= e_default: + u0 = e_default # RdSAP 10 §5.8 + Table 14 (PDF p.41-42) — added External/Internal # insulation on a stone wall: U = 1/(1/U₀ + R_ins), with U₀ the # RAW §5.6 stone result (the Table-6 footnote (a) 1.7 cap does NOT @@ -630,15 +643,12 @@ def u_wall( Decimal("0.01"), rounding=ROUND_HALF_UP ) ) - # As-built (uninsulated, not dry-lined) stone wall of KNOWN - # thickness, age A-E: return the raw §5.6 result. The Table-6 - # footnote (a) "< 1.7" clause governs the UNKNOWN-thickness path - # (which falls through to the Table-6 typical 1.7 default below) — - # NOT a documentary-thickness lodgement. A thin solid stone wall - # genuinely has U > 1.7 (e.g. sandstone 400 mm = 1.90, granite - # 120 mm = 3.89); capping it to 1.7 under-counts fabric loss and - # over-rates. Confirmed against Elmhurst (age-B sandstone 400 mm - # → 1.9) and the §5.6 Table-12 formula tests. + # As-built (uninsulated, not dry-lined) stone wall, age A-E: + # return the §5.6 result — uncapped for A-D, age-E-capped above. + # A thin solid stone wall genuinely has U > 1.7 (sandstone 400 mm + # = 1.90, granite 120 mm = 3.89); capping A-D to 1.7 under-counts + # fabric loss and over-rates. Confirmed against Elmhurst (age-B + # sandstone 400 mm → 1.90) and the §5.6 Table-12 formula tests. return u0 known_types = { WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY, diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 38efc93c..14599a33 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -849,6 +849,96 @@ def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a assert abs(result - 1.7) <= 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