Merge pull request #1617 from Hestia-Homes/fix/pashub-semi-exposed-floor

PasHub SAP accuracy: semi-exposed-unheated floor → Table 20 (+ Walsden/Paxton fixtures)
This commit is contained in:
KhalimCK 2026-07-16 02:01:07 +01:00 committed by GitHub
commit e75144afb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 93 additions and 17 deletions

View file

@ -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"
},
{

View file

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

View file

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

View file

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