From efa10fe6cb7d252407828968dc8c2d3773859161 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 11:58:14 +0000 Subject: [PATCH] S0380.239: system-build walls take masonry structural infiltration (0.35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §2 (Ventilation, "Walls" row): "Structural infiltration: 0.25 for steel or timber frame or 0.35 for masonry construction ... System build: treated as masonry." `_is_timber_or_steel_frame` wrongly included wall_construction code 6 (system build) alongside code 5 (timber frame), handing system-build dwellings the 0.25 structural ACH instead of 0.35. On the cat-10 room-heater fixture (ref 001431, walls SY System Build → code 6) this under-stated the infiltration rate (18) by exactly 0.10 (0.45 vs worksheet 0.55), dropping the effective air change (25), the ventilation heat loss (38)m = 0.33 × (25)m × (5), and the heat-transfer coefficient (39) — so space-heating demand (98) came out 404 kWh low ((211) 11158.6 vs worksheet 11563.2). Restrict the 0.25 branch to code 5 only; code 6 (and everything else) is masonry at 0.35. Pins the rating-block (38)m ventilation heat loss mean = 83.3613 W/K at abs 1e-4 and asserts the classifier treats the system-build wall as masonry. §4 suite green (2415 passed, 1 skipped); no existing fixture relied on system-build → 0.25. Residual after this slice: SAP +0.03 / cost -£0.95 — a small fabric (33) gap (-0.15 W/K) plus lighting (232) +1.0 kWh remain as separate causes. Co-Authored-By: Claude Opus 4.8 --- .../test_room_heater_worksheet_001431.py | 50 +++++++++++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 14 ++++-- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/backend/documents_parser/tests/test_room_heater_worksheet_001431.py b/backend/documents_parser/tests/test_room_heater_worksheet_001431.py index e7892678..452bebc0 100644 --- a/backend/documents_parser/tests/test_room_heater_worksheet_001431.py +++ b/backend/documents_parser/tests/test_room_heater_worksheet_001431.py @@ -31,6 +31,7 @@ from datatypes.epc.domain.mapper import EpcPropertyDataMapper from domain.sap10_calculator.calculator import calculate_sap_from_inputs from domain.sap10_calculator.rdsap.cert_to_inputs import ( SAP_10_2_SPEC_PRICES, + _is_timber_or_steel_frame, # pyright: ignore[reportPrivateUsage] cert_to_inputs, ) @@ -44,6 +45,12 @@ _FIXTURE_DIR = ( # heater is electric (efficiency (216) = 100 %), so (219) == (64) output. _WORKSHEET_LINE_219_WATER_FUEL_KWH = 1770.2313 +# P960 line ref (38)m "Ventilation heat loss calculated monthly" — rating +# block, mean of the 12 printed monthly values +# (90.1949 .. 86.1692) / 12. The dwelling is SY System Build (masonry per +# RdSAP 10 §2), so the structural infiltration (11) = 0.35 not 0.25. +_WORKSHEET_LINE_38_VENT_HEAT_LOSS_MEAN_W_PER_K = 83.3613 + _ABS_TOLERANCE = 0.0001 @@ -99,3 +106,46 @@ def test_electric_room_heater_water_fuel_matches_worksheet_line_219() -> None: # Assert assert abs(actual_water_fuel_kwh - expected_water_fuel_kwh) <= _ABS_TOLERANCE + + +def test_system_build_wall_is_classified_masonry_for_structural_infiltration() -> None: + # Arrange — the dwelling's walls are SY System Build (wall_construction + # code 6). Per RdSAP 10 §2 (Ventilation, "Walls" row): "System build: + # treated as masonry", so it must NOT take the 0.25 steel/timber-frame + # structural infiltration — only code 5 (timber frame) does. + summary_pdf = next(_FIXTURE_DIR.glob("Summary_*.pdf")) + pages = _summary_pdf_to_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + main_wall_construction_code = epc.sap_building_parts[0].wall_construction + + # Act + is_frame = _is_timber_or_steel_frame(epc.sap_building_parts) + + # Assert + assert main_wall_construction_code == 6 + assert is_frame is False + + +def test_electric_room_heater_ventilation_heat_loss_matches_worksheet_line_38() -> None: + # Arrange — with SY System Build treated as masonry the structural + # infiltration (11) = 0.35, lifting the effective air change (25) and + # the monthly ventilation heat loss (38)m = 0.33 × (25)m × (5) to the + # worksheet's rating-block values. + summary_pdf = next(_FIXTURE_DIR.glob("Summary_*.pdf")) + pages = _summary_pdf_to_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + expected_vent_heat_loss_w_per_k = _WORKSHEET_LINE_38_VENT_HEAT_LOSS_MEAN_W_PER_K + + # Act + rating = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES), + ) + actual_vent_heat_loss_w_per_k = rating.intermediate["infiltration_w_per_k"] + + # Assert + assert ( + abs(actual_vent_heat_loss_w_per_k - expected_vent_heat_loss_w_per_k) + <= _ABS_TOLERANCE + ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index a0b833a7..5faa40aa 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1393,13 +1393,19 @@ def _climate_source( def _is_timber_or_steel_frame(parts: list[SapBuildingPart]) -> bool: - """RdSAP 10 §5: wall_construction codes 5 (timber frame) and 6 (system - build steel frame) get the lower 0.25 structural ACH; everything else - is treated as 0.35 masonry.""" + """RdSAP 10 §2 (Ventilation, "Walls" row): "Structural infiltration: + 0.25 for steel or timber frame or 0.35 for masonry construction ... + System build: treated as masonry." So only wall_construction code 5 + (timber frame) takes the lower 0.25 structural ACH; code 6 (system + build) is explicitly masonry (0.35), as is everything else. + + (Park homes also take the timber-frame value per the same spec row, + but that is a dwelling-type flag, not a wall_construction code, and is + out of scope here.)""" if not parts: return False wc = parts[0].wall_construction - return isinstance(wc, int) and wc in (5, 6) + return isinstance(wc, int) and wc == 5 def _living_area_fraction_default(habitable_rooms_count: Optional[int]) -> float: