diff --git a/backend/documents_parser/tests/fixtures/pashub_accuracy/507639151845.pdf b/backend/documents_parser/tests/fixtures/pashub_accuracy/507639151845.pdf index 2c6a8a218..b72df83c9 100644 Binary files a/backend/documents_parser/tests/fixtures/pashub_accuracy/507639151845.pdf and b/backend/documents_parser/tests/fixtures/pashub_accuracy/507639151845.pdf differ diff --git a/backend/documents_parser/tests/fixtures/pashub_accuracy/manifest.json b/backend/documents_parser/tests/fixtures/pashub_accuracy/manifest.json index 7db88f559..59b7c6c86 100644 --- a/backend/documents_parser/tests/fixtures/pashub_accuracy/manifest.json +++ b/backend/documents_parser/tests/fixtures/pashub_accuracy/manifest.json @@ -1091,8 +1091,8 @@ { "deal_id": "507639151845", "uprn": null, - "pre_sap_raw": "C80", - "pre_sap_score": 80, + "pre_sap_raw": "C76", + "pre_sap_score": 76, "pdf": "507639151845.pdf" }, { diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy.py b/backend/documents_parser/tests/test_pashub_sap_accuracy.py index 66524f6bd..bdc027c74 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 " @@ -307,3 +321,48 @@ def test_pashub_sap_accuracy_aggregate(capsys: pytest.CaptureFixture[str]) -> No assert sap_mae <= _MAX_SAP_MAE, ( f"SAP MAE {sap_mae:.3f} exceeded ceiling {_MAX_SAP_MAE}" ) + + +@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy fixtures/manifest") +def test_walsden_pins_to_pashub_resurvey_sap_64() -> None: + """13 Walsden Street (deal 499630239947) has TWO distinct gov-EPC assessments + for the same UPRN: the PAS Hub site-note re-survey we parse (inspected + 26 May 2026, accredited SAP **64**, band D, backed by `rd_sap_site_note` + files) and a SEPARATE later lodged cert (24 Jun 2026, SAP 68, registered, no + site-note). The portfolio-838 baseline latched onto the 24-Jun SAP-68 cert, + but our engine models the survey it was GIVEN and reproduces that survey's own + accredited 64 (~64.19). Confirmed against pashub (Khalim) and corroborated by + the manifest `pre_sap` D64 + hubspot D64; see the 2026-07-15e provenance + investigation in the campaign memory. The manifest is correctly 64, but the + fixture-builder can revert manifest values to a colliding DB row — so this pins + 64 explicitly. Do NOT "correct" this target to 68: that is a different + assessment with no site-note backing, and 13 Walsden is a known + re-survey-diverges outlier, not an engine under-rate.""" + outcome = _evaluate("499630239947") + assert outcome.sap_continuous is not None + assert abs(outcome.sap_continuous - 64) < 0.5, ( + f"13 Walsden SAP {outcome.sap_continuous:.2f} diverged from the PAS Hub " + "re-survey's own accredited 64 (NOT the colliding 24-Jun cert's 68)" + ) + + +@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy fixtures/manifest") +def test_paxton_fixture_is_v5_resurvey_sap_76() -> None: + """5 Paxton Place (deal 507639151845, uprn 10070864419) was RE-ISSUED: the + same PAS Hub survey (inspected 29 May/Jun 2026, report B74AA5A4-…) has two + accredited revisions — V2 (ventilation "Natural", SAP 80) and V5 (ventilation + "Mechanical Extract - Centralised", SAP 76, the current version). The + portfolio-838 oracle tracks the latest revision (76), so the fixture is + refreshed to the V5 PDF (2026-07-15e); our engine reproduces its accredited 76 + (~75.6). Do NOT revert this fixture/manifest to the V2 SAP-80 version. + CAVEAT: `hubspot_deal_data.pre_sap` for this deal is still stale at "C80", so a + `scripts/build_pashub_accuracy_fixtures.py` rerun would regenerate the manifest + `pre_sap` back to 80 — this pin (asserting a literal 76 against the V5 fixture) + is the canary. Confirmed against pashub: April survey → SAP 76 (Khalim).""" + outcome = _evaluate("507639151845") + assert outcome.sap_continuous is not None + assert abs(outcome.sap_continuous - 76) < 0.5, ( + f"5 Paxton SAP {outcome.sap_continuous:.2f} diverged from the current V5 " + "(MEV) re-survey's accredited 76 — has the fixture reverted to the V2 " + "(Natural, SAP 80) PDF?" + ) 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