diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index e232e149..eedb95c2 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -212,6 +212,17 @@ def _window_bp_index(window_location: Any, num_parts: int) -> int: for prefix, idx in (("1st", 1), ("2nd", 2), ("3rd", 3), ("4th", 4)): if s.startswith(prefix): return idx if idx < num_parts else 0 + # Bare "Extension" — PDF wrap artifact: cert 9380's Summary lodges + # "1st Extension" but pdftotext wraps "1st" onto a preceding layout + # line, so the extractor only captures "Extension". RdSAP10 §3 p.17 + # requires window/door areas to deduct from the building part the + # opening pierces ("for each building part, software will deduct + # window/door areas contained in the relevant wall areas"); without + # this route the ext1 windows deduct from main, over-counting the + # main wall's contribution and under-counting the ext1's by the + # U-value difference × opening area. + if s == "extension" and num_parts >= 2: + return 1 return 0 diff --git a/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py b/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py index b26cd227..d0da32a4 100644 --- a/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py +++ b/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py @@ -35,6 +35,9 @@ from domain.sap10_calculator.worksheet.heat_transmission import ( HeatTransmission, heat_transmission_from_cert, ) +from domain.sap10_calculator.worksheet.heat_transmission import ( + _window_bp_index, # pyright: ignore[reportPrivateUsage] +) def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() -> None: @@ -593,6 +596,42 @@ def test_walls_w_per_k_uses_sum_of_per_storey_perimeter_times_height_not_ground_ assert result.walls_w_per_k == pytest.approx(24.0, abs=0.5) +def test_window_bp_index_routes_bare_extension_to_first_extension_per_rdsap10_section_3() -> None: + # Arrange — RdSAP10 §3 p.17: "for each building part, software will + # deduct window/door areas contained in the relevant wall areas". + # Cert 9380's Summary PDF lodges 2 windows on its single extension, + # but pdftotext wraps "1st" onto a preceding layout line while + # "Extension" lands on a separate line — the Elmhurst extractor + # captures only the second token ("Extension") and the dwelling has + # exactly 2 building parts (Main + Ext1). Without this route the + # extension windows deduct from the main wall and the U-weighted + # net-area sum drifts by the U-difference × opening area (cert + # 9380: 1.59 × (0.70 − 0.53) = 0.27 W/K on (29a)). + + # Act + bare_extension_idx = _window_bp_index("Extension", num_parts=2) + ordinal_idx = _window_bp_index("1st Extension", num_parts=2) + main_idx = _window_bp_index("Main", num_parts=2) + + # Assert — bare "Extension" routes to BP[1] just like "1st Extension". + assert bare_extension_idx == 1 + assert ordinal_idx == 1 + assert main_idx == 0 + + +def test_window_bp_index_bare_extension_with_only_main_bp_falls_back_to_main() -> None: + # Arrange — when the dwelling has no extension BP at all, the + # "Extension" string is unresolvable; the function must fall back + # to BP[0] (main) rather than raise. Mirrors the existing ordinal- + # prefix out-of-range behaviour. + + # Act + idx = _window_bp_index("Extension", num_parts=1) + + # Assert + assert idx == 0 + + def test_main_plus_extension_sums_per_element_contributions() -> None: # Arrange — Main + single-storey age L extension. Each contributes to the # element totals. With_extension > main_only on every populated field