fix(uvalues): cap as-built stone U at age E only, not A-E; drop insulation-state gate (RdSAP 10 Tables 6-7 footnote a, PDF p.33-35)

Commit 034d4b7c corrected two stone cases but via the wrong mechanism: it
removed the 1.7 cap for ALL age bands and gated the §5.6 formula on
`wall_insulation_type is not None`. Both diverge from RdSAP 10 Tables 6-10:

- The stone rows read "According to 5.6" for bands A-D (uncapped formula)
  and "1.7 a" at band E, where footnote (a) = "Or from equations in 5.6 if
  the calculated U-value is less than 1.7" → U_E = min(formula, 1.7). The
  cap belongs ONLY at age E. Removing it for E let an as-built 50 mm granite
  age-E wall return the formula's 6.09 instead of 1.70.
- Scotland sandstone/limestone age E defaults to 1.5 (Table 7, PDF p.35);
  granite/whinstone stays 1.7.
- The insulation-state gate is not a spec rule. It sent age-A-D stone with
  "insulation Unknown" (wall_insulation_type None) to the flat-1.7 table
  instead of the §5.6 formula; it only "worked" for fixture 000565 Ext1
  because that wall is age E, where 1.7 is correct anyway. Removed: the
  age-E cap, not the gate, is what produces 000565 Ext1's worksheet 1.70.

Bands A-D stay uncapped (sandstone 400 mm → 1.90, granite 120 mm → 3.89).
Unknown-thickness handling unchanged in this commit (still flat-table; the
§3.5 Table-3 default follows separately).

pyright not installed in this container — strict type gate not run locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-20 13:24:31 +00:00
parent 034d4b7cd5
commit 7e187078b9
2 changed files with 126 additions and 26 deletions

View file

@ -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,

View file

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