mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
commit
e75144afb3
5 changed files with 93 additions and 17 deletions
Binary file not shown.
|
|
@ -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"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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?"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue