From 86226ebdb66fc39ac79ff36c9afbe3dfc579ea01 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 14:10:11 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.31:=20deduct=20alt-wall=20window?= =?UTF-8?q?=20opening=20from=20(31)=20net=20external=20area=20=E2=80=94=20?= =?UTF-8?q?closes=20cert=202636=20cantilever=20residual=20-0.015=20?= =?UTF-8?q?=E2=86=92=20-2.4e-6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix K eqn (K2) p.84: HTB = y × Σ(Aexp) where Aexp is "the total area of external elements calculated at worksheet (31)". The worksheet (31) column header reads "Total NET area of external elements" — net of openings. Cert 2636 (dr87-0001-000898 line 187): (31) = 160.33 m² = 47.70 main net + 11.57 alt net + 42.92 roof + 39.18 ground floor + 3.74 cantilever + 11.52 windows + 3.70 doors. Pre-fix cascade summed the alt-wall at its 12.76 m² gross (no opening deduction) — (31) was 161.52, driving (36) to 24.228 vs worksheet 24.0495 (Δ +0.1785 W/K). That drift propagated through (39) HTC → MIT → space heating, leaving cert 2636 at Δ -0.015 SAP — the only ASHP cohort cert above the 1e-4 floor. `alt_walls_total_area` aggregates per-alt-wall gross at line 736; this slice subtracts `alt_window_area` from it in the (31) sum so the alt-wall contribution is net, matching the (29a) net-area convention already applied per-element to the A×U sums. Cohort-1 ASHP cohort: 9/9 certs < 1e-4 Summary path (was 8/9 with cert 2636 at -0.015). Cert 2636 API path also closes to < 1e-4 — the bug was path-symmetric in the cascade, not in either mapper. Cohort-2 unchanged at 33 exact + 5 ≤0.07. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 53 +++++++++++++++++++ .../rdsap/tests/test_golden_fixtures.py | 10 ++-- .../worksheet/heat_transmission.py | 14 ++++- 3 files changed, 71 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 fa0671f4..b4d7fced 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -936,6 +936,39 @@ def test_summary_2636_full_chain_sap_within_spec_floor_of_worksheet() -> None: assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < _ASHP_COHORT_CHAIN_TOLERANCE +def test_summary_2636_thermal_bridging_excludes_alt_wall_window_opening_per_sap_10_2_appendix_k() -> None: + # Arrange — cert 2636 has BP0 with an alt-wall (gross 12.76 m²) + # carrying one 1.19 m² alt-wall window (`window_wall_type=2`). + # + # SAP 10.2 Appendix K eqn (K2) p.84: HTB = y × Σ(Aexp), where + # Aexp is "the total area of external elements calculated at + # worksheet (31)". Worksheet line 187 (cert 2636 dr87-0001-000898) + # labels (31) "Total NET area of external elements" — net of + # openings. Cert 2636 worksheet (31) = 160.33 m² = 47.70 main net + # + 11.57 alt net + 42.92 roof + 39.18 ground floor + 3.74 + # cantilever + 11.52 windows + 3.70 doors. + # + # Pre-S0380.31 the cascade summed the alt-wall at its 12.76 m² + # gross (no opening deduction) — (31) was 161.52 → (36) = 24.228, + # worksheet (36) = 24.0495, Δ +0.1785 W/K. That drift propagated + # through (39) HTC → MIT → space heating, leaving the cert at + # Δ -0.015 SAP — the only ASHP cohort cert above the 1e-4 floor. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000898_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + + # Assert — worksheet (36) = 24.0495 W/K to 4 d.p.; full SAP + # cascade lands within the 1e-4 spec-precision floor of the + # worksheet's 86.2641. + assert abs(result.intermediate["thermal_bridging_w_per_k"] - 24.0495) <= 1e-4 + assert abs(result.sap_score_continuous - 86.2641) <= 1e-4 + + def test_summary_mapper_raises_on_unmapped_cylinder_size_label() -> None: # Arrange — start from a real cohort cert (any extracted site # notes) and inject an unmapped §15.1 "Cylinder Size" label @@ -1498,6 +1531,26 @@ def test_api_2636_cantilever_floor_surfaces_as_exposed_floor() -> None: ) +def test_api_2636_thermal_bridging_excludes_alt_wall_window_opening_per_sap_10_2_appendix_k() -> None: + # Arrange — API-path mirror of the Summary-path (31) NET pin. + # The Summary EPC and API EPC for cert 2636 produce identical + # cascade output once the alt-wall window opening is deducted + # from (31) per SAP 10.2 Appendix K eqn (K2) p.84. Worksheet (36) + # = 24.0495 W/K, worksheet "SAP value" 86.2641 — cascade closes + # to the 1e-4 spec-precision floor on the API path too. + doc = json.loads(_API_2636_JSON.read_text()) + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + + # Assert + assert abs(result.intermediate["thermal_bridging_w_per_k"] - 24.0495) <= 1e-4 + assert abs(result.sap_score_continuous - 86.2641) <= 1e-4 + + def test_api_2636_alt_wall_openings_deducted_from_alt_not_main() -> None: # Arrange — cert 2636 has BP0 with `sap_alternative_wall_1` # (area 12.76 m², cavity unfilled at age D → U=0.70) and 7 diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 90705e70..a776e09f 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -298,15 +298,15 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2636-0525-2600-0401-2296", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-9.5780, + expected_pe_resid_kwh_per_m2=-9.6497, expected_co2_resid_tonnes_per_yr=+0.2200, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " "PV + 3.74 m² cantilever exposed floor + 12.76 m² alt wall. " - "Worksheet SAP 86.2641. Slice 102f-prep.9 (cantilever) and " - "102f-prep.10 (alt-wall opening allocation per " - "window_wall_type) brought cascade walls to spec-exact " - "20.024 and SAP from +0.49 → +0.03." + "Worksheet SAP 86.2641. Slice S0380.31 deducted the alt-wall " + "window opening (1.19 m²) from (31) total external area per " + "SAP 10.2 Appendix K eqn K2 — closed the SAP residual from " + "-0.015 → -2.4e-6 and shifted PE -9.578 → -9.650." ), ), _GoldenExpectation( diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 0950648a..e232e149 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -847,8 +847,20 @@ def heat_transmission_from_cert( # alongside walls + roof + floor + openings. Cantilever contributes # its area to (31) too (worksheet cert 2636 line 31 = 160.33 # includes the 3.74 m² (28b) cantilever). + # + # SAP 10.2 Appendix K eqn (K2) p.84: HTB = y × Σ(Aexp), where + # Aexp is "the total area of external elements calculated at + # worksheet (31)". The worksheet (31) column header reads + # "Total NET area of external elements" — net of openings. + # `alt_walls_total_area` aggregates per-alt-wall gross areas + # (line 736); the alt-wall window opening (`alt_window_area`) + # must be deducted here so the alt-wall contribution to (31) + # is net, matching the worksheet net-area convention (cert + # 2636: alt-wall gross 12.76 − alt-window 1.19 = 11.57 net). part_external_area = ( - main_wall_area + alt_walls_total_area + roof_area + floor_area_total + main_wall_area + + (alt_walls_total_area - alt_window_area) + + roof_area + floor_area_total + w_area + d_area + rw_area_part + rr_a_rr + rr_detailed_area + cantilever_area )