Slice S0380.32: route bare \"Extension\" window location to BP[1] per RdSAP10 §3 — closes cert 9380 +0.027 residual

RdSAP10 §3 p.17:
    "When specifying windows and doors, for each building part
     assessor allocates windows and doors to the corresponding
     wall (the appropriate main wall or each alternative wall).
     For each building part, software will deduct window/door
     areas contained in the relevant wall areas."

SAP 10.2 §3 p.16:
    "Wall area is the net area of walls after subtracting the
     area of windows and doors."

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. `_window_bp_index` previously
matched "main" / "1st"-"4th" prefixes but fell through bare
"Extension" to BP[0] (main), causing the cascade to deduct ext1
windows from the main wall:
    Worksheet (29a): main 60.60 × 0.70 + ext1 18.25 × 0.53 = 52.0925
    Pre-fix cascade: main 59.01 × 0.70 + ext1 19.84 × 0.53 = 51.8222
                     Δ -0.27 W/K → SAP +0.027

This slice adds bare "extension" (when num_parts >= 2) as a sibling
to the ordinal-prefix matches. Closes cert 9380 +0.027 → -4.8e-6.

Cohort-2 distribution after S0380.31 + S0380.32:
    34 exact + 4 ≤0.07 (was 33 exact + 5 ≤0.07).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 14:59:37 +00:00
parent 86226ebdb6
commit 396907f46a
2 changed files with 50 additions and 0 deletions

View file

@ -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

View file

@ -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