mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-08-02 21:08:24 +00:00
Three silent-drop extraction fixes the gov-API/Elmhurst mappers already apply but `from_site_notes` uniquely omitted — each let a favorable default reach the SAP-10.2 calculator and over-rate the dwelling. Same calculator, so the whole gap was extraction fidelity (gov-API corpus 78.6% within-0.5 vs pashub 23.9%). #1598 — electric-shower + bath counts: `_map_sap_heating` built `shower_outlets` but never set `electric_shower_count`/`mixer_shower_count`/`number_baths`, so the cascade defaulted to 1 gas mixer / 0 electric, billing electric-shower hot water off the cheap main system. 100/205 fixtures lodge "Electric Shower". #1599 — floor label: pashub lodges "Ground Floor" (capital F); the calculator's RdSAP §5(12) suspended-timber floor-infiltration gate is case-sensitive on "Ground floor", so the 0.2-ACH term was silently dropped. Normalized at the mapper boundary. 98/205 suspended-timber. #1600 — water-heating "None": the RdSAP 10 §10.7 "no water heating system" signal was discarded and HW inherited the gas combi (58 Hackle 67.7 vs verified 59). Coded to WHC 999 so the existing electric-immersion + Table 28/29 default fires (also folds in #1594: blank cylinder size is no cylinder, not a phantom one). Guinness GMCA 205 cohort: within-0.5 23.9% -> 50.2% (103/205), MAE 1.410 -> 0.650. Ratchets tightened to 0.50 / 0.65. Verified vs Khalim's ground truth (16 Stillwater 78.2 -> 75.7 vs 76). #1601 (upper-floor +0.25 m) held — it empirically over-corrects this cohort (internal-vs-external dimension question, under investigation). pyright: 0 new errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
278 lines
14 KiB
Python
278 lines
14 KiB
Python
"""End-to-end accuracy test for the pashub site-notes extractor.
|
|
|
|
Chain under test (per fixture):
|
|
|
|
pashub RdSAP site-note PDF
|
|
-> parse_site_notes_pdf (extractor + `from_site_notes` mapper)
|
|
-> EpcPropertyData
|
|
-> Sap10Calculator().calculate -> SapResult.sap_score_continuous
|
|
vs pashub's own `pre_sap` rating (from hubspot_deal_data)
|
|
|
|
Fixtures live in `fixtures/pashub_accuracy/` with a `manifest.json`; they are
|
|
built by `scripts/build_pashub_accuracy_fixtures.py` from the Guinness GMCA
|
|
project (see that script for provenance). `pre_sap` (e.g. `"C73"` -> 73) is
|
|
pashub's *own* RdSAP figure, so this gauge conflates two independent error
|
|
sources — our extractor+mapper AND our SAP-10 calculator (which alone lands
|
|
within 0.5 on only ~79% of the RdSAP corpus, see
|
|
`tests/infrastructure/epc_client/test_sap_accuracy_corpus.py`). It is therefore
|
|
a **hybrid** gate:
|
|
|
|
* `test_pashub_fixture_computes` — per fixture: the extractor must produce a
|
|
calculable `EpcPropertyData`. Any *unexpected* exception is a hard fail (a
|
|
real extractor/mapper bug on that property). The one *known* remaining gap —
|
|
the pashub mapper not coding a main-heating `main_heating_control` label to a
|
|
SAP code — is `xfail`ed dynamically (see `_KNOWN_GAPS` for its two forms), so
|
|
these flip to passing once that mapper fix lands. The main-fuel-code gap
|
|
(`Bulk LPG`, and the `Fuel:`-form Mains Gas the extractor was dropping) was
|
|
closed in #1558, so `MissingMainFuelType` is *no longer* swallowed — a
|
|
fixture that regresses to a dropped fuel now hard-fails.
|
|
|
|
* `test_pashub_sap_accuracy_aggregate` — over the fixtures that compute:
|
|
fraction within 0.5 SAP of `pre_sap`, and MAE. Ratcheting floor/ceiling
|
|
(never loosen), mirroring the corpus gauge. While every fixture is blocked
|
|
on the main-heating-control gap the aggregate has no data and `xfail`s; once
|
|
that mapper fix lands, ratchet the constants below to the observed values.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
from backend.documents_parser.parser import parse_site_notes_pdf
|
|
from datatypes.epc.domain.mapper import UnmappedPasHubLabel
|
|
from domain.sap10_calculator.calculator import Sap10Calculator
|
|
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
|
|
|
# Known in-progress pashub main-heating string→SAP-code mapper gaps, fixed
|
|
# field-by-field. The remaining gap is the `main_heating_control` label, which
|
|
# surfaces in one of two forms: `UnmappedSapCode` (raw label reaches the
|
|
# calculator) or, once the control mapper (#1557) lands, `UnmappedPasHubLabel`
|
|
# (strict-raised at the boundary for a control label not yet mapped — e.g. the
|
|
# cohort's community-heating charging control). Both xfail until that fix lands.
|
|
# The main-fuel-code gaps are closed (mains-gas code #1554; "Bulk LPG" + the
|
|
# `Fuel:`-form Mains Gas the extractor was dropping #1558), so `MissingMainFuelType`
|
|
# is deliberately *not* here — a fuel-drop regression hard-fails instead of hiding.
|
|
_KNOWN_GAPS: tuple[type[Exception], ...] = (UnmappedSapCode, UnmappedPasHubLabel)
|
|
|
|
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "pashub_accuracy"
|
|
_MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
|
|
|
|
# --- Ratchets (never loosen at fixed coverage), mirroring test_sap_accuracy_corpus.py.
|
|
# The last integrated state computed 201/205 (4 blocked on residual
|
|
# `UnmappedPasHubLabel` gaps) at SAP within-0.5 = 12.4% (25/201), MAE = 2.560.
|
|
# Closing those last gaps — secondary fuel 'Mains Gas'/'House Coal' and the
|
|
# Table 4e Group 3 heat-network control 'Charging system linked to use of
|
|
# community heating, room thermostat only' — brings the cohort to **205/205
|
|
# computed (0 blocked)**. That is coverage growth, not a loosening: the
|
|
# within-0.5 *count* is unchanged at 25, so no fixture regressed; the fraction
|
|
# is 25/205 = 12.2% only because the 4 newly-computable fixtures all sit outside
|
|
# 0.5. MAE rises 2.560 -> 2.701 because two of those four are large, isolated
|
|
# divergences NOT caused by the mappings (which land the others at d=-1.7/+7.6):
|
|
# - 499584755922 d=-33.0 Bulk-LPG main + house-coal secondary (34.0 vs pre_sap 67)
|
|
# - 507644414148 d=+18.2 community heating — pashub path maps only the control
|
|
# code, not the full heat-network fuel/flags (70.2 vs 52)
|
|
# Both are tracked for verification (pre_sap correctness vs a deeper modelling
|
|
# gap) — see docs/HANDOVER_PASHUB_838_PROBLEM_PROPERTIES.md. Re-baselined to the
|
|
# full-coverage aggregate; ratchet back DOWN as those two are resolved.
|
|
# Ratcheted 0.12 -> 0.18 / 2.55 -> 1.75 by #1590 bug 4 (mechanical ventilation
|
|
# dispatched into the §2 ACH cascade instead of defaulting to NATURAL): 76/205
|
|
# cohort fixtures lodge a mechanical system; observed 18.5% within-0.5,
|
|
# MAE 1.730 — the campaign's largest single move.
|
|
# Ratcheted 0.18 -> 0.19 by the dig-round-3 fixes (see the MAE note below):
|
|
# observed 20.0% (41/205).
|
|
# Ratcheted 0.19 -> 0.23 by the window-orientation fix (see the MAE note
|
|
# below): observed 23.9% (49/205) — the campaign's largest within-0.5 move.
|
|
# Ratcheted 0.23 -> 0.50 by the #1596 extraction bundle (#1598 electric-shower
|
|
# + bath counts never threaded into `sap_heating`; #1599 floor label "Ground
|
|
# Floor" normalized to the calculator's "Ground floor" so the §5(12) suspended-
|
|
# timber infiltration term fires; #1600 water-heating "None" coded to the RdSAP
|
|
# §10.7 WHC 999 default instead of inheriting the gas combi). Observed 50.2%
|
|
# (103/205) — the campaign's largest move. All three are silent-drop fixes the
|
|
# gov-API/Elmhurst paths already apply; verified against Khalim's ground truth
|
|
# (16 Stillwater 78.2 -> 75.7 vs 76; 58 Hackle 67.7 -> ~58 vs 59). #1601 (upper-
|
|
# floor +0.25 m) is HELD — it empirically over-corrects this cohort.
|
|
_MIN_WITHIN_HALF: float = 0.50
|
|
# 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.
|
|
# Ratcheted 2.64 -> 2.58 by #1590 bug 1 (surveyed PV arrays now reach the
|
|
# Appendix M input): observed MAE 2.571 — 507639151843's 13.5-SAP zero-PV-credit
|
|
# under-rate closing.
|
|
# Ratcheted 2.58 -> 2.55 by #1590 bug 3 (PV Connection label -> gov-API int:
|
|
# "Not connected" no longer silently credited): observed MAE 2.538.
|
|
# Ratcheted 1.75 -> 1.70 by the dig-round-2 fixes: RIR surfaces billed with
|
|
# the surveyed roof insulation + stud-wall commons, and community heating
|
|
# lodging Table 4a code 301 + Table 12 fuel 51 (507644414148 computes on the
|
|
# heat-network branch). Observed MAE 1.684.
|
|
# Ratcheted 1.70 -> 1.56 after Khalim manually verified 4 suspect pre_sap
|
|
# values in pashub (2026-07-14): 9 Council Houses CW5 8AP is F33 (manifest had
|
|
# a stale D67 — our 34.0 was right within a point) and Brightholme M11 4WE is
|
|
# the accredited 43 (was pashub's preliminary 53). 58 Hackle Street D58 and
|
|
# 16 Bingley Close E52 were CONFIRMED correct — our +7.8/+5.7 deviations there
|
|
# are extraction bugs to hunt. Observed MAE 1.551.
|
|
# Ratcheted 1.56 -> 1.39 (and within-0.5 0.18 -> 0.19) by the dig-round-3
|
|
# fixes: (a) RIR roof-insulation threading now respects the per-surface
|
|
# "insulation known? No" answers (Brightholme, whose loft hatch was screwed
|
|
# shut, +7.3 -> +0.6); (b) the surveyed "Wall Dry-Lined? Yes" (39/205
|
|
# fixtures) reaches the §5.8/Table 14 wall-U adjustment; (c) the Bingley
|
|
# community residual — cylinder insulation label int-coded so storage loss
|
|
# bills, Table 3 primary loss + RdSAP p.59 flat-rate charging default on the
|
|
# water-only heat-network branch (+5.7 -> +3.2). Observed 20.0% / MAE 1.383.
|
|
# Held at 1.39 (not loosened) after the secondary-heating-type coding fix
|
|
# (raw "Secondary System:" label int-coded to its Table 4a room-heater code
|
|
# instead of silently defaulting to code 693 eff 1.00): correctness fix,
|
|
# 499607224524 +4.0 -> +1.2, observed MAE 1.383 -> 1.381 — too small to
|
|
# tighten the 2-decimal ceiling, within-0.5 unchanged at 20.0%.
|
|
# Ratcheted 1.39 -> 1.37 by the Simplified-RIR common-wall fix: a "Roof room
|
|
# type" (RdSAP §3.9.2) lodgement now bills common walls as `common_wall` at
|
|
# the main-wall U (spec area L x (0.25 + H)) instead of a Detailed `stud_wall`
|
|
# roof surface that dropped to the Table 18 col(4) 2.30 uninsulated default
|
|
# (78/72 North Road, +1.1 SAP each). Observed MAE 1.3703, within-0.5 20.0%.
|
|
# DELIBERATELY LOOSENED 1.371 -> 1.41 by the window-orientation fix (Khalim
|
|
# authorised, 2026-07-15) — the ONE sanctioned exception to "ratchets never
|
|
# loosen". PasHub lodged `orientation` as a raw string ("South East") where
|
|
# the solar-gains cascade only accepts the int octant 1..8, so EVERY window's
|
|
# solar gain was silently zeroed (100% of the cohort, ~1,647 windows). Coding
|
|
# it (`_pashub_orientation_int`) is an unambiguous correctness fix that moves
|
|
# within-0.5 20.0% -> 23.9% (41 -> 49; the campaign's largest within-0.5 move)
|
|
# and within-1 87 -> 98. MAE RISES 1.370 -> 1.405 ONLY because the zeroed
|
|
# gains were masking the known SAP-10.2-engine-vs-lodged-2012-RdSAP offset the
|
|
# module docstring already flags (bias -0.67 -> +1.04) — NOT an extraction
|
|
# regression. The correct oracle for this field is the Elmhurst 10.2 worksheet,
|
|
# not the hybrid `pre_sap` gauge; validate there before tightening further.
|
|
# Nudged 1.41 -> 1.411 by the #1592 revert of the water-only heat-network
|
|
# CALCULATOR changes (primary loss + flat-rate charging leaked into the gov-API
|
|
# RdSAP corpus). 16 Bingley Close reverts +3.2 -> +4.3, so observed MAE 1.405 ->
|
|
# 1.4103; re-tighten when the water-only branch is fixed correctly under #1592
|
|
# (the cylinder-insulation MAPPER fix is retained, so the revert is partial).
|
|
# Ratcheted 1.411 -> 0.65 by the #1596 extraction bundle (#1598 shower/bath +
|
|
# #1599 floor-label + #1600 water-heating-None — see the within-0.5 note above):
|
|
# observed MAE 0.6499. The three silent-drop fixes the gov-API/Elmhurst paths
|
|
# already apply; the electric-shower drop (100/205 fixtures) was the dominant
|
|
# lever. #1601 (upper-floor +0.25 m) held — it over-corrects this cohort.
|
|
_MAX_SAP_MAE: float = 0.65
|
|
|
|
_KNOWN_GAP_REASON = (
|
|
"pashub `from_site_notes` mapper does not int-code a main-heating "
|
|
"`main_heating_control` label to a SAP code; fix in progress field-by-field"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PashubFixture:
|
|
deal_id: str
|
|
uprn: Optional[int]
|
|
pre_sap_raw: str
|
|
pre_sap_score: int
|
|
pdf: str
|
|
|
|
@property
|
|
def path(self) -> Path:
|
|
return _FIXTURES_DIR / self.pdf
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Outcome:
|
|
"""Result of running one fixture through the pipeline."""
|
|
|
|
blocked: bool # blocked on the known main-heating-control gap (xfail territory)
|
|
reason: Optional[str]
|
|
sap_continuous: Optional[float]
|
|
diff: Optional[float] # abs(our SAP - pre_sap)
|
|
|
|
|
|
def _load_manifest() -> list[PashubFixture]:
|
|
if not _MANIFEST_PATH.exists():
|
|
return []
|
|
data = json.loads(_MANIFEST_PATH.read_text())
|
|
return [PashubFixture(**entry) for entry in data["fixtures"]]
|
|
|
|
|
|
_FIXTURES: list[PashubFixture] = _load_manifest()
|
|
_BY_ID: dict[str, PashubFixture] = {f.deal_id: f for f in _FIXTURES}
|
|
_IDS: list[str] = [f.deal_id for f in _FIXTURES]
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def _evaluate(deal_id: str) -> Outcome:
|
|
"""Run one fixture end-to-end. Shared across the two tests via the cache.
|
|
|
|
Only `UnmappedSapCode` is swallowed (the known control-label gap). Every
|
|
other exception propagates — a real extractor/mapper bug the caller must
|
|
surface.
|
|
"""
|
|
fixture = _BY_ID[deal_id]
|
|
try:
|
|
epc = parse_site_notes_pdf(str(fixture.path), uprn=fixture.uprn)
|
|
result = Sap10Calculator().calculate(epc)
|
|
except _KNOWN_GAPS as exc:
|
|
return Outcome(blocked=True, reason=str(exc), sap_continuous=None, diff=None)
|
|
diff = abs(result.sap_score_continuous - fixture.pre_sap_score)
|
|
return Outcome(
|
|
blocked=False,
|
|
reason=None,
|
|
sap_continuous=result.sap_score_continuous,
|
|
diff=diff,
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy fixtures/manifest")
|
|
@pytest.mark.parametrize("deal_id", _IDS)
|
|
def test_pashub_fixture_computes(deal_id: str) -> None:
|
|
"""The extractor must yield a calculable dwelling for each real property."""
|
|
outcome = _evaluate(deal_id)
|
|
if outcome.blocked:
|
|
pytest.xfail(f"{_KNOWN_GAP_REASON}: {outcome.reason}")
|
|
assert outcome.sap_continuous is not None
|
|
assert 1 <= outcome.sap_continuous <= 100
|
|
|
|
|
|
@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy fixtures/manifest")
|
|
def test_pashub_sap_accuracy_aggregate(capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""SAP within-0.5 of pashub's `pre_sap`, aggregated over computable fixtures."""
|
|
diffs: list[float] = []
|
|
blocked = 0
|
|
errored = 0
|
|
for deal_id in _IDS:
|
|
try:
|
|
outcome = _evaluate(deal_id)
|
|
except Exception: # noqa: BLE001 - a per-fixture bug; scored there, not here
|
|
errored += 1
|
|
continue
|
|
if outcome.blocked:
|
|
blocked += 1
|
|
continue
|
|
assert outcome.diff is not None
|
|
diffs.append(outcome.diff)
|
|
|
|
if not diffs:
|
|
pytest.xfail(
|
|
f"no fixture computes a SAP score "
|
|
f"({blocked} blocked on the main-heating-control gap, {errored} errored); "
|
|
f"{_KNOWN_GAP_REASON}"
|
|
)
|
|
|
|
n = len(diffs)
|
|
within_half = sum(1 for d in diffs if d < 0.5) / n
|
|
sap_mae = sum(diffs) / n
|
|
|
|
with capsys.disabled():
|
|
print(
|
|
f"\n[pashub Guinness | {n} computed / {blocked} blocked / "
|
|
f"{errored} errored]"
|
|
f"\n SAP within-0.5 = {within_half:.1%} MAE = {sap_mae:.3f}"
|
|
)
|
|
|
|
assert within_half >= _MIN_WITHIN_HALF, (
|
|
f"SAP within-0.5 {within_half:.1%} fell below floor "
|
|
f"{_MIN_WITHIN_HALF:.0%}"
|
|
)
|
|
assert sap_mae <= _MAX_SAP_MAE, (
|
|
f"SAP MAE {sap_mae:.3f} exceeded ceiling {_MAX_SAP_MAE}"
|
|
)
|