Merge pull request #1650 from Hestia-Homes/fix/pashub-sitenotes-boiler-thermostat-gable

PasHub Wythenshawe: fix 6 from_site_notes defects + re-pin harness to DB oracle (#1649)
This commit is contained in:
KhalimCK 2026-07-20 12:04:16 +01:00 committed by GitHub
commit 08dc81332c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1177 additions and 220 deletions

View file

@ -115,6 +115,16 @@ one-line reason. Absent ≠ zero everywhere — note which.
- **Water heating** 903 = Electric immersion off-peak → Elmhurst "Water Heater"
category, not "Boiler Circulator" (901 = from main system).
- **Windows** are synthesised from `glazed_area` band × TFA — not real geometry.
- **Room height — give the RAW surveyed room height, NOT the engine's storey
height.** Elmhurst adds the RdSAP +0.25 m upper-storey (joist-void) allowance
itself. Our mapper already applies that +0.25 to upper floors
(`_UPPER_FLOOR_HEIGHT_ADD_M`), so `sap_floor_dimensions[*].room_height_m` for
a first floor is *already* `raw + 0.25`. Reading that value onto the sheet
makes Elmhurst add a SECOND 0.25 → storey height too tall → ~4-5 m² extra wall
+ volume → **~1 SAP too low** (it under-scored 6 Barry Road 60→59 until the
first-floor height was corrected from 2.76 back to the raw 2.51). Pull heights
from the survey/raw `building_measurements`, not the transformed
`sap_floor_dimensions`; the ground/lowest floor takes the raw value either way.
- **Age band — pick by construction YEAR, not the engine's band letter.** The
live RdSAP-10 tool bands differently from `mapping.md`'s older table: it
offers `…K 2007-2011`, **`L 2012-2022`**, **`M 2023 onwards`**. A 2020 build

View file

@ -481,7 +481,9 @@ class PasHubRdSapSiteNotesExtractor:
def extract_room_count_elements(self) -> RoomCountElements:
rce_section = self._section("Room Count Elements", "Customer Response")
heated_rooms_raw = self._get_in(rce_section, "Number of heated rooms?")
heated_rooms_raw = self._get_in(
rce_section, "Please enter the number of HEATED rooms:"
)
return RoomCountElements(
number_of_habitable_rooms=int(
self._get_in(rce_section, "Number of habitable rooms?") or 0
@ -624,7 +626,11 @@ class PasHubRdSapSiteNotesExtractor:
condensing=self._bool_in(data, "Condensing"),
year=self._get_in(data, "Year") or "",
mount=self._get_in(data, "Mount") or "",
open_flue=self._get_in(data, "Open Flue") or "",
# PCDF-Search "Open Flue" column wins; a Manual-Entry boiler instead
# lodges the flue under "Is there an open flue?" (Yes/No) (#1649 A).
open_flue=self._get_in(data, "Open Flue")
or self._get_in(data, "Is there an open flue?")
or "",
fan_assist=self._bool_in(data, "Fan Assist"),
status=self._get_in(data, "Status") or "",
central_heating_pump_age=self._get_in(data, "Central heating pump age:")
@ -636,6 +642,12 @@ class PasHubRdSapSiteNotesExtractor:
weather_compensator=self._bool_in(data, "Is there a weather compensator?"),
emitter=self._get_in(data, "Emitter:") or "",
emitter_temperature=self._get_in(data, "Emitter Temperature:") or "",
# Manual-Entry boiler descriptor (blank on a PCDF-Search boiler):
# "Type of boiler:" and "Heating System (Boiler):" (the latter
# carries the pre/post-1998 age band) → the mapper's Table 4b
# efficiency lookup (#1649 A).
boiler_type=self._get_in(data, "Type of boiler:") or "",
boiler_age_band=self._get_in(data, "Heating System (Boiler):") or "",
)
def _parse_secondary_heating(self, data: List[str]) -> SecondaryHeating:
@ -865,4 +877,9 @@ class PasHubRdSapSiteNotesExtractor:
or "",
wall_thickness_mm=self._wall_thickness_in(data),
dry_lined=self._optional_bool_in(data, "Wall Dry-Lined?"),
# Surveyed alt-wall insulation thickness ("50 mm") → the mapper's
# §5.4 thickness input; dropped, the cascade assumes 100 mm (#1649 D).
insulation_thickness_mm=self._parse_insulation_thickness(
self._get_in(data, "Wall insulation thickness:")
)[0],
)

View file

@ -329,6 +329,22 @@ class TestAlternativeWalls:
).extract_alternative_walls()
assert walls == []
def test_insulation_thickness_parses_to_mm(self) -> None:
# The surveyed "Wall insulation thickness:" line must reach
# `AlternativeWall.insulation_thickness_mm`; dropped, the cascade takes
# the RdSAP 10 §5.4 100 mm favourable default (#1649 D).
block = list(self._BLOCK)
i = block.index("Wall Dry-Lined?")
block[i:i] = ["Wall insulation thickness:", "50 mm"]
walls = PasHubRdSapSiteNotesExtractor(block).extract_alternative_walls()
assert walls[0].insulation_thickness_mm == 50
def test_no_insulation_thickness_line_lodges_none(self) -> None:
walls = PasHubRdSapSiteNotesExtractor(
list(self._BLOCK)
).extract_alternative_walls()
assert walls[0].insulation_thickness_mm is None
class TestBuildingMeasurements:
@pytest.fixture
@ -1407,3 +1423,83 @@ class TestSolidMasonryPartyWall:
bc.main_building.party_wall_construction_type
== "Solid Masonry, Timber Frame, or System Built"
)
class TestManualEntryBoilerDescriptor:
"""A Manual-Entry (non-PCDF) boiler lodges its type/age via the labels
"Type of boiler:", "Heating System (Boiler):" (which carries the pre/post-
1998 age band) and "Is there an open flue?" not the PCDF-Search columns
("Type"/"Year"/"Open Flue"). The extractor must read the manual labels so
the mapper can resolve the SAP 10.2 Table 4b efficiency; dropped, the boiler
descriptor is lost and the mapper falls to the generic 0.80 default
(#1649 A). Tokens spliced verbatim from deal 500796377318 (4a Hollyhedge)."""
_SECTION = [
"Heating & Hot Water",
"How would you like to select the Heating System?",
"Manual Entry",
"System type:",
"Boiler with radiators or underfloor heating",
"Fuel:",
"Mains Gas",
"Type of boiler:",
"Back boiler",
"Heating System (Boiler):",
"1998 or later - Back boiler",
"Is there an open flue?",
"Yes",
"Ventilation",
]
def test_manual_boiler_descriptor_captured(self) -> None:
# Act
main = PasHubRdSapSiteNotesExtractor(
self._SECTION
).extract_heating_and_hot_water().main_heating
# Assert
assert main.selection_method == "Manual Entry"
assert main.product_id == 0
assert main.boiler_type == "Back boiler"
assert main.boiler_age_band == "1998 or later - Back boiler"
assert main.open_flue == "Yes"
def test_pcdf_open_flue_column_still_wins(self) -> None:
# A PCDF-Search boiler carries the "Open Flue" column ("Room-sealed");
# the manual "Is there an open flue?" fallback must not clobber it.
section = [
"Heating & Hot Water",
"System type:",
"Boiler with radiators or underfloor heating",
"Open Flue",
"Room-sealed",
"Ventilation",
]
main = PasHubRdSapSiteNotesExtractor(
section
).extract_heating_and_hot_water().main_heating
assert main.open_flue == "Room-sealed"
class TestHeatedRoomsLabel:
"""The PasHub PDF lodges the heated-room count under "Please enter the
number of HEATED rooms:", not "Number of heated rooms?" — the old query
silently dropped it to None (#1649 E)."""
def _section(self) -> list[str]:
return [
"Room Count Elements",
"Number of habitable rooms?",
"5",
"Are any of these rooms unheated?",
"Yes",
"Please enter the number of HEATED rooms:",
"3",
"Customer Response",
]
def test_heated_rooms_label_captured(self) -> None:
rce = PasHubRdSapSiteNotesExtractor(
self._section()
).extract_room_count_elements()
assert rce.number_of_heated_rooms == 3

View file

@ -1,17 +1,30 @@
"""End-to-end accuracy test for the pashub site-notes extractor — WYTHENSHAWE.
Sibling of `test_pashub_sap_accuracy.py` (the Guinness GMCA cohort); see that
module's docstring for the full chain and the meaning of the hybrid `pre_sap`
gauge. This module points at the `pashub_accuracy_wythenshawe/` fixtures built by
module's docstring for the full chain. This module points at the
`pashub_accuracy_wythenshawe/` fixtures built by
python scripts/build_pashub_accuracy_fixtures.py \\
--project-code "[Wythenshawe WH:SHF RAs - WH:SF - 240426 - 494493718748]" \\
--out-dir pashub_accuracy_wythenshawe
from `hubspot_deal_data` (the project_code above) + the `rd_sap_site_note` PDFs
in S3. The ratchets below are calibrated to THIS cohort do not copy the
Guinness constants across; each portfolio discovers its own floor/ceiling by
running.
in S3.
ORACLE: this module grades against the **DB PasHub-assessment SAP** the
`epc_property_energy_performance.energy_rating_current` (`ep.source='lodged'`)
that PasHub's own assessment lodged for the dwelling — NOT the manifest
`pre_sap` (`hubspot_deal_data.pre_sap`, surveyor hand-entered and frequently
wrong; grading against it gave a misleading 79.2% / MAE 0.75 while the engine
actually matched the DB in every pre_sapDB case). Each fixture's oracle SAP is
resolved (by UPRN or postcode+house-number) and baked into `manifest.json` as
`oracle_sap` by `scripts/enrich_pashub_wythenshawe_oracle.py`, so CI needs no DB
access. 20 fixtures have no DB-oracle match yet (`oracle_sap` null) and are
excluded from the accuracy aggregate until PasHub sources their SAP.
The ratchets below are calibrated to THIS cohort against the DB oracle do not
copy the Guinness constants across; each portfolio discovers its own
floor/ceiling by running.
"""
from __future__ import annotations
@ -35,39 +48,35 @@ _KNOWN_GAPS: tuple[type[Exception], ...] = (UnmappedSapCode, UnmappedPasHubLabel
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "pashub_accuracy_wythenshawe"
_MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
# --- Ratchets (never loosen at fixed coverage), mirroring the Guinness module.
# First full run (2026-07-16): **149 computed / 19 blocked / 0 errored**
# (within-0.5 76.5% = 114/149, MAE 0.665). The 19 blocks are a spread of
# separately-ticketed mapper gaps — floor type 'Same dwelling below' (×10),
# room-in-roof gable 'None' (×2), 'No thermostatic control...' on 'Room heaters'
# (×2), plus one-off secondary-heating / secondary-fuel / room-heater / HHR
# storage-control (2404-on-402) labels — each its own field-by-field follow-up,
# not tuning. The floor/ceiling below are the observed baseline with a hair of
# headroom; treat them as a regression tripwire, NOT a cohort accuracy figure,
# and re-baseline (coverage growth is not loosening) as further mapper fixes
# unblock fixtures.
# 2026-07-16 (#1622/#1624): room-heater control 2601 unblocked the two 'No
# thermostatic control...' fixtures → **151 computed / 17 blocked** (within-0.5
# 76.2% = 115/151, MAE 0.716; 500767954156 lands within 0.5, 500797465814 is a
# 8.8 tail entry). MAE ceiling re-baselined 0.67 → 0.72 per the
# coverage-growth convention above.
# 2026-07-16 (#1622/#1625): floor type 'Same dwelling below' → "(another
# dwelling below)" party-floor suppression (gov-API code-8 precedent) unblocked
# all 10 fixtures → **161 computed / 7 blocked** (within-0.5 75.8% = 122/161,
# MAE 0.740). 7/10 newly-computed land within 0.5 — the treatment validates;
# 3.3/5.5 tail entries dilute the aggregates. Floor 0.76 → 0.75, ceiling
# 0.72 → 0.75.
# 2026-07-16 (#1622/#1626-#1631): the remaining one-off labels (HHR-control
# 2404→2402 downgrade, 'Loose jacket' cylinder, secondary 601/609, secondary
# dual fuel, gas room-heater 603) → **165 computed / 3 blocked** (within-0.5
# 75.2% = 124/165, MAE 0.844). The MAE step is one accepted pre_sap
# divergence: 500797805807 (90 Brookfield Gardens) scores 49.5 vs pre_sap 31
# (+18.5) — its own 2025 accredited cert 6890-0597-0722-0196-3943 lodges the
# identical heating codes (603/26/2601) at band E, agreeing with US; the
# PasHub pre_sap instead matches the dwelling's superseded electric
# room-heater certs (2017/2023, band F). Ceiling 0.75 → 0.85.
_MIN_WITHIN_HALF: float = 0.75
_MAX_SAP_MAE: float = 0.85
# --- Ratchets (never loosen at fixed coverage), against the DB oracle.
# Re-pinned to the DB PasHub-assessment SAP (#1649): grading the earlier
# pre_sap-based history (which floated 75-76% / MAE 0.67-0.85 purely on
# surveyor data-entry noise) is retired — see the module docstring.
# 2026-07-19 (#1649 A-F): with the six from_site_notes fixes in place, the
# **148 DB-oracle-matched fixtures** (39 UPRN + 109 postcode+house-number; 20
# unmatched, excluded) score **within-0.5 89.2% (132/148), MAE 0.297**. The
# residual tail is NOT fixable engine defects — two independent causes, both
# established, not inferred:
# 1. Integer-oracle rounding: the DB oracle is a *rounded integer* SAP graded
# against our continuous engine, so a band of spec-correct dwellings sits
# just over a rounding boundary (within-1.0 is 98%).
# 2. Input-provenance drift between the site-notes survey and the lodged cert.
# The two worst under-raters, 6 Barry Road (ours 60.12, oracle 62) and 87
# Alderue (ours 61.80, oracle 63), were re-keyed into accredited Elmhurst
# on our exact inputs: Elmhurst returned **60 and 62** — matching our
# engine, and both *below* the lodged oracle. So our engine computes
# correct RdSAP; the lodged cert simply carried a lighter input set (it
# dropped the 17.4 m² solid-brick alternative wall the surveyor recorded
# via brick-bond evidence, +1.7 SAP). Accredited engines agree on identical
# inputs — a 2-point gap is an input difference, not a calculation one.
# So do NOT chase these as engine bugs, and do NOT tune the engine to close them
# (that would move the gov-API corpus off 78.9% and diverge from accredited
# Elmhurst). Treat floor/ceiling as a regression tripwire, not a target;
# re-baseline (coverage growth is not loosening) as PasHub sources the 20
# unmatched SAPs. The clean accuracy proof is engine-vs-accredited on identical
# inputs (done for 6 Barry / 87 Alderue), not engine-vs-lodged-integer.
_MIN_WITHIN_HALF: float = 0.88
_MAX_SAP_MAE: float = 0.31
_KNOWN_GAP_REASON = (
"pashub `from_site_notes` mapper does not int-code a main-heating "
@ -82,6 +91,13 @@ class PashubFixture:
pre_sap_raw: str
pre_sap_score: int
pdf: str
# DB PasHub-assessment SAP (the grading oracle) —
# `epc_property_energy_performance.energy_rating_current`, `source='lodged'`,
# matched by UPRN or postcode+house-number and baked into the manifest by
# `scripts/enrich_pashub_wythenshawe_oracle.py` (see the module docstring).
# None for the 20 address-unmatched fixtures (excluded from the aggregate).
oracle_sap: Optional[int] = None
oracle_match: Optional[str] = None # "uprn" | "address" | None
@property
def path(self) -> Path:
@ -95,7 +111,7 @@ class Outcome:
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)
diff: Optional[float] # abs(our SAP - DB oracle); None when unmatched/blocked
def _load_manifest() -> list[PashubFixture]:
@ -119,7 +135,14 @@ def _evaluate(deal_id: str) -> Outcome:
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)
# Grade against the DB PasHub-assessment oracle, not the surveyor-entered
# `pre_sap`. A fixture with no DB-oracle match carries diff None and is
# excluded from the accuracy aggregate (it still asserts it computes).
diff = (
abs(result.sap_score_continuous - fixture.oracle_sap)
if fixture.oracle_sap is not None
else None
)
return Outcome(
blocked=False,
reason=None,
@ -141,10 +164,14 @@ def test_pashub_fixture_computes(deal_id: str) -> None:
@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy_wythenshawe 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."""
"""SAP within-0.5 of the DB PasHub-assessment oracle, aggregated over the
address/UPRN-matched fixtures. Fixtures with no DB-oracle match are counted
(`unmatched`) but excluded from the accuracy figures they cannot be graded
until PasHub sources their SAP."""
diffs: list[float] = []
blocked = 0
errored = 0
unmatched = 0
for deal_id in _IDS:
try:
outcome = _evaluate(deal_id)
@ -154,13 +181,15 @@ def test_pashub_sap_accuracy_aggregate(capsys: pytest.CaptureFixture[str]) -> No
if outcome.blocked:
blocked += 1
continue
assert outcome.diff is not None
if outcome.diff is None: # computed, but no DB-oracle SAP to grade against
unmatched += 1
continue
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"no fixture grades against the DB oracle "
f"({blocked} blocked, {unmatched} address-unmatched, {errored} errored); "
f"{_KNOWN_GAP_REASON}"
)
@ -170,8 +199,8 @@ def test_pashub_sap_accuracy_aggregate(capsys: pytest.CaptureFixture[str]) -> No
with capsys.disabled():
print(
f"\n[pashub WYTHENSHAWE | {n} computed / {blocked} blocked / "
f"{errored} errored]"
f"\n[pashub WYTHENSHAWE | {n} graded / {unmatched} unmatched / "
f"{blocked} blocked / {errored} errored | oracle=DB energy_rating_current]"
f"\n SAP within-0.5 = {within_half:.1%} MAE = {sap_mae:.3f}"
)

View file

@ -6124,11 +6124,20 @@ def _pashub_room_in_roof(
continue
surface_kind = kind
if surface_kind is None: # gable — kind from the surveyed type
if d.gable_type not in _PASHUB_GABLE_TYPE_TO_KIND:
if d.gable_type is None:
# A form variant omits the "Gable wall N type:" line, so the
# type is a genuine field-absence (not a truncation or a new
# label). Default unlodged → party `gable_wall` (U=0.25),
# mirroring the gov-API sibling `_api_type_1_gable_kind(None)`
# (SAP 10.2 Table 4 p.22 party-wall default) (#1649 B). A
# present-but-unrecognised label still strict-raises below.
surface_kind = "gable_wall"
elif d.gable_type not in _PASHUB_GABLE_TYPE_TO_KIND:
raise UnmappedPasHubLabel(
"room-in-roof gable type", str(d.gable_type)
)
surface_kind = _PASHUB_GABLE_TYPE_TO_KIND[d.gable_type]
else:
surface_kind = _PASHUB_GABLE_TYPE_TO_KIND[d.gable_type]
thickness = insulation_thickness_mm
if form_asks_insulation_known and not d.insulation_known:
thickness = None
@ -6377,6 +6386,15 @@ def _map_pashub_alternative_wall(a: AlternativeWall) -> SapAlternativeWall:
),
wall_thickness_measured="Y" if a.wall_thickness_mm is not None else "N",
wall_thickness_mm=a.wall_thickness_mm,
# Surveyed alt-wall insulation thickness → the cascade's
# `_parse_thickness_mm`; dropped, RdSAP 10 §5.4 assumes a favourable
# 100 mm (#1649 D). Mirror the main-wall `wall_insulation_thickness`
# string wiring.
wall_insulation_thickness=(
str(a.insulation_thickness_mm)
if a.insulation_thickness_mm is not None
else None
),
)
@ -6544,6 +6562,18 @@ def _map_sap_heating(
cylinder_insulation_type=_pashub_cylinder_insulation_type_code(
heating.water_heating.insulation_type, cylinder_present
),
# A surveyed cylinder thermostat ("Has thermostat? Yes") must reach the
# calculator, which reads `!= "Y"` to apply the SAP 10.2 Table 2b Note
# a) ×1.3 absent-thermostat cylinder-temperature-factor penalty (and the
# Table 4c(2) boiler interlock). Dropped (None), every PasHub cylinder
# dwelling was penalised as thermostat-less (#1649 F). Gated on a lodged
# cylinder — a combi/no-cylinder dwelling keeps None. Mirrors the
# Elmhurst path (`"Y" if cylinder_thermostat else "N"`).
cylinder_thermostat=(
("Y" if heating.water_heating.has_thermostat else "N")
if cylinder_present
else None
),
cylinder_insulation_thickness_mm=heating.water_heating.insulation_thickness_mm,
water_heating_code=water_heating_code,
water_heating_fuel=water_heating_fuel,
@ -6702,6 +6732,10 @@ _PASHUB_SECONDARY_HEATING_TYPE_TO_SAP10: Dict[str, int] = {
"Fire or wall heater with balanced flue": 609,
# Solid fuel (eff 0.32 column B).
"Open fire in grate": 631,
# Solid-fuel closed room heater — SAP 10.2 Table 4a code 633 (inside the
# Elmhurst solid-fuel secondary block {631..636}, so downstream fuel/
# category resolution already covers it) (#1649 C).
"Closed room heater": 633,
}
@ -8154,11 +8188,45 @@ def _pashub_main_heating_control_code(
return code
# PasHub Manual-Entry boiler descriptor → SAP 10.2 Table 4b sub-row code
# (`table_4b.py`), keyed on the surveyed "Heating System (Boiler):" cell
# (`boiler_age_band`), which carries both the boiler type and the pre/post-1998
# age band. A Manual-Entry boiler has no PCDB `product_id`, so its efficiency
# must come from Table 4b, not the generic 0.80 gas default (#1649 A). Only the
# cohort's observed gas/liquid descriptors are mapped; an unmapped descriptor
# (or an electric "Direct acting" boiler, which Table 4b does not cover) resolves
# to None, preserving the pre-existing behaviour (no block, no regression).
_PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B: Dict[str, int] = {
# Gas boilers 1998 or later (Table 4b 101-109).
"1998 or later - Condensing combi": 104, # 84/75 winter/summer
"1998 or later - Condensing": 102, # regular condensing, 84/74
"1998 or later - Back boiler": 109, # back boiler to radiators, 66/56
# Gas pre-1998 (Table 4b 110-119).
"Pre 1998 - Back boiler": 119, # back boiler to radiators, 66/56
"Pre 1998 - Wall mounted": 115, # regular, wall mounted, 66/56
# Liquid fuel boilers (Table 4b 124-132).
"Pre 1998 - Standard oil boiler": 125, # standard oil 1985-1997, 71/59
}
def _pashub_manual_boiler_table_4b_code(main: PasHubMainHeating) -> Optional[int]:
"""Resolve a Manual-Entry boiler's surveyed descriptor to its SAP 10.2
Table 4b code (#1649 A). Returns None for a PCDF-Search boiler (product_id
lodged efficiency is the SEDBUK lookup) and for any descriptor the Table
4b map does not cover (e.g. an electric boiler), leaving the calculator's
existing cascade untouched for those."""
if main.product_id > 0:
return None
return _PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B.get(main.boiler_age_band)
def _pashub_main_heating_system_code(main: PasHubMainHeating) -> Optional[int]:
"""Resolve the Table 4a system code for a PasHub storage/room-heater
lodgement at the mapper boundary (ADR-0015). Heat networks resolve their
code via `_pashub_community_heating`; boilers carry None (their efficiency
is a SEDBUK/product lookup, not a Table 4a rd row).
code via `_pashub_community_heating`. A PCDF-Search boiler carries None (its
efficiency is the SEDBUK/product lookup); a Manual-Entry boiler resolves its
surveyed descriptor to a SAP 10.2 Table 4b code (#1649 A) so the calculator
bills the Table 4b seasonal efficiency instead of the generic 0.80 default.
The surveyed heater *type* is lodged in the "Heating System (Other)" field
(`community_heat_source`) for Manual Entry; a PCDF-Search storage lodgement
@ -8185,6 +8253,11 @@ def _pashub_main_heating_system_code(main: PasHubMainHeating) -> Optional[int]:
f"{main.community_heat_source!r} (Heating System (Other))",
)
return code
if system in _PASHUB_BOILER_SYSTEM_TYPES:
# PCDF-Search boiler → None (SEDBUK product efficiency); Manual-Entry
# boiler → its Table 4b code (or None for an uncovered/non-Table-4b
# descriptor, keeping the pre-existing cascade) (#1649 A).
return _pashub_manual_boiler_table_4b_code(main)
return None

View file

@ -1957,6 +1957,7 @@ class TestFromSiteNotesExample1:
has_fixed_air_conditioning=False,
cylinder_size=2, # "Normal (90-130 litres)" → Table 28 code 2
cylinder_insulation_type=1, # "Factory fitted" → foam, SAP10 code 1
cylinder_thermostat="Y", # "Has thermostat? Yes" → interlock (#1649 F)
cylinder_insulation_thickness_mm=12,
water_heating_code=901, # cylinder + "From main heating 1" → WHC 901
shower_outlets=ShowerOutlets(
@ -3000,3 +3001,250 @@ class TestPasHubUnmappedHeatEmitter:
# Act / Assert
with pytest.raises(UnmappedPasHubLabel, match="Skirting heaters"):
EpcPropertyDataMapper.from_site_notes(survey)
class TestPasHubCylinderThermostat:
"""The surveyed hot-water cylinder thermostat ("Has thermostat? Yes") must
reach `sap_heating.cylinder_thermostat` dropped (left None), the
calculator reads `!= "Y"` and applies the SAP 10.2 Table 2b Note a) ×1.3
absent-thermostat cylinder-temperature-factor penalty (equivalently the
Table 4c(2) 5pp boiler-interlock penalty), inflating stored-HW loss and
under-rating every PasHub cylinder dwelling. The Elmhurst sibling maps it
(`"Y" if cylinder_thermostat else "N"`); `from_site_notes` did not.
Evidence: 52 Bucklow Drive (DB 62, ours 59.44 61.79) and 13 Kerne Grove
(DB 67, ours 64.95 67.11) the two worst DB-oracle under-raters (#1649 F).
"""
def test_surveyed_cylinder_thermostat_yes_maps_to_Y(self) -> None:
# Arrange — example1 lodges a cylinder ("Normal (90-130 litres)") with
# "Has thermostat? Yes".
data = load("pashub_rdsap_site_notes_example1.json")
assert data["heating_and_hot_water"]["water_heating"]["has_thermostat"] is True
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act
result = EpcPropertyDataMapper.from_site_notes(survey)
# Assert
assert result.sap_heating.cylinder_thermostat == "Y"
def test_surveyed_cylinder_thermostat_no_maps_to_N(self) -> None:
# Arrange
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["water_heating"]["has_thermostat"] = False
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act
result = EpcPropertyDataMapper.from_site_notes(survey)
# Assert
assert result.sap_heating.cylinder_thermostat == "N"
def test_no_cylinder_leaves_thermostat_none(self) -> None:
# Arrange — a combi/no-cylinder dwelling has no cylinder thermostat to
# lodge; the field must stay None (not "N"), so the calculator's
# cylinder-loss path is not spuriously engaged.
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["water_heating"]["cylinder_size"] = "No Cylinder"
data["heating_and_hot_water"]["water_heating"]["has_thermostat"] = None
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act
result = EpcPropertyDataMapper.from_site_notes(survey)
# Assert
assert result.sap_heating.cylinder_thermostat is None
class TestPasHubRoomInRoofGableNoneDefaultsParty:
"""A survey-form variant that omits the room-in-roof gable-type field lodges
a genuine `gable_type=None` (field-absence, not a truncation or a new
label). The mapper must default it to the party `gable_wall` (U=0.25),
mirroring the gov-API sibling `_api_type_1_gable_kind(None)` a
present-but-unrecognised label still strict-raises. Left raising, the
fixture is uncomputable (500771973367 8 Bucklow Drive, 500759637226 34
Bucklow Drive both BLOCKED; #1649 B). Spec: SAP 10.2 Table 4 p.22 (party
wall U=0.25).
"""
_RIR_GABLE_NONE = {
"age_range": "C: 1930 - 1949",
"floor_area_m2": 21.0,
# The form variant lodges length/height but no "Gable wall N type:" line
# → gable_type is a genuine None.
"gables": [
{"length_m": 4.76, "height_m": 2.32},
{"length_m": 3.16, "height_m": 2.32},
],
"slopes": [{"length_m": 4.76, "height_m": 2.5}],
}
def test_gable_type_none_defaults_to_party_gable_wall(self) -> None:
# Arrange
data = load("pashub_rdsap_site_notes_example1.json")
data["roof_space"]["main_building"]["rooms_in_roof"] = True
data["roof_space"]["main_building"]["room_in_roof"] = self._RIR_GABLE_NONE
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act — must not raise
rir = EpcPropertyDataMapper.from_site_notes(survey).sap_building_parts[
0
].sap_room_in_roof
# Assert — both unlodged-type gables default to the party gable_wall.
assert rir is not None
assert rir.detailed_surfaces is not None
gable_kinds = [
s.kind for s in rir.detailed_surfaces if s.kind.startswith("gable_wall")
]
assert gable_kinds == ["gable_wall", "gable_wall"]
def test_present_but_unknown_gable_label_still_raises(self) -> None:
# Arrange — a lodged-but-unrecognised label is a real coverage gap and
# must still fail loud (the None default must not swallow it).
data = load("pashub_rdsap_site_notes_example1.json")
rir = {
**self._RIR_GABLE_NONE,
"gables": [{"length_m": 4.76, "height_m": 2.32, "gable_type": "Mystery"}],
}
data["roof_space"]["main_building"]["rooms_in_roof"] = True
data["roof_space"]["main_building"]["room_in_roof"] = rir
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act / Assert
with pytest.raises(UnmappedPasHubLabel, match="Mystery"):
EpcPropertyDataMapper.from_site_notes(survey)
class TestPasHubSecondaryClosedRoomHeater:
"""The secondary-heating label "Closed room heater" must map to SAP 10.2
Table 4a code 633 (solid-fuel closed room heater). An uncovered label
strict-raises and blocks the fixture (500796700868 8 Brinkshaw Avenue;
#1649 C). 633 is already in `_ELMHURST_SECONDARY_SOLID_CODES {631..636}`,
so downstream fuel/category resolution already covers it.
"""
def test_closed_room_heater_maps_to_633(self) -> None:
# Arrange
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["secondary_heating"][
"secondary_system"
] = "Closed room heater"
data["heating_and_hot_water"]["secondary_heating"]["secondary_fuel"] = (
"Dual Fuel Appliance (Mineral and Wood)"
)
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act — must not raise
result = EpcPropertyDataMapper.from_site_notes(survey)
# Assert
assert result.sap_heating.secondary_heating_type == 633
class TestPasHubManualBoilerEfficiency:
"""A Manual-Entry boiler (no PCDB `product_id`) must resolve its surveyed
descriptor to a SAP 10.2 Table 4b code so the calculator bills the Table 4b
seasonal efficiency, not the generic 0.80 "no data" gas default. Left None,
a back boiler (Table 4b 66%) is credited 80% (500796377318 4a Hollyhedge:
DB 53, ours 63.6 53.4) and a condensing combi's water-heating summer
efficiency (75%) is over-credited (the 84 Button / 91 Alderue / 17 Kennett
/ 43 Kenninghall / 156 Gladeside over-raters). #1649 A.
A PCDF-Search boiler keeps `None` (its efficiency is the SEDBUK product
lookup); a non-Table-4b descriptor (e.g. electric) also keeps `None`
(unchanged behaviour no regression).
"""
@staticmethod
def _manual_boiler(data: Dict[str, Any], *, boiler_type: str, age_band: str) -> Any:
mh = data["heating_and_hot_water"]["main_heating"]
mh["selection_method"] = "Manual Entry"
mh["system_type"] = "Boiler with radiators or underfloor heating"
mh["product_id"] = 0
mh["fuel"] = "Mains Gas"
mh["boiler_type"] = boiler_type
mh["boiler_age_band"] = age_band
return from_dict(PasHubRdSapSiteNotes, data)
def test_manual_back_boiler_post_1998_maps_to_109(self) -> None:
survey = self._manual_boiler(
load("pashub_rdsap_site_notes_example1.json"),
boiler_type="Back boiler",
age_band="1998 or later - Back boiler",
)
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
assert main.sap_main_heating_code == 109
def test_manual_condensing_combi_maps_to_104(self) -> None:
survey = self._manual_boiler(
load("pashub_rdsap_site_notes_example1.json"),
boiler_type="Condensing combi",
age_band="1998 or later - Condensing combi",
)
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
assert main.sap_main_heating_code == 104
def test_pcdf_boiler_keeps_none(self) -> None:
# example1 as lodged is a PCDF-Search boiler (product_id 18400): its
# efficiency is the SEDBUK lookup, so the Table 4a code stays None.
survey = from_dict(
PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json")
)
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
assert main.sap_main_heating_code is None
def test_manual_boiler_unmapped_descriptor_keeps_none(self) -> None:
# An electric "Direct acting" descriptor is not a Table 4b gas/liquid
# boiler — keep None (unchanged 0.80 behaviour, no regression / no block).
survey = self._manual_boiler(
load("pashub_rdsap_site_notes_example1.json"),
boiler_type="Standard",
age_band="Direct acting",
)
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
assert main.sap_main_heating_code is None
class TestPasHubAlternativeWallInsulationThickness:
"""A surveyed alternative-wall "Wall insulation thickness:" must reach
`SapAlternativeWall.wall_insulation_thickness`; dropped, the cascade takes
the RdSAP 10 §5.4 "assume 100 mm if unknown" favourable default instead of
the surveyed value (#1649 D). Mirrors the gov-API / Elmhurst paths.
"""
def test_alt_wall_insulation_thickness_reaches_sap_alt_wall(self) -> None:
# Arrange — a main-building alt wall lodging a 50 mm insulation thickness.
data = load("pashub_rdsap_site_notes_example1.json")
data["alternative_walls"] = [
{
"location": "Main Building",
"id": 1,
"construction_type": "Solid brick",
"insulation_type": "External",
"area_m2": 13.71,
"wall_u_value_known": False,
"thermal_conductivity_of_wall_insulation": "Unknown",
"wall_thickness_mm": 250,
"dry_lined": False,
"insulation_thickness_mm": 50,
}
]
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act
result = EpcPropertyDataMapper.from_site_notes(survey)
# Assert — the surveyed 50 mm reaches the SAP alt wall (string wiring,
# like the main wall) instead of the §5.4 100 mm favourable default.
alt = result.sap_building_parts[0].sap_alternative_wall_1
assert alt is not None
assert alt.wall_insulation_thickness == "50"

View file

@ -232,6 +232,16 @@ class MainHeating:
# "Water or oil-filled radiators"); blank when the type is fixed elsewhere
# (a PCDF-Search product, or a boiler). See #1619.
community_heat_source: str = ""
# Manual-Entry (non-PCDF) boiler descriptor. A boiler selected by "Manual
# Entry" (product_id 0) carries no SEDBUK record, so its efficiency must
# come from SAP 10.2 Table 4b keyed on these two surveyed labels rather than
# the generic 0.80 gas default (#1649 A). `boiler_type` is the §Heating
# "Type of boiler:" cell (e.g. "Condensing combi" / "Back boiler"),
# `boiler_age_band` the "Heating System (Boiler):" cell, which carries the
# pre/post-1998 age band (e.g. "1998 or later - Condensing combi"). Blank on
# a PCDF-Search boiler (efficiency is the product lookup).
boiler_type: str = ""
boiler_age_band: str = ""
@dataclass
@ -369,6 +379,10 @@ class AlternativeWall:
thermal_conductivity_of_wall_insulation: str
wall_thickness_mm: Optional[int] = None
dry_lined: Optional[bool] = None
# Surveyed "Wall insulation thickness:" (mm) for the alternative wall.
# Dropped, the cascade takes the RdSAP 10 §5.4 "assume 100 mm if unknown"
# favourable default instead of the lodged value (#1649 D).
insulation_thickness_mm: Optional[int] = None
def _no_alternative_walls() -> List[AlternativeWall]:

View file

@ -0,0 +1,134 @@
"""Enrich the PasHub Wythenshawe fixture manifest with the DB-oracle SAP.
The accuracy harness must grade against the reliable oracle the PasHub
company-assessment result stored in the DB (`epc_property_energy_performance.
energy_rating_current`, `ep.source='lodged'`) NOT the manifest `pre_sap`
(`hubspot_deal_data.pre_sap`, surveyor hand-entered and frequently wrong). This
one-time / rebuild-able provenance script resolves each fixture to that DB SAP
and writes `oracle_sap` (int or null) + `oracle_match` ("uprn" / "address" /
null) back into `manifest.json`, so the committed manifest is what the test
consumes and CI needs no DB access. Mirrors `build_pashub_accuracy_fixtures.py`.
Match order: manifest `uprn` (bigint, exact) else postcode + house-number
parsed from the PDF "Property Address:" block. Latest cert per key by `ep.id`.
Requires DB credentials (reads `backend/.env`); NOT run in CI.
python scripts/enrich_pashub_wythenshawe_oracle.py [--dry-run]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Optional
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
from scripts.e2e_common import build_engine, load_env # noqa: E402
from sqlalchemy import text # noqa: E402
from sqlalchemy.engine import Connection # noqa: E402
from backend.documents_parser.parser import pdf_to_text_list # noqa: E402
_FIXTURES = (
REPO_ROOT
/ "backend"
/ "documents_parser"
/ "tests"
/ "fixtures"
/ "pashub_accuracy_wythenshawe"
)
_MANIFEST = _FIXTURES / "manifest.json"
_POSTCODE_RE = re.compile(r"^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$")
_HOUSENUM_RE = re.compile(r"^(\d+[A-Za-z]?)\b")
def _address_from_pdf(deal_id: str) -> tuple[Optional[str], Optional[str]]:
"""(house_number, postcode) from the PDF "Property Address:" block."""
tl = pdf_to_text_list((_FIXTURES / f"{deal_id}.pdf").read_bytes())
try:
i = next(k for k, l in enumerate(tl) if l.strip() == "Property Address:")
except StopIteration:
return None, None
block = [l.strip().rstrip(",") for l in tl[i + 1 : i + 6] if l.strip()]
postcode = next((l for l in block if _POSTCODE_RE.match(l.upper())), None)
house = None
if block:
m = _HOUSENUM_RE.match(block[0])
if m:
house = m.group(1)
return house, postcode
def _resolve(
conn: Connection, uprn: Optional[int], deal_id: str
) -> tuple[Optional[int], str]:
if uprn:
row = conn.execute(
text(
"select eppe.energy_rating_current from epc_property ep "
"join epc_property_energy_performance eppe "
"on eppe.epc_property_id = ep.id "
"where ep.source='lodged' and ep.uprn=:u "
"and eppe.energy_rating_current is not null "
"order by ep.id desc limit 1"
),
{"u": int(uprn)},
).scalar()
if row is not None:
return int(row), "uprn"
house, postcode = _address_from_pdf(deal_id)
if house and postcode:
rows = conn.execute(
text(
"select ep.address_line_1, eppe.energy_rating_current "
"from epc_property ep join epc_property_energy_performance eppe "
"on eppe.epc_property_id = ep.id "
"where ep.source='lodged' "
"and replace(upper(ep.postcode),' ','')=:pc "
"and eppe.energy_rating_current is not null order by ep.id desc"
),
{"pc": postcode.upper().replace(" ", "")},
).fetchall()
for row_pair in rows:
a1 = str(row_pair[0] or "").strip()
m = _HOUSENUM_RE.match(a1)
if m and m.group(1).lower() == house.lower():
return int(row_pair[1]), "address"
return None, "unmatched"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
load_env(REPO_ROOT / "backend" / ".env")
engine = build_engine()
manifest = json.loads(_MANIFEST.read_text())
matched = 0
with engine.connect() as conn:
for fx in manifest["fixtures"]:
oracle_sap, kind = _resolve(conn, fx.get("uprn"), fx["deal_id"])
fx["oracle_sap"] = oracle_sap
fx["oracle_match"] = None if kind == "unmatched" else kind
if oracle_sap is not None:
matched += 1
n = len(manifest["fixtures"])
print(f"resolved {matched}/{n} fixtures to a DB oracle SAP")
if args.dry_run:
print("--dry-run: manifest NOT written")
return
_MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n")
print(f"wrote {_MANIFEST}")
if __name__ == "__main__":
main()