Slice S0380.31: deduct alt-wall window opening from (31) net external area — closes cert 2636 cantilever residual -0.015 → -2.4e-6

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 14:10:11 +00:00 committed by Jun-te Kim
parent 8c269dbe2d
commit 28e5265df2
3 changed files with 71 additions and 6 deletions

View file

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

View file

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

View file

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