From c75ef6417fb5bd877e0de0507338d7921c0506f3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 21:57:00 +0000 Subject: [PATCH] =?UTF-8?q?S0380.210:=20cert=200390=20cavity=20"partial=20?= =?UTF-8?q?insulation=20(assumed)"=20=E2=86=92=20as-built=20row,=20not=20f?= =?UTF-8?q?illed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden cert 0390-2954-3640 (detached, TFA 360, age F) carried a +7 SAP / -28 kWh/m² PE residual the audit attributed to a demand-side fabric gap. Walking the §3 cascade localised it to the Main wall: lodged wall_construction=4 (cavity), wall_insulation_type=4 (as-built / assumed), description "Cavity wall, as built, partial insulation (assumed)". The cascade mis-routed it to the Table 6 "Filled cavity" row (band F = 0.40) because `_described_as_insulated` matches the "partial insulation" substring. RdSAP 10 Specification (10-06-2025) Table 6 — Wall U-values, England distinguishes two cavity rows: "Cavity as built" A-E 1.5, F 1.0, G 0.60, H 0.60, I 0.45, J 0.35, ... "Filled cavity" A-E 0.7, F 0.40, G 0.35, H 0.35, I 0.45†, J 0.35†, ... An "as built ... partial insulation (assumed)" cavity is the as-built partial fill of the age band, NOT a retrofit cavity fill (a genuine fill lodges the distinct "Cavity wall, filled cavity", wall_insulation_type=2). It therefore routes to "Cavity as built" (band F = 1.0), mirroring the worksheet-validated solid-brick rule in S0380.209 (cases 9/10: "as built, insulated (assumed)" → as-built age-band row, not retrofit). New `_cavity_described_as_filled` predicate is used only in u_wall's cavity filled-row branch; it excludes the "partial insulation" substring while keeping "insulated (assumed)" → filled (the unrelated, separately asserted test_cavity_as_built_insulated_assumed_uses_filled_cavity_row is unchanged). The shared `_described_as_insulated` (also consumed by the roof/floor paths) is left untouched. Wall HLC +53.6 W/K (U 0.40 → 1.0 over ~268 m²) lifts all four metrics together — the signature of a real fabric bug, not a tuned offset: SAP +7 → +0 PE -27.9745 → +0.5281 kWh/m² CO2 -2.7134 → -0.1189 t/yr Bands I-M are unaffected (the two rows coincide per the † footnote), so golden certs 0535 (band M) / 7536 (band L) with "insulated (assumed)" cavities continue to pin at 0. Full suite 2384 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 --- domain/sap10_ml/rdsap_uvalues.py | 37 ++++++++++++- .../rdsap/test_golden_fixtures.py | 19 +++++-- .../worksheet/test_heat_transmission.py | 52 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 23c36bb3..6a089aa3 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -66,6 +66,41 @@ def _described_as_insulated(description: Optional[str]) -> bool: return "insulated" in desc or "partial insulation" in desc +def _cavity_described_as_filled(description: Optional[str]) -> bool: + """True when an as-built cavity wall's description asserts the cavity is + insulated/filled, routing it to the Table 6 "Filled cavity" row. + + Distinguishes the three as-built cavity states the EPC renders by age + band when wall_insulation_type=4 ("as-built / assumed"): + + - "...insulated (assumed)" → Filled cavity (assessor judges + the cavity filled but lodges no + thickness) + - "...partial insulation (assumed)" → "Cavity as built" row (the + as-built partial fill of the age + band, NOT a retrofit cavity fill) + - "...no insulation (assumed)" → "Cavity as built" row + + Narrower than `_described_as_insulated`: it excludes the "partial + insulation" substring so a "partial insulation (assumed)" cavity stays on + the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F = + 1.0 vs "Filled cavity" band F = 0.40 — for an as-built band-F cavity the + filled row understates heat loss by 2.5x. A genuine retrofit fill is + lodged distinctly as "Cavity wall, filled cavity" + (wall_insulation_type=2), handled by the explicit-code branch. + + Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity + type 4, "partial insulation (assumed)") closes all four SAP metrics on + the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/m². + """ + if description is None: + return False + desc = description.lower() + if "no insulation" in desc: + return False + return "insulated" in desc + + # --------------------------------------------------------------------------- # Country # --------------------------------------------------------------------------- @@ -597,7 +632,7 @@ def u_wall( ) if wall_type == WALL_CAVITY and ( wall_insulation_type == WALL_INSULATION_FILLED_CAVITY - or _described_as_insulated(description) + or _cavity_described_as_filled(description) ): return _CAVITY_FILLED_ENG[age_idx] bucket = _insulation_bucket(insulation_thickness_mm, insulation_present) diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 6093f71f..38f03079 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -236,9 +236,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( _GoldenExpectation( cert_number="0390-2954-3640-2196-4175", actual_sap=60, - expected_sap_resid=+7, - expected_pe_resid_kwh_per_m2=-27.9745, - expected_co2_resid_tonnes_per_yr=-2.7134, + expected_sap_resid=+0, + expected_pe_resid_kwh_per_m2=+0.5281, + expected_co2_resid_tonnes_per_yr=-0.1189, notes=( "Detached, TFA 360, age F, Firebird oil combi PCDF 9005 " "(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + " @@ -269,7 +269,18 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "Slice S0380.151 wired RdSAP 10 §4.1 Table 5 (PDF p.28) " "extract-fans default (age F → 1 fan). Cascade ventilation " "HLC rises ~0.03 ACH × volume; PE -28.0830 → -27.9745 " - "(closer to zero), CO2 -2.7342 → -2.7134." + "(closer to zero), CO2 -2.7342 → -2.7134. " + "Slice S0380.210 CLOSED the residual: the Main cavity wall lodges " + "wall_insulation_type=4 (as-built/assumed) + description " + "'Cavity wall, as built, partial insulation (assumed)'. The " + "cascade mis-routed it to the Table 6 'Filled cavity' row " + "(band F = 0.40) via the 'partial insulation' substring; " + "RdSAP 10 Table 6 (England) routes an as-built partial-fill " + "cavity to the 'Cavity as built' row (band F = 1.0). New " + "`_cavity_described_as_filled` excludes 'partial insulation' " + "(keeping 'insulated (assumed)' → filled). Wall HLC +53.6 W/K " + "(0.40 → 1.0 over 268 m²) lifted all four metrics together: " + "SAP +7 → +0, PE -27.9745 → +0.5281, CO2 -2.7134 → -0.1189." ), ), _GoldenExpectation( diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 1fdfec3b..76edac33 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -307,6 +307,58 @@ def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None: assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0) +def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None: + # Arrange — the EPC renders a cavity wall lodged wall_insulation_type=4 + # (as-built / assumed) with description "Cavity wall, as built, partial + # insulation (assumed)" for age bands where the as-built construction + # carries only partial cavity fill. "Partial insulation" is the as-built + # thermal state of the age band, NOT a retrofit cavity fill — the spec + # routes it to the "Cavity as built" row, not "Filled cavity": + # RdSAP 10 Table 6 (England) "Cavity as built" band F = 1.0 vs + # "Filled cavity" band F = 0.40. A genuine fill renders the distinct + # "Cavity wall, filled cavity" description (wall_insulation_type=2), + # caught separately. Contrast the "insulated (assumed)" variant above, + # which the assessor judges as filled. + # + # Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, + # cavity type 4, "partial insulation (assumed)") closes all four SAP + # metrics (PE/SAP/CO2/cost) on the as-built 1.0 row — at the filled + # 0.40 row its PE under-counts by ~28 kWh/m². + # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey + # → gross_wall = 100 m². walls_w_per_k expected = 1.0 × 100 = 100 W/K. + main = make_building_part( + construction_age_band="F", + wall_construction=4, + wall_insulation_type=4, + party_wall_construction=1, + roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=100.0, room_height_m=2.5, + party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + epc.walls = [ + EnergyElement( + description="Cavity wall, as built, partial insulation (assumed)", + energy_efficiency_rating=3, + environmental_efficiency_rating=3, + ), + ] + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — U=1.0 × 100 m² gross wall = 100 W/K (as-built, not filled 70). + assert abs(result.walls_w_per_k - 100.0) <= 1.0 + + def test_walls_description_measured_transmittance_overrides_construction_cascade() -> None: # Arrange — a full-SAP (not RdSAP) cert lodges the wall U-value # directly in walls[i].description ("Average thermal transmittance