Model/backend/documents_parser/tests/test_pashub_sap_accuracy.py
Daniel Roth 53ba1f1694 Re-baseline the pashub MAE ceiling to 1.415 for the water-only DHW fix 🟪
Second sanctioned exception to "ratchets never loosen" — needs @KhalimCK's
call, since the prior note expected to re-tighten here. Exactly one cohort
property is WHC 950 (16 Bingley Close, +5.946 -> +6.818 = the whole +0.0043);
within-0.5 unchanged at 23.9%. Its pre_sap is verified correct but its ~+6
residual is a known extraction bug, and this hybrid gauge cannot adjudicate a
calculator fix (#1592 Problem 2). The gov-API corpus, which gauges the
calculator alone, improves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:57:16 +00:00

293 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.
_MIN_WITHIN_HALF: float = 0.23
# 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).
# DELIBERATELY LOOSENED 1.411 -> 1.415 by the #1592 sub-task B water-only DHW
# fix — the SECOND sanctioned exception to "ratchets never loosen", and the
# note above expected to RE-TIGHTEN here, so this needs @KhalimCK's explicit
# call before merge.
#
# The fix is spec-verified against the SAP 10.2 PDF (not ticket prose): §4.3
# p.24 "The efficiency for water heating is incorporated in the price of heat
# from a heat network in Table 12"; C4 p.53 scopes the plant efficiency to
# "the calculation of CO2 emissions and Primary Energy"; C6 p.54 + worksheet
# (310) = (64) × (305a) × (306) carry no efficiency term. The dwelling-side
# `plant_eff / DLF` divisor was applying it a SECOND time.
#
# Exactly ONE cohort property is WHC 950 — 507644414148 (16 Bingley Close),
# +5.946 -> +6.818 = 0.872/205 = +0.0043 MAE, the whole movement. within-0.5
# UNCHANGED at 23.9% (49/205); mean signed +1.050 -> +1.054. Observed 1.41458.
#
# Why this is not a regression signal: Bingley's `pre_sap` E52 is Khalim-
# verified correct (2026-07-14), but its ~+6 residual is a KNOWN extraction bug
# still being hunted (see HANDOVER_PASHUB_EXTRACTOR_ACCURACY.md: "community-
# heating residual — ... community standing charge/tariff detail, or DLF
# 'Unknown' distribution type"). This hybrid gauge conflates that extraction
# error with the SAP-10.2-vs-lodged-2012-RdSAP engine offset, so per #1592
# Problem 2 it CANNOT adjudicate a calculator fix — the same reasoning the
# orientation exception above rests on. The gov-API RdSAP corpus, which gauges
# the calculator alone, IMPROVES on this change (within-0.5 78.7% -> 78.8%,
# MAE 0.6273 -> 0.6272; exactly 3 certs move, all WHC 950).
#
# Re-tighten under #1592 sub-task C, once an accredited Elmhurst 10.2 worksheet
# pins WHC-950 — Bingley is the natural capture candidate.
_MAX_SAP_MAE: float = 1.415
_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}"
)