From 4c0ccad8821321fe655e3861a496d8e3f1f21dfd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 15 Jul 2026 21:52:13 +0000 Subject: [PATCH] =?UTF-8?q?PasHub=20SAP=20accuracy:=20semi-exposed-unheate?= =?UTF-8?q?d=20floor=20=E2=86=92=20Table=2020,=20not=20=C2=A75.14=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_PASHUB_FLOOR_EXPOSURE` mapped the surveyed "Semi Exposed (unheated)" floor label to `is_above_partially_heated_space` (RdSAP §5.14, constant U=0.7). A floor semi-exposed to an enclosed UNHEATED space belongs in §5.13 Table 20 `u_exposed_floor` (band-B as-built U=1.20). §5.14's U=0.7 is reserved for a floor above a *partially* heated space. #1602 transposed the two branches; the too-low U under-counted floor loss on the 9 label-carrying cohort fixtures → a shared over-credit. Triangulated against all three siblings + the calculator docstring: the gov-API code-2 "To unheated space" (mapper.py:4797) and Elmhurst `_is_floor_exposed_to_unheated_space` (mapper.py:5970) both route to Table 20; rdsap_uvalues.py:1416 documents Table 20 as collapsing exposed and semi-exposed-to-unheated into one lookup. One-line data flip, from_site_notes-only (gov-API RdSAP corpus untouched, still 78.9%). Adjacent Ronald St mid-terrace pair (the clustered signal): 10 Ronald 60.35→59.02 (oracle 59), 12 Ronald 60.79→59.36 (oracle 59). Cohort vs DB oracle (portfolio 838 effective_sap) 83.3%→86.9% within-0.5, MAE 0.377→0.348, no built form regresses, all 7 verified dwellings unchanged (none carry the label). pashub harness 79.5%→82.4%, MAE 0.389→0.367; ratchets tightened 0.78→0.82 / 0.40→0.37. pyright zero-new. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/test_pashub_sap_accuracy.py | 18 +++++++++-- datatypes/epc/domain/mapper.py | 13 ++++++-- .../epc/domain/tests/test_from_site_notes.py | 30 ++++++++++++------- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy.py b/backend/documents_parser/tests/test_pashub_sap_accuracy.py index 66524f6bd..bac2bafd0 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy.py @@ -115,7 +115,19 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # built form near zero. Observed 79.5% here; vs the DB SAP-10.2 oracle # (property_baseline_performance.effective_sap_score, portfolio 838) the cohort # moves 62.1% -> 83.3% within-0.5. gov-API RdSAP corpus unchanged (78.9%). -_MIN_WITHIN_HALF: float = 0.78 +# Ratcheted 0.78 -> 0.82 by the 2026-07-15e SEMI-EXPOSED-FLOOR fix: from_site_notes +# mis-routed the surveyed "Semi Exposed (unheated)" floor label to RdSAP §5.14 +# (partially-heated constant U=0.7) when a floor semi-exposed to an enclosed +# UNHEATED space belongs in §5.13 Table 20 `u_exposed_floor` (band B as-built +# U=1.20) — the gov-API code-2 "To unheated space" and Elmhurst +# `_is_floor_exposed_to_unheated_space` siblings both route it to Table 20. The +# too-low U under-counted floor loss on the 9 label-carrying cohort fixtures (the +# adjacent Ronald St mid-terrace pair among them) → a shared over-credit. A pure +# data flip in `_PASHUB_FLOOR_EXPOSURE`, from_site_notes-only (gov-API corpus +# untouched, still 78.9%). Observed 82.4% here; vs the DB SAP-10.2 oracle the +# cohort moves 83.3% -> 86.9% within-0.5, no built form regresses, all 7 verified +# dwellings unchanged (none carry the label). +_MIN_WITHIN_HALF: float = 0.82 # Ratcheted 2.71 -> 2.64 by #1590 bug 2 (roof "Insulation At: None" -> explicit # zero thickness): observed MAE 2.632 — the two ~7-SAP roof over-raters # (507665533138 / 507670893756) correcting is exactly 14/205 ≈ 0.07 of MAE. @@ -187,7 +199,9 @@ _MIN_WITHIN_HALF: float = 0.78 # (percent_draughtproofed + upper-floor +0.25 m + built-form sheltered_sides; # #1601 upper-floor RE-ADJUDICATED, see the within-0.5 note above): observed # MAE 0.389. -_MAX_SAP_MAE: float = 0.40 +# Ratcheted 0.40 -> 0.37 by the 2026-07-15e semi-exposed-floor fix (see the +# within-0.5 note above): observed MAE 0.367. +_MAX_SAP_MAE: float = 0.37 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating " diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3998b57eb..db43f6069 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6191,9 +6191,16 @@ _PASHUB_FLOOR_EXPOSURE: Final[Dict[str, tuple[bool, bool]]] = { # "Exposed Floor" → open to external air / over enclosed unheated space → # RdSAP 10 §5.13 Table 20 `u_exposed_floor` cascade (is_exposed_floor). "Exposed Floor": (True, False), - # "Semi Exposed (unheated)" → above a space heated to a lesser extent → - # RdSAP 10 §5.14 constant U=0.7 (is_above_partially_heated_space). - "Semi Exposed (unheated)": (False, True), + # "Semi Exposed (unheated)" → floor semi-exposed to an enclosed UNHEATED + # space → RdSAP 10 §5.13 Table 20 `u_exposed_floor` (is_exposed_floor), + # NOT §5.14. Table 20 "collapses exposed (to outside air) and semi-exposed + # (to enclosed unheated space) into the same lookup" (rdsap_uvalues.py:1416; + # band B as-built U=1.20). Mirrors the gov-API code-2 "To unheated space" + # (semi-exposed → Table 20, mapper.py:4797) and the Elmhurst + # `_is_floor_exposed_to_unheated_space` sibling (mapper.py:5970). §5.14's + # constant U=0.7 is reserved for a floor above a *partially* heated space + # (`is_above_partially_heated_space`); the #1602 routing transposed the two. + "Semi Exposed (unheated)": (True, False), } diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 5a21a6db5..d5dc35ffc 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -508,12 +508,16 @@ class TestWallDryLinedMapping: class TestFloorExposureMapping: """A surveyed "Floor type" of "Semi Exposed (unheated)" or "Exposed Floor" must set the SapFloorDimension exposure flags on the part's LOWEST storey - (floor==0), so the floor-U cascade routes through RdSAP §5.14 (U=0.7, - above partially heated space) / §5.13 Table 20 (u_exposed_floor) instead of - the default BS EN ISO 13370 ground-floor formula. `_map_floor_dimensions` - previously never set them — the exposure was silently dropped and every - semi-exposed/exposed floor billed as an on-soil ground floor (#1602). The - gov-API/Elmhurst siblings already flag the ground SapFloorDimension.""" + (floor==0), so the floor-U cascade routes through RdSAP §5.13 Table 20 + (`u_exposed_floor`) — which covers BOTH a floor exposed to outside air AND + one semi-exposed to an enclosed unheated space — instead of the default + BS EN ISO 13370 ground-floor formula. `_map_floor_dimensions` previously + never set them — the exposure was silently dropped and every semi-exposed/ + exposed floor billed as an on-soil ground floor (#1602). §5.14's constant + U=0.7 is reserved for a floor above a *partially* heated space, which the + PAS Hub survey does not lodge; #1602 mis-routed "Semi Exposed (unheated)" + there and this corrects it to Table 20. The gov-API/Elmhurst siblings + already flag the ground SapFloorDimension to Table 20.""" @staticmethod def _ground(part: SapBuildingPart) -> SapFloorDimension: @@ -523,7 +527,13 @@ class TestFloorExposureMapping: def _upper(part: SapBuildingPart) -> SapFloorDimension: return next(fd for fd in part.sap_floor_dimensions if fd.floor == 1) - def test_semi_exposed_sets_above_partially_heated_on_ground_floor(self) -> None: + def test_semi_exposed_unheated_sets_exposed_floor_on_ground_floor(self) -> None: + # "Semi Exposed (unheated)" sits above an enclosed UNHEATED space → + # RdSAP 10 §5.13 Table 20 `u_exposed_floor` (is_exposed_floor), NOT + # §5.14's constant U=0.7 (reserved for a *partially* heated space + # below). Mirrors the gov-API code-2 "To unheated space" and the + # Elmhurst `_is_floor_exposed_to_unheated_space` siblings; corrects + # the #1602 routing that transposed the two branches. # Arrange data = load("pashub_rdsap_site_notes_example1.json") data["building_construction"]["floor"]["floor_type"] = "Semi Exposed (unheated)" @@ -532,10 +542,10 @@ class TestFloorExposureMapping: # Act result = EpcPropertyDataMapper.from_site_notes(survey) - # Assert — §5.14 flag on the ground floor only, exposed flag clear. + # Assert — Table 20 exposed flag on the ground floor only. ground = self._ground(result.sap_building_parts[0]) - assert ground.is_above_partially_heated_space is True - assert ground.is_exposed_floor is False + assert ground.is_exposed_floor is True + assert ground.is_above_partially_heated_space is False def test_exposed_floor_sets_exposed_on_ground_floor(self) -> None: # Arrange