From 7f099d986aabdc8b98cc1f8e05bbf79106221afe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 21:47:56 +0000 Subject: [PATCH] Slice S0380.13: widen cantilever gate to accept "House" descriptive form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes cert 2636 to spec floor (Δ +0.5167 → +0.0323) by accepting both the EPC schema enum-as-string ("0") AND the Elmhurst Summary mapper's descriptive form ("House") for the cantilever-detection property-type gate at `heat_transmission.py:768`. Root cause: slice 102f-prep.9 (commit 06b4ef3d) added cantilever detection gated on `epc.property_type == _PROPERTY_TYPE_HOUSE` where `_PROPERTY_TYPE_HOUSE = "0"`. That matches the API mapper's encoding (schema enum), but the Summary mapper produces "House" (descriptive) and the hand-built worksheet fixtures also use "House" — so neither triggers the gate and the cantilever path silently no-ops on the Summary path. Cert 2636's worksheet (28b) "Exposed floor Main 3.74 × 1.20 = 4.4880" is the cantilever — without surfacing it the cascade missed 4.488 W/K of floor heat loss. Three-encoding origins: - API mapper: property_type='0' (schema enum-as-string) - Summary mapper: property_type='House' (descriptive from §1) - Hand-built fixtures: property_type='House' (legacy convention) Fix: replace the equality check with a `_is_house()` helper that accepts the {"0", "House"} frozenset. Centralised so future property-type sensitive gates can call the same helper. Forcing function: cert 2636 first-attempt Summary SAP closes from Δ +0.5167 (after S0380.12 walls fix) to Δ **+0.0323** — within the ±0.07 ASHP-cohort spec floor. `floor_w_per_k` moves from 19.1982 (ground floor only) to 23.6862 (ground 19.20 + cantilever 4.49 = worksheet (28a) + (28b) exact match). Cohort closure status (6 of 7 ASHP certs at spec floor): cert Δ vs worksheet spec floor? 0380 +0.0594 ✓ 0350 +0.0458 ✓ 2225 +0.0441 ✓ 2636 +0.0323 ✓ ← this slice 3800 +0.0442 ✓ 9285 +0.0502 ✓ 9418 +2.5973 ✗ (Daikin EDLQ05CAV3 — final cert) Boiler hand-built parity verified intact: 5 hand-built cohort certs (000474, 000477, 000480, 000490, 000516) all use property_type= "House" and now also fire the cantilever gate, but none have floor1_area > floor0_area + 1m² (the cantilever-area trigger) so their cascade output is unchanged. Regression suite 683 pass + 10 fail (= handover baseline 669 + 10 + 17 new GREEN tests across S0380.2..S0380.13). Pyright net-zero on edited files: domain/sap10_calculator/worksheet/heat_transmission.py: 13 (baseline; no new errors) backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0 Spec / precedent refs: - Slice 102f-prep.9 (commit 06b4ef3d) — RdSAP cantilever-exposed- floor detection (originally API-only via `property_type=="0"` gate). - SAP 10.2 Table 20 — U_exposed_floor (age D + no insulation → 1.20 W/m²K, the cohort's cantilever U-value). - Cert 2636 worksheet `dr87-0001-000898.pdf` line refs (28a)+(28b) sum 23.6862 W/K (exact cascade match after this slice). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 27 +++++++++++++++++++ .../worksheet/heat_transmission.py | 23 ++++++++++++---- 2 files changed, 45 insertions(+), 5 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 fa79dabe..a9ad62b0 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -789,6 +789,33 @@ def test_summary_2225_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_full_chain_sap_within_spec_floor_of_worksheet() -> None: + # Arrange — cert 2636-0525-2600-0401-2296 (Summary_000898.pdf): + # Mitsubishi PUZ-WM50VHA, mid-terrace house with **alt-wall + + # cantilever** — the most complex geometry in the ASHP cohort. + # Worksheet "SAP value" lodges 86.2641. + # + # Closed by two combined slices: + # - S0380.12: alt-wall window-location parser fix (walls W/K + # 20.5595 → 20.0240 = worksheet exact). + # - S0380.13: cantilever gate accepts "House" descriptive form + # in addition to the schema enum "0" (allowing the Summary + # mapper's descriptive property_type to trigger the cantilever + # detection that slice 102f-prep.9 added on the API path). + 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 — ±0.07 ASHP-cohort spec-floor tolerance. + worksheet_unrounded_sap = 86.2641 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < _ASHP_COHORT_CHAIN_TOLERANCE + + def test_summary_3800_full_chain_sap_within_spec_floor_of_worksheet() -> None: # Arrange — cert 3800-8515-0922-3398-3563 (Summary_000901.pdf / # dr87-0001-000901.pdf) is the third ASHP cohort cert to close on diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 82077dbb..0baf31a4 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -109,10 +109,23 @@ _COS_30_DEG: Final[float] = cos(radians(30.0)) # BP0: 195%), neither of which the worksheet treats as cantilever. _CANTILEVER_MIN_AREA_M2: Final[float] = 1.0 _CANTILEVER_MAX_RATIO: Final[float] = 0.25 -# EPC API `property_type` strings that flag a dwelling as a house (not -# flat). Cantilever detection only fires for houses — flats with very -# small floor=0 areas (stairwell access) would otherwise over-count. -_PROPERTY_TYPE_HOUSE: Final[str] = "0" +# `property_type` values that flag a dwelling as a house (not flat). +# Cantilever detection only fires for houses — flats with very small +# floor=0 areas (stairwell access) would otherwise over-count. +# The API mapper produces the EPC schema enum-as-string ("0"), the +# Elmhurst Summary mapper produces the descriptive form ("House"), and +# the hand-built worksheet fixtures use the descriptive form too. The +# canonical check below accepts both so the cantilever path fires +# uniformly regardless of source-mapper encoding. +_PROPERTY_TYPES_HOUSE: Final[frozenset[str]] = frozenset({"0", "House"}) + + +def _is_house(property_type: Optional[str]) -> bool: + """True when `epc.property_type` encodes a house (not flat / maisonette + / park home). Tolerant of both the API schema's enum-as-string ("0") + and the Summary mapper / hand-built fixture descriptive form + ("House").""" + return property_type in _PROPERTY_TYPES_HOUSE @dataclass(frozen=True) @@ -765,7 +778,7 @@ def heat_transmission_from_cert( # thermal bridges via its area on (31). cantilever_area = ( _round_half_up(geom.get("cantilever_floor_area_m2", 0.0), _AREA_ROUND_DP) - if epc.property_type == _PROPERTY_TYPE_HOUSE + if _is_house(epc.property_type) else 0.0 ) if cantilever_area > 0: