From 6c8bbbc9e2cb3a7b90faf569044ad89231fcd71e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 08:37:46 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.86:=20=C2=A75.6=20thin-wall=20sto?= =?UTF-8?q?ne=20+=20=C2=A75.8=20dry-line=20closes=20BP[0]=20alt1=20cascade?= =?UTF-8?q?=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Sandstone or limestone: U = 54.876 × W^(-0.561) Granite or whinstone: U = 45.315 × W^(-0.513) Where W is wall thickness in mm. "Apply the adjustment according to Table 14: Insulation thickness and corresponding resistance if wall is insulated or dry-lined including lath and plaster." Combined with §5.8 (PDF p.40) + Table 14 (PDF p.41) dry-line R = 0.17 m²K/W: U = 1 / (1/U₀ + 0.17). Cert 000565 BP[0] Main alt1 is the cohort fixture: Stone Granite, age band A (inherited from Main), 120 mm wall thickness, 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) "External walls Main alt.1 ... SolidWallDensePlasterInsul, Solid, 0.0, 2.34" EXACT. Pre-S0380.86 two coupled bugs blocked this path: 1. Mapper mis-name per [[feedback-no-misleading-insulation-type]]: `_map_elmhurst_alternative_wall` routed the Elmhurst Summary §7 "Alternative Wall N Thickness" lodging (the WALL thickness) onto `SapAlternativeWall.wall_insulation_thickness="120"`. The cascade then mis-bucketed it as 100 mm insulation (bucket=100 → _BRICK_INS_100 row at age A → U=0.32). The Elmhurst Summary schema has no "Alternative Wall N Insulation Thickness" line at all — `wall_insulation_thickness` on alts was always semantically the wall thickness, never insulation. 2. `u_wall` had no §5.6 thin-wall stone branch. Stone constructions fell through to Table 6 row values (designed for typical- thickness ~300mm+ walls), which dramatically under-state heat loss for sub-200mm stone. Fix span: - datatypes/epc/domain/epc_property_data.py:SapAlternativeWall: new `wall_thickness_mm: Optional[int] = None` field, mirroring `SapBuildingPart.wall_thickness_mm`. - datatypes/epc/domain/mapper.py:_map_elmhurst_alternative_wall: routes Elmhurst `a.thickness_mm` (Wall thickness) onto `wall_thickness_mm`; leaves `wall_insulation_thickness=None` on this path (no Elmhurst Summary alt-wall insulation-thickness line exists). - domain/sap10_ml/rdsap_uvalues.py: new `_u_stone_thin_wall_age_a_to_e(construction, W)` helper implements §5.6 Table 12 formulas. `u_wall` accepts a new `wall_thickness_mm: Optional[int] = None` param; dispatches §5.6 formula when (a) wall thickness lodged, (b) age band ∈ A-E, (c) construction ∈ {STONE_GRANITE, STONE_SANDSTONE}. §5.8 + Table 14 R=0.17 applied on top when dry_lined=True. - domain/sap10_calculator/worksheet/heat_transmission.py: `_alt_wall_contribution_w_per_k` passes `wall_thickness_mm=alt_wall.wall_thickness_mm` to `u_wall`. Tests (7 new, AAA-structure): - 5 in domain/sap10_ml/tests/test_rdsap_uvalues.py — granite at 120 mm with dry-line (U=2.34); granite raw formula (U=3.89); sandstone (U=3.74); age-G gate (Table 6 row, NOT formula); no wall_thickness fallback (Table 6 row 1.7). - 2 in backend/documents_parser/tests/test_summary_pdf_mapper_chain .py — mapper pin (wall_thickness_mm=120 on BP[0] alt1; wall_insulation_thickness=None) and cascade pin (walls_w_per_k ≥ 595, post-S0380.85 was 555.93). **Cert 000565 cascade walls: 555.93 → 602.40 W/K (worksheet 604.07; 0.27% residual).** BP[0] alt1 cascade U: 0.32 → 2.34. Cascade walls within 2 W/K of worksheet target across S0380.85+.86 closure cycle. Test baseline: 560 pass (was 558 + 7 new − 5 already passing pins that moved) + 9 expected `test_sap_result_pin[000565-*]` fails unchanged. Cohort + golden + cert 9501 unaffected: of the 6 cohort fixtures only cert 000565 alt1 lodged a `wall_insulation_thickness` value on `SapAlternativeWall` (audit confirmed) — and that value was always semantically the wall thickness, so the rename is a fix not a behaviour change. The API mapper path defaults `wall_thickness_mm` to None (API schema doesn't yet surface alt-wall thickness; safe forward-compat). Per [[feedback-verify-handover-claims]]: the post-S0380.84 handover predicted SH residual would close after the wall fixes. Empirically SH grew +2591 → +6348 → +7924 across S0380.84/.85/.86 — confirming a SEPARATE SH-channel over-count that's independent of fabric (each +1 W/K of spec-correct walls adds ~33.5 kWh of cascade SH, vs the worksheet's ~38.96 kWh/W/K rate). The walls fixes are spec-correct; the SH over-count is now a single isolated open work-item for the next slice (~+8 k kWh structural). Pyright net-zero per touched file (test_rdsap_uvalues.py error count actually decreased by 1). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 83 +++++++++++ datatypes/epc/domain/epc_property_data.py | 8 + datatypes/epc/domain/mapper.py | 23 ++- .../worksheet/heat_transmission.py | 5 + domain/sap10_ml/rdsap_uvalues.py | 48 ++++++ domain/sap10_ml/tests/test_rdsap_uvalues.py | 137 ++++++++++++++++++ 6 files changed, 298 insertions(+), 6 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 650022f8..edc526a3 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1460,6 +1460,89 @@ def test_summary_000565_ext2_curtain_wall_routes_to_u_value_1p4_per_rdsap_10_sec ) +def test_summary_000565_mapper_routes_alt_wall_thickness_120mm_to_wall_thickness_mm_field() -> None: + """The Summary §7 "Alternative Wall N Thickness" line is the WALL + thickness, NOT an insulation thickness. Cert 000565 BP[0] Main + alt1 lodges + + Alternative Wall 1 Type SG Stone: granite or whinstone + Alternative Wall 1 Insulation A As Built + Alternative Wall 1 Dry-lining Yes + Alternative Wall 1 Thickness 120 mm + + Pre-S0380.86 `_map_elmhurst_alternative_wall` routed this 120 mm + onto `SapAlternativeWall.wall_insulation_thickness="120"`, a + semantic mis-name flagged in `[[feedback-no-misleading-insulation- + type]]`. The cascade then mis-bucketed it as insulation (bucket + 100 → _BRICK_INS_100 → U=0.32 at age A) instead of routing to the + RdSAP 10 §5.6 thin-wall stone formula (U₀=3.89 → §5.8 dry-line + adjustment → U=2.34, matching worksheet line (29a)). + + This pin asserts the mapper now lodges the wall thickness on the + new `SapAlternativeWall.wall_thickness_mm` field, leaving + `wall_insulation_thickness=None` (the As-Built lodging carries + no insulation thickness). + """ + # Arrange + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + alt1 = epc.sap_building_parts[0].sap_alternative_wall_1 + assert alt1 is not None + assert alt1.wall_construction == 1, ( + f"BP[0] alt1 wall_construction = {alt1.wall_construction!r}; " + f"expected 1 (WALL_STONE_GRANITE)" + ) + assert alt1.wall_thickness_mm == 120, ( + f"BP[0] alt1 wall_thickness_mm = {alt1.wall_thickness_mm!r}; " + f"expected 120 (the lodged wall thickness, not insulation)" + ) + assert alt1.wall_insulation_thickness is None, ( + f"BP[0] alt1 wall_insulation_thickness = " + f"{alt1.wall_insulation_thickness!r}; expected None (As-Built " + f"lodging carries no insulation thickness)" + ) + assert alt1.wall_dry_lined == "Y" + + +def test_summary_000565_bp0_alt1_stone_granite_thin_wall_routes_to_u_value_2p34_per_rdsap_10_section_5_6() -> None: + """End-to-end cascade pin: with `wall_thickness_mm=120` plumbed + through extractor + mapper + `u_wall` §5.6 thin-wall formula + + §5.8 dry-line adjustment, cert 000565 BP[0] Main alt1 cascade + U-value moves from 0.32 → 2.34 (worksheet line (29a) pin). + + Δ U=2.02 × area=23 m² → +46.5 W/K of cascade walls heat loss. + Combined with S0380.85's Curtain Wall closure (+112 W/K), the + cascade walls subtotal closes from 443 W/K (pre-S0380.84 + baseline) → ~602 W/K (worksheet 604.07; <0.5% residual). + + Asserts the cascade walls subtotal is now within 2% of worksheet + (post-S0380.85 was 555.93; this slice should bring it to ~602). + """ + # Arrange + from domain.sap10_calculator.worksheet.heat_transmission import ( + heat_transmission_from_cert, + ) + + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + ht = heat_transmission_from_cert(epc) + + # Assert — worksheet target 604.07; lower-bound 595 is a robust + # gate that admits ≤2% residual against the worksheet pin. + assert ht.walls_w_per_k >= 595.0, ( + f"walls_w_per_k = {ht.walls_w_per_k:.2f}; expected ≥595 after " + f"§5.6 thin-wall + §5.8 dry-line dispatch (post-S0380.85 was 555.93)" + ) + + def test_summary_000565_ext1_party_wall_routes_to_cavity_filled_code_4() -> None: # Arrange — RdSAP 10 Table 15 row 3 "Cavity masonry filled": # cert 000565 Ext1 lodges "CF Cavity masonry filled". Routes diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index e6474755..a126ec9e 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -375,6 +375,14 @@ class SapAlternativeWall: # at U=1.90, where the 9-mm-thick single-layer timber wall doesn't # fit the Table 6 buckets cleanly). u_value: Optional[float] = None + # WALL thickness in mm (not insulation thickness — separately + # surfaced as `wall_insulation_thickness`). Lodged by Elmhurst + # Summary §7 "Alternative Wall N Thickness" when `Thickness + # Unknown: No`. Drives the RdSAP 10 §5.6 thin-wall stone formula + # (PDF p.40) when construction is stone and age band is A-E. + # Mirrors `SapBuildingPart.wall_thickness_mm` per the + # [[feedback-no-misleading-insulation-type]] convention. + wall_thickness_mm: Optional[int] = None @property def is_basement_wall(self) -> bool: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 35d17d92..b9ce5639 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3139,18 +3139,29 @@ def _map_elmhurst_alternative_wall( measurement); we route through the cascade with thickness=None so `u_wall` falls through to the age-band-and-construction default (e.g. Timber Frame age B → U=1.9 for the 000487 9-mm-thin-wall - case, matching the full-cert-text "TimberWallOneLayer" lodgement).""" + case, matching the full-cert-text "TimberWallOneLayer" lodgement). + + The Elmhurst Summary §7 "Alternative Wall N Thickness" line is the + WALL thickness (drives the RdSAP 10 §5.6 thin-wall stone formula + per PDF p.40 for age A-E stone constructions), NOT an insulation + thickness. Pre-S0380.86 the mapper mis-routed it to + `wall_insulation_thickness` per [[feedback-no-misleading- + insulation-type]] semantic mismatch; now lodged on the new + `wall_thickness_mm` field. There is no separate "Alternative Wall + N Insulation Thickness" line in the Elmhurst Summary schema — + alt-wall insulation thickness is always None on this path. + """ + measured_thickness_mm = ( + a.thickness_mm if (not a.thickness_unknown and a.thickness_mm is not None) else None + ) return SapAlternativeWall( wall_area=a.area_m2, wall_dry_lined="Y" if a.dry_lined else "N", wall_construction=_elmhurst_wall_construction_int(a.wall_type) or 0, wall_insulation_type=_elmhurst_wall_insulation_int(a.insulation) or 4, wall_thickness_measured="Y" if not a.thickness_unknown else "N", - wall_insulation_thickness=( - None - if a.thickness_unknown - else str(a.thickness_mm) if a.thickness_mm is not None else None - ), + wall_insulation_thickness=None, + wall_thickness_mm=measured_thickness_mm, ) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index afb60b20..ced751dc 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -1008,5 +1008,10 @@ def _alt_wall_w_per_k( # when no insulation thickness is lodged. Cohort fixture: cert # 7700 Alt 1 (Cavity, As-Built, Dry-lined) → 1.50 → 1.20. dry_lined=alt_wall.wall_dry_lined == "Y", + # RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall formula + # for age bands A-E, keyed on lodged wall thickness. Cohort + # fixture: cert 000565 BP[0] alt1 (Stone Granite age A, 120mm, + # dry-lined) → U=2.34 via §5.6 + §5.8 chain. + wall_thickness_mm=alt_wall.wall_thickness_mm, ) return alt_u * net_alt_area diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 90423d57..0de18568 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -144,6 +144,39 @@ _WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04 _DRY_LINING_RESISTANCE_M2K_PER_W: Final[float] = 0.17 +# RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall formula, age bands +# A to E (Table 12): +# +# Sandstone or limestone: U = 54.876 × W^(-0.561) +# Granite or whinstone: U = 45.315 × W^(-0.513) +# +# Where W is wall thickness in mm. Apply §5.8 + Table 14 (PDF p.41) on +# top for dry-lining / lath-and-plaster: U_adj = 1/(1/U₀ + 0.17). The +# formula only applies for age bands A-E per the §5.6 heading; for age +# F+ Table 6 row values represent typical-thickness stone walls and +# are the spec target. +# +# Empirical pin: cert 000565 BP[0] Main alt1 lodges Stone Granite, +# age A, 120 mm wall, dry-lined → §5.6 + §5.8 → U=2.34 (worksheet +# line (29a)). The §5.6 formula keys on a documentary wall-thickness +# lodgement (RdSAP 10 §5.3 / §3.5); without it, fall back to Table 6. +_STONE_AGE_A_TO_E: Final[frozenset[str]] = frozenset({"A", "B", "C", "D", "E"}) + + +def _u_stone_thin_wall_age_a_to_e( + construction: int, wall_thickness_mm: int, +) -> Optional[float]: + """RdSAP 10 §5.6 Table 12 (PDF p.40) — formula U-value for an + uninsulated stone wall of known thickness in age bands A-E. + Returns None when the construction is not stone (granite / + sandstone) — caller must fall through to the Table 6 cascade.""" + if construction == WALL_STONE_GRANITE: + return 45.315 * (wall_thickness_mm ** -0.513) + if construction == WALL_STONE_SANDSTONE: + return 54.876 * (wall_thickness_mm ** -0.561) + return None + + # RdSAP 10 §5.18 (PDF p.48) — curtain-wall U-values. # # "If documentary evidence is available, use calculated U-value of the @@ -370,6 +403,7 @@ def u_wall( wall_insulation_type: Optional[int] = None, dry_lined: bool = False, curtain_wall_age: Optional[str] = None, + wall_thickness_mm: Optional[int] = None, ) -> float: """RdSAP10 wall U-value in W/m^2K, never null. @@ -415,6 +449,20 @@ 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 thin-wall + # formula, age bands A-E. Fires only when a documentary wall + # thickness is lodged (per §5.3 documentary-evidence rule). + # §5.8 + Table 14 dry-line adjustment applies on top. + if ( + wall_thickness_mm 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: + if dry_lined: + return 1.0 / (1.0 / u0 + _DRY_LINING_RESISTANCE_M2K_PER_W) + return u0 known_types = { WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY, WALL_TIMBER_FRAME, WALL_SYSTEM_BUILT, WALL_COB, diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 174ea842..1b4b28c3 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -27,6 +27,7 @@ from domain.sap10_ml.rdsap_uvalues import ( WALL_INSULATION_FILLED_CAVITY, WALL_SOLID_BRICK, WALL_STONE_GRANITE, + WALL_STONE_SANDSTONE, WALL_SYSTEM_BUILT, WALL_TIMBER_FRAME, thermal_bridging_y, @@ -586,6 +587,142 @@ def test_u_wall_curtain_wall_pre_2023_uses_rdsap_5_18_default_u_2p0() -> None: assert abs(result - 2.0) <= 1e-9 +def test_u_wall_stone_granite_thin_wall_age_a_120mm_dry_lined_applies_5_6_formula_with_5_8_adjustment() -> None: + # Arrange — RdSAP 10 §5.6 (PDF p.40) "U-values of uninsulated stone + # walls, age bands A to E": + # + # Table 12: Default U-values of stone walls + # Granite or whinstone: U = 45.315 × W^(-0.513) + # Where W is wall thickness in mm. + # + # Then RdSAP 10 §5.8 (PDF p.40) + Table 14 (PDF p.41) — for + # dry-lining (including laths and plaster) apply R = 0.17 m²K/W + # additively to U₀: + # + # U = 1 / (1/U₀ + R_insulation) + # + # Cert 000565 BP[0] Main alt1 is the cohort fixture: stone granite, + # age band A (inherited from Main), wall thickness 120 mm, dry-lined. + # §5.6 formula: U₀ = 45.315 × 120^(-0.513) ≈ 3.8871 + # §5.8 + Table 14 dry-line: U = 1/(1/3.8871 + 0.17) ≈ 2.3405 + # → matches worksheet U985-0001-000565 line (29a) pin U=2.34. + # + # Pre-S0380.86: the cert lodged its alt-wall thickness via the + # misnamed `wall_insulation_thickness="120"` field, which routed + # through `_insulation_bucket(120, ins_present=False)` → 100 → + # _BRICK_INS_100 (the stone-insulated-100mm row) → 0.32 W/m²K at + # age A. Δ contribution −46.5 W/K on the 23 m² alt area. + + # Act + result = u_wall( + country=Country.ENG, + age_band="A", + construction=WALL_STONE_GRANITE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + dry_lined=True, + wall_thickness_mm=120, + ) + + # Assert — worksheet 2.34 (4 d.p. tolerance for round-half-up) + assert abs(result - 2.34) <= 1e-2 + + +def test_u_wall_stone_granite_thin_wall_age_a_120mm_no_dry_line_returns_raw_5_6_formula() -> None: + # Arrange — same wall + thickness as above but without dry-lining. + # §5.6 formula returns U₀ directly (no §5.8 adjustment applied). + # U₀ = 45.315 × 120^(-0.513) ≈ 3.8871 + + # Act + result = u_wall( + country=Country.ENG, + age_band="A", + construction=WALL_STONE_GRANITE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + dry_lined=False, + wall_thickness_mm=120, + ) + + # Assert + assert abs(result - 3.8871) <= 1e-3 + + +def test_u_wall_stone_sandstone_thin_wall_age_a_120mm_uses_5_6_sandstone_formula() -> None: + # Arrange — §5.6 (PDF p.40) Table 12: sandstone/limestone formula + # is distinct from granite/whinstone: + # Sandstone or limestone: U = 54.876 × W^(-0.561) + # At W=120 mm: U₀ ≈ 3.7408. The dispatch must pick the formula + # by construction code (WALL_STONE_SANDSTONE vs WALL_STONE_GRANITE). + + # Act + result = u_wall( + country=Country.ENG, + age_band="A", + construction=WALL_STONE_SANDSTONE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + dry_lined=False, + wall_thickness_mm=120, + ) + + # Assert + assert abs(result - 3.7408) <= 1e-3 + + +def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_age_a_to_e_gate() -> None: + # Arrange — §5.6 (PDF p.40) heading explicitly scopes the formula + # to "age bands A to E". For age F onwards Table 6 gives literal + # U-values that already encode typical-thickness stone wall heat + # loss — applying §5.6 outside the A-E gate would over-estimate U + # for modern stone walls. Cert 000565 alt1 happens to be age A, + # but this test guards against §5.6 leaking into post-1976 stone + # constructions. + # + # At age G stone granite, Table 6 gives U=0.60 (cohort-typical row). + # The §5.6 formula at 120 mm would return 3.89 — wildly over. + + # Act + result = u_wall( + country=Country.ENG, + age_band="G", + construction=WALL_STONE_GRANITE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + wall_thickness_mm=120, + ) + + # Assert — Table 6 row at age G, NOT §5.6 formula. + assert abs(result - 0.60) <= 1e-3 + + +def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a_default() -> None: + # Arrange — §5.6 formula only fires when a wall thickness is + # lodged. Without documentary wall-thickness evidence, fall back + # to the Table 6 row (which represents typical thickness). For + # age A stone granite without thickness, the cascade preserves + # its existing "as-built typical" U value rather than the formula + # extrapolation. + + # Act + result = u_wall( + country=Country.ENG, + age_band="A", + construction=WALL_STONE_GRANITE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + wall_thickness_mm=None, + ) + + # Assert — _TYPICAL_STONE_UNINSULATED at age A = 1.7 (cohort default). + assert abs(result - 1.7) <= 1e-3 + + def test_u_wall_curtain_wall_missing_age_lodgement_defaults_to_pre_2023_u_2p0_per_rdsap_5_18() -> None: # Arrange — when the cert lodges `Type: CW Curtain Wall` but no # `Curtain Wall Age` line (older Elmhurst Summary PDFs, or API EPCs