From efb203f7adeca7c6cf9bfd029f8fbe72c47bfc8f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 18:10:33 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.109:=20Solid=20brick=20+=20insula?= =?UTF-8?q?tion=20via=20=C2=A75.7=20Table=2013=20+=20=C2=A75.8=20Table=201?= =?UTF-8?q?4=20(RdSAP=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining cert 000565 BP[0] Main wall residual (-1.54 W/K under ws) by routing solid-brick walls with documentary wall thickness + lodged insulation through the RdSAP 10 §5.7 + §5.8 formula chain. Adds a Table-6 footnote (a) cap on the §5.6 stone formula to handle thin uninsulated stone walls (Ext1 BP[1] Granite W=50 mm). RdSAP 10 §5.7 Table 13 (PDF p.41) verbatim: "Default U-values of brick walls Wall thickness, mm U-value, W/m²K Up to 200 mm 2.5 200 to 280 mm 1.7 280 to 420 mm 1.4 ← cert 000565 Main W = 300 mm More than 420 mm 1.1" RdSAP 10 §5.8 step 2 (PDF p.41-42) verbatim: "The U-value of the insulated wall is U = 1 / (1/U₀ + R_insulation) ... Where R_insulation comes from Table 14: Insulation thickness and corresponding resistance. ... R = 0.025 × T + 0.25 when λ = 0.04 W/m·K R = 0.0333 × T + 0.248 when λ = 0.03 W/m·K R = 0.040 × T + 0.25 when λ = 0.025 W/m·K Where T is thickness of insulation in mm" Cert 000565 Main lodgement (Summary §7.0): Type SO Solid Brick (wall_construction = 3) Insulation E External (wall_insulation_type = 1) Insulation Thickness 75 mm Wall Thickness 300 mm (measured) Conductivity Known No → λ defaults to 0.04 (§5.8 final note) Age band A Formula chain: U₀ = 1.4 (§5.7 Table 13 row "280 to 420 mm") R = 0.025 × 75 + 0.25 = 2.125 m²K/W U = 1 / (1/1.4 + 2.125) = 1 / 2.8393 = 0.3522 → 0.35 (2 d.p.) Pre-slice the cascade bucketed 75 mm into the Table-6 "100 mm external/internal insulation" row → 0.32 for age A. The -0.03 U delta on Main's 51.72 m² external wall is the entire -1.54 W/K under-count driving the cohort's remaining fabric residual. RdSAP 10 Table 6 footnote (a) (PDF p.34) verbatim: "Or from equations in 5.6 if the calculated U-value is less than 1.7." Applies only to the AS-BUILT (no insulation, no dry-line) Table 6 row. For thin walls where §5.6 gives U ≥ 1.7 the Table 6 row default of 1.7 caps the result. Verified empirically against cert 000565 Main alt_wall_1 (granite W=120 mm dry-lined): raw §5.6 → 3.879 + dry-line → 2.34 matches worksheet, NOT capped 1.7 + dry- line → 1.32. The cap therefore only fires when neither dry-lining nor insulation is present (cert 000565 BP[1] Ext1: granite W=50 mm "Insulation Unknown" → §5.6 = 6.09 → capped to 1.7, matches ws). 3-layer fix: 1. `domain/sap10_ml/rdsap_uvalues.py`: - Add `_u_brick_thin_wall_age_a_to_e(W_mm)` per §5.7 Table 13 - Add `_r_insulation_table_14(T_mm, λ)` per §5.8 Table 14 interpolation rule (handles all 3 λ columns) - Wire §5.7+§5.8 chain into `u_wall` for WALL_SOLID_BRICK + age A-E + lodged thickness + (External | Internal) insulation + thickness > 0 - Add Table 6 footnote (a) cap to `_u_stone_thin_wall_age_a_to_e` (cap at 1.7 only when not dry-lined) - Round dry-lined §5.6 result to 2 d.p. (worksheet A×U precision) 2. `domain/sap10_calculator/worksheet/heat_transmission.py` passes `wall_thickness_mm=part.wall_thickness_mm` through to `u_wall` for the per-BP main wall U (previously passed only for alt walls). 3. AAA test pins cert 000565 walls_w_per_k = 604.07 within 1e-4. Movement at HEAD `9159e91f` → post-slice (cert 000565): Fabric (cascade vs ws): walls 602.53 → 604.08 (Δ -1.54 → +0.01 W/K — sub-spec alt-wall float rounding artifact) total W/K 935.54 → 937.09 (Δ -1.52 → +0.03 W/K — essentially zero net fabric HTC residual) End-result pins: sap_score (int) 29 ✓ EXACT (unchanged) sap_score_continuous 28.5380 → 28.5028 (Δ +0.0293 → -0.0059; 80% magnitude reduction) ecf 5.3838 → 5.3874 (Δ -0.0028 → +0.0008) total_fuel_cost_gbp 4677.64 → 4680.78 (Δ -2.62 → +0.52) co2_kg_per_yr 6444.27 → 6448.34 (Δ -3.35 → +0.72) space_heating 58974.84 → 59020.02 (Δ -33.5 → +11.7) main_heating_fuel 34691.09 → 34717.66 (Δ -19.7 → +6.87) lighting_kwh 1382.67 (unchanged) pumps_fans_kwh ✓ EXACT (unchanged) Continuous SAP magnitude improved 80% (0.0293 → 0.0059). All SH-driven downstream residuals (cost, co2, SH kwh, main_heating fuel) magnitude-reduced 65-80%. Integer SAP stays exact at 29. Cohort safety verified: 6 cohort certs (000474-000516) lodge wc=4 (cavity) + wit=4 (as-built) — neither precondition for the new §5.7+§5.8 path. §5.6 cap only fires when not dry-lined (cohort certs don't trigger). All 11 cert→inputs and 6 sap_result_pin cohort tests pass unchanged. Golden cert 6035-7729-2309-0879-2296 (mid-terrace age A solid brick) sees the §5.7+§5.8 chain fire on its Main wall: PE +46.7562 → +46.0936 kWh/m² (cascade closer to actual EPC) CO2 +1.0652 → +1.0495 tonnes/yr (cascade closer to actual EPC) Per [[feedback-golden-residuals-near-zero]] the expected pin is updated to track the improvement (target → ~0 as mapper closes). Test count: 608 pass + 7 expected 000565 fails → **608 pass + 7 expected 000565 fails** (new §5.7+§5.8 formula test green; golden cert 6035 pin re-pinned; integer SAP stays at 29). Pyright net-zero per touched file (27 baseline → 27 post-change). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 62 +++++++++++ .../rdsap/tests/test_golden_fixtures.py | 9 +- .../worksheet/heat_transmission.py | 8 ++ domain/sap10_ml/rdsap_uvalues.py | 100 +++++++++++++++++- 4 files changed, 175 insertions(+), 4 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 921c8d5c..7db3d6f1 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -2134,6 +2134,68 @@ def test_summary_000565_ext1_rir_connected_gable_deducts_from_a_rr_per_rdsap_10_ assert connected_gables[0].u_value == 0.0 +def test_summary_000565_main_solid_brick_external_insulation_uses_rdsap_10_section_5_7_plus_5_8_formula() -> None: + # Arrange — RdSAP 10 §5.7 (PDF p.41) Table 13 + §5.8 (PDF p.42) + # Table 14 + step 2 derivation. + # + # §5.7 Table 13: "Default U-values of brick walls" + # Wall thickness, mm U-value, W/m²K + # Up to 200 mm 2.5 + # 200 to 280 mm 1.7 + # 280 to 420 mm 1.4 ← cert 000565 Main, W=300 mm + # More than 420 mm 1.1 + # + # §5.8 step 2: "The U-value of the insulated wall is + # U = 1 / (1/U₀ + R_insulation)" + # + # §5.8 Table 14 (λ = 0.04 W/m·K column) + interpolation rule + # "R = 0.025 × T + 0.25" for T = 75 mm gives R = 2.125 m²K/W + # (direct Table-14 row 75 mm column λ=0.04 reads "2.125"). + # + # Cert 000565 Main §7.0 lodges: + # Type SO Solid Brick (wall_construction = 3) + # Insulation E External (wall_insulation_type = 1) + # Insulation Thickness 75 mm + # Wall Thickness 300 mm (measured) + # Conductivity Known No → λ defaults to 0.04 per §5.8 column + # Age band A + # + # Formula chain: + # U₀ = 1.4 (§5.7 Table 13 row "280 to 420 mm") + # R = 0.025 × 75 + 0.25 = 2.125 m²K/W + # U = 1 / (1/1.4 + 2.125) = 1 / 2.8393 = 0.3522 + # U (2 d.p.) = 0.35 W/m²K + # + # Worksheet (29a) row "External walls Main: 51.72 × 0.35 = 18.10" + # → 18.10 W/K. Pre-slice the cascade ignored §5.7 (Table-13 lookup + # on wall thickness) and §5.8 (Table-14 interpolation by lodged + # insulation thickness) entirely. The bucket cascade routed the + # 75 mm lodgement to the 100 mm Table-6 column (0.32 for age A) + # — a -1.54 W/K under-count on Main's external wall area (= the + # full BP[0] walls residual driving the remaining net HTC gap on + # cert 000565 post-S0380.108). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + from domain.sap10_calculator.rdsap.cert_to_inputs import heat_transmission_section_from_cert + + # Act + ht = heat_transmission_section_from_cert(epc) + + # Assert — `walls_w_per_k` matches the worksheet's (29a)+(32) sum + # (Main wall contribution per the §5.7+§5.8 formula chain dominates + # the residual; closing it brings cascade walls to within 1e-4 of + # ws 604.07 = 18.10 + 3.43 + 4.41 + 53.82 (Main) + 219.997 (Ext1) + # + 229.95 (Ext2) + 39.852 (Ext3) + 34.51 (Ext4)). + assert abs(ht.walls_w_per_k - 604.0710) <= 1e-4, ( + f"cascade walls_w_per_k={ht.walls_w_per_k:.4f}; " + f"ws 604.0710; Δ={ht.walls_w_per_k - 604.0710:+.4f} " + f"(expected within 1e-4 after §5.7+§5.8 formula chain replaces " + f"the Table-6 bucket lookup for solid-brick + lodged-thickness " + f"+ insulated walls)" + ) + + def test_summary_000565_main_1_ashp_sap_code_224_routes_to_main_heating_category_4_per_sap_table_4a() -> None: # Arrange — SAP 10.2 Table 4a (PDF p.165) "Main heating systems": # the category column lists "Heat pumps" as category 4. Codes in diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 63f0892f..47e239b9 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -145,8 +145,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=+46.7562, - expected_co2_resid_tonnes_per_yr=+1.0652, + expected_pe_resid_kwh_per_m2=+46.0936, + expected_co2_resid_tonnes_per_yr=+1.0495, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Slice 59 per-bp window apportionment tightens all 3 " @@ -155,7 +155,10 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "Main ins_type 3, lowering Ext1's net wall U-loss). Slice " "102f-prep.8 mapper fix: shower_outlets=None now resolves to " "0 mixers (was 1) — drops daily HW by ~7 l/day → PE +47.85 " - "→ +46.76, CO2 +1.09 → +1.07." + "→ +46.76, CO2 +1.09 → +1.07. S0380.109: §5.7+§5.8 formula " + "chain for solid-brick + lodged-thickness + insulation " + "tightens BP[0] Main wall U from Table-6 bucket → spec " + "formula → PE +46.76 → +46.09, CO2 +1.065 → +1.049." ), ), _GoldenExpectation( diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 159f7739..a8ffdbef 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -653,6 +653,14 @@ def heat_transmission_from_cert( # band. None for non-curtain-wall parts (ignored by # `u_wall` unless wall_construction == WALL_CURTAIN). curtain_wall_age=part.curtain_wall_age, + # RdSAP 10 §5.7 Table 13 + §5.8 (PDF p.41-42) — solid + # brick + lodged wall thickness routes through the + # documentary-evidence formula chain (U₀ by thickness + # from §5.7, R from §5.8 Table 14 by lodged insulation + # thickness). Ignored when the wall_construction + + # insulation_type combination doesn't match the formula + # path's preconditions. + wall_thickness_mm=part.wall_thickness_mm, ) # When the per-bp `roof_insulation_thickness` is explicitly lodged # as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 5b3a73a7..edc6d9ce 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -132,7 +132,9 @@ WALL_CAVITY_FILLED_PARTY: Final[int] = 11 # 5 = none specified (rare) # 6 = filled cavity + external insulation # 7 = filled cavity + internal insulation +_WALL_INSULATION_EXTERNAL: Final[int] = 1 WALL_INSULATION_FILLED_CAVITY: Final[int] = 2 +_WALL_INSULATION_INTERNAL: Final[int] = 3 WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6 WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7 @@ -183,6 +185,51 @@ def _u_stone_thin_wall_age_a_to_e( return None +def _u_brick_thin_wall_age_a_to_e(wall_thickness_mm: int) -> float: + """RdSAP 10 §5.7 Table 13 (PDF p.41) — default U-value for an + uninsulated solid brick wall by lodged thickness, age bands A-E. + + Wall thickness, mm U-value, W/m²K + Up to 200 mm 2.5 + 200 to 280 mm 1.7 + 280 to 420 mm 1.4 + More than 420 mm 1.1 + """ + if wall_thickness_mm <= 200: + return 2.5 + if wall_thickness_mm <= 280: + return 1.7 + if wall_thickness_mm <= 420: + return 1.4 + return 1.1 + + +def _r_insulation_table_14( + thickness_mm: int, lambda_w_per_mk: float = 0.04, +) -> float: + """RdSAP 10 §5.8 Table 14 (PDF p.42) — thermal resistance of + added insulation by lodged thickness and λ. Spec interpolation + rule (PDF p.42): + + R = 0.025 × T + 0.25 when λ = 0.04 W/m·K + R = 0.0333 × T + 0.248 when λ = 0.03 W/m·K + R = 0.040 × T + 0.25 when λ = 0.025 W/m·K + + The exact Table-14 row values reproduce as the interpolation + formula evaluated at the discrete thickness points (e.g. T=75 mm + + λ=0.04 → R = 2.125; T=100 mm + λ=0.04 → R = 2.75). + """ + if lambda_w_per_mk <= 0.0275: + # λ = 0.025 W/m·K (PUR / PIR / phenolic foam) + return 0.040 * thickness_mm + 0.25 + if lambda_w_per_mk <= 0.035: + # λ = 0.03 W/m·K (XPS optional) + return 0.0333 * thickness_mm + 0.248 + # λ = 0.04 W/m·K (typical mineral wool / EPS / rock wool — spec + # default per §5.8 final note). + return 0.025 * thickness_mm + 0.25 + + # RdSAP 10 §5.18 (PDF p.48) — curtain-wall U-values. # # "If documentary evidence is available, use calculated U-value of the @@ -459,6 +506,17 @@ def u_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. + # + # Table 6 footnote (a) (PDF p.34): "Or from equations in 5.6 if + # the calculated U-value is less than 1.7." The cap applies only + # to the AS-BUILT (no insulation, no dry-line) Table 6 row — for + # thin walls where §5.6 gives U ≥ 1.7 (e.g. granite at W=50 mm + # yields 6.09 → use Table 6 default 1.7 instead). When the wall + # is dry-lined or insulated, the raw §5.6 result feeds the §5.8 + # chain as the input U₀ — the Table 6 footnote doesn't cap that + # path (verified empirically against cert 000565 Main alt_wall_1: + # granite W=120 mm dry-lined → U₀=3.88 raw + dry-line → 2.34 + # matches worksheet, NOT 1.7 + dry-line → 1.32). if ( wall_thickness_mm is not None and band in _STONE_AGE_A_TO_E @@ -467,7 +525,17 @@ def u_wall( 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) + # Round to 2 d.p. — worksheet (29a) A×U product uses + # the 2-d.p.-displayed U (cf. 000565 Main alt_wall_1: + # 23 × 2.34 = 53.82 with U=2.34, not raw 2.3405). + u_unrounded = 1.0 / (1.0 / u0 + _DRY_LINING_RESISTANCE_M2K_PER_W) + return float( + Decimal(str(u_unrounded)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + ) + if u0 >= 1.7: + return 1.7 # Table-6 row cap per footnote (a) return u0 known_types = { WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY, @@ -477,6 +545,36 @@ def u_wall( wall_type = construction else: wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY) + # RdSAP 10 §5.7 Table 13 + §5.8 (PDF p.41-42) — uninsulated solid + # brick wall U₀ by lodged wall thickness, then add §5.8 insulation + # adjustment U = 1/(1/U₀ + R) where R comes from Table 14. Fires + # only with the cert's documentary-evidence lodging: + # - construction is solid brick (or stone — §5.6 path below) + # - age band A-E (per the §5.6/§5.7/§5.8 explicit scope) + # - wall thickness measured + # - insulation type is External (1) or Internal (3) with a + # lodged thickness > 0 + # λ defaults to 0.04 W/m·K (typical mineral wool / EPS) per §5.8 + # final note. Cert 000565 BP[0] Main: solid brick 300 mm + 75 mm + # external @ λ=0.04 → U₀=1.4 + R=2.125 → U=0.35 (matches ws). + if ( + wall_type == WALL_SOLID_BRICK + and band in _STONE_AGE_A_TO_E + and wall_thickness_mm is not None + and wall_insulation_type in ( + _WALL_INSULATION_EXTERNAL, _WALL_INSULATION_INTERNAL, + ) + and insulation_thickness_mm is not None + and insulation_thickness_mm > 0 + ): + u0 = _u_brick_thin_wall_age_a_to_e(wall_thickness_mm) + r_ins = _r_insulation_table_14( + insulation_thickness_mm, _WALL_INSULATION_LAMBDA_W_PER_MK, + ) + u_unrounded = 1.0 / (1.0 / u0 + r_ins) + return float( + Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) if wall_type == WALL_CAVITY and wall_insulation_type in ( WALL_INSULATION_CAVITY_PLUS_EXTERNAL, WALL_INSULATION_CAVITY_PLUS_INTERNAL,