Merge pull request #1642 from Hestia-Homes/1622-sitenotes-mapper-label-blocks

PasHub mapper: close the 14 ticketed label blocks under #1622 (LRHA 45→77, Wythenshawe 149→165 computed)
This commit is contained in:
Daniel Roth 2026-07-17 13:54:25 +01:00 committed by GitHub
commit c07c59bb2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 517 additions and 34 deletions

View file

@ -48,10 +48,23 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
# '...2002-2021', cylinder size 'Exact cylinder volume' / 'Medium (131-170
# litres)', 'Heating oil' fuel, community-heating charging controls, 'MV - Heat
# Recovery' — each its own field-by-field follow-up, not tuning.
# 2026-07-16 (#1622/#1632): 'Heating oil' main fuel → 28 unblocked the oil
# dwellings → **51 computed / 53 blocked** (within-0.5 41.2% = 21/51, MAE
# 2.948). The newly-computable oil configs land outside 0.5 per the
# coverage-growth convention above — floor re-baselined 0.46 → 0.41.
# 2026-07-16 (#1622 sub-issues #1630-#1637): cylinder Medium band, heat-pump
# Group 2 controls + category 4, heat-network charging controls
# 2306/2314/2313, MVHR, biomass community fuel 43, HHR-control downgrade →
# **77 computed / 27 blocked** (within-0.5 42.9% = 33/77, MAE 2.960; ratchets
# hold). Residual blocks: 'Exact cylinder volume' ×16 (extractor+dataclass,
# tracked separately), the line-wrap-truncated '…room thermostat and'
# controls label ×6 (extractor fix, tracked separately), heat_network_age_band
# '2007 - 2011' ×4 (calculator cascade dict, surfaced behind the biomass
# fuel), room-in-roof gable 'None' ×1.
# Treat the constants as a regression tripwire, NOT a cohort accuracy figure;
# re-baseline (coverage growth is not loosening) as further mapper fixes unblock
# fixtures.
_MIN_WITHIN_HALF: float = 0.46
_MIN_WITHIN_HALF: float = 0.41
_MAX_SAP_MAE: float = 3.0
_KNOWN_GAP_REASON = (

View file

@ -46,8 +46,28 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
# 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.
_MIN_WITHIN_HALF: float = 0.76
_MAX_SAP_MAE: float = 0.67
# 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
_KNOWN_GAP_REASON = (
"pashub `from_site_notes` mapper does not int-code a main-heating "

View file

@ -6180,9 +6180,21 @@ _CANONICAL_GROUND_FLOOR_TYPE: Final[str] = "Ground floor"
def _pashub_floor_type(label: str) -> str:
if label.strip().lower() == _CANONICAL_GROUND_FLOOR_TYPE.lower():
return _CANONICAL_GROUND_FLOOR_TYPE
# "Same dwelling below" = an internal floor between heated storeys of ONE
# dwelling — RdSAP 10 §3 gives it no floor heat loss, mechanically
# identical to a party floor. Reuse the "(another dwelling below)"
# suppression string `heat_transmission.py` substring-matches, exactly as
# the gov-API code-8 precedent does (`_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE`).
if label.strip() == _PASHUB_SAME_DWELLING_BELOW_FLOOR_TYPE:
return "(another dwelling below)"
return label
# PasHub floor-location label for a floor directly above a heated room of the
# same dwelling (e.g. an upper-storey extension floor over the main building).
_PASHUB_SAME_DWELLING_BELOW_FLOOR_TYPE: Final[str] = "Same dwelling below"
# PAS Hub lodges the ground-floor exposure as the "Floor type:" label. Only the
# two non-ground exposures carry SAP consequence; a ground floor (or an unasked/
# blank field) routes through the default ground-floor cascade. Mirrors the
@ -6202,6 +6214,10 @@ _PASHUB_FLOOR_EXPOSURE: Final[Dict[str, tuple[bool, bool]]] = {
# 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),
# Internal/party floor over a heated room of the same dwelling — no floor
# heat loss (suppressed via `_pashub_floor_type`'s "(another dwelling
# below)" normalization); neither Table 20 nor §5.14 applies.
_PASHUB_SAME_DWELLING_BELOW_FLOOR_TYPE: (False, False),
}
@ -6477,11 +6493,9 @@ def _map_sap_heating(
if community_sap_code is not None
else _pashub_main_heating_system_code(main)
)
main_heating_control = _pashub_main_heating_control_code(
main.controls, main.system_type
)
_pashub_guard_storage_control_coherence(
main_heating_control, sap_main_heating_code
main_heating_control = _pashub_resolve_storage_control_coherence(
_pashub_main_heating_control_code(main.controls, main.system_type),
sap_main_heating_code,
)
return SapHeating(
@ -6491,6 +6505,11 @@ def _map_sap_heating(
has_fghrs=main.flue_gas_heat_recovery_system,
main_fuel_type=main_fuel,
sap_main_heating_code=sap_main_heating_code,
main_heating_category=(
_PASHUB_HEATING_CATEGORY_HEAT_PUMP
if main.system_type.lower() in _PASHUB_HEAT_PUMP_SYSTEM_TYPES
else None
),
heat_emitter_type=_pashub_heat_emitter_code(main.emitter),
emitter_temperature=main.emitter_temperature,
fan_flue_present=main.fan_assist,
@ -6546,6 +6565,13 @@ _PASHUB_VENTILATION_TYPE_TO_KIND: Dict[str, Optional[str]] = {
"Mechanical Extract - Centralised": "EXTRACT_OR_PIV_OUTSIDE",
"Positive Input - From Outside": "EXTRACT_OR_PIV_OUTSIDE",
"Positive Input - From Loft (As Natural)": None,
# Balanced mechanical ventilation with heat recovery. PasHub's survey
# form carries no PCDF product index / wet-room count / duct type, so
# `_mvhr_system_values` always takes the Table 4g default branch
# (66% × 0.70 in-use = 46.2% heat recovery) regardless of the installed
# unit — expect these dwellings in the accuracy tail until PasHub
# captures the MVHR product reference (#1637).
"MV - Heat Recovery": "MVHR",
}
@ -6576,6 +6602,14 @@ _PASHUB_COMMUNITY_HEAT_SOURCE_TO_ELMHURST: Dict[str, str] = {
"Community heating - boilers only": "Boilers",
}
# PasHub community-fuel labels that use SAP 10.2 Table 12 row wording rather
# than the Elmhurst §14.1 vocabulary (`_ELMHURST_COMMUNITY_BOILER_FUEL_TO_
# TABLE_12` covers "Mains Gas"/"Biomass" etc.). 43 = heat from boilers —
# biomass (Table 12, PDF p.189).
_PASHUB_COMMUNITY_FUEL_TO_TABLE_12: Dict[str, int] = {
"Heat from boilers - biomass": 43,
}
def _pashub_community_heating(
heat_source_label: str, fuel_label: str
@ -6584,14 +6618,20 @@ def _pashub_community_heating(
code, Table 12 fuel code) at the mapper boundary (ADR-0015), reusing
`_resolve_community_heating_fuel_code`. An uncovered heat-source label
strict-raises so the dwelling is never silently priced as an ordinary
boiler (issue #1590 follow-up, fixture 507644414148)."""
boiler (issue #1590 follow-up, fixture 507644414148); an uncovered
non-empty fuel label strict-raises too, rather than passing the raw
string through to the calculator's deep `MissingMainFuelType`."""
if heat_source_label not in _PASHUB_COMMUNITY_HEAT_SOURCE_TO_SAP_CODE:
raise UnmappedPasHubLabel("community heat source", heat_source_label)
sap_code = _PASHUB_COMMUNITY_HEAT_SOURCE_TO_SAP_CODE[heat_source_label]
fuel = _resolve_community_heating_fuel_code(
_PASHUB_COMMUNITY_HEAT_SOURCE_TO_ELMHURST[heat_source_label],
fuel_label,
)
fuel = _PASHUB_COMMUNITY_FUEL_TO_TABLE_12.get(fuel_label)
if fuel is None:
fuel = _resolve_community_heating_fuel_code(
_PASHUB_COMMUNITY_HEAT_SOURCE_TO_ELMHURST[heat_source_label],
fuel_label,
)
if fuel is None and fuel_label:
raise UnmappedPasHubLabel("community fuel", fuel_label)
return sap_code, fuel
@ -6603,6 +6643,7 @@ def _pashub_community_heating(
# convention (issue #1590 bug 6).
_PASHUB_CYLINDER_SIZE_TO_SAP10: Dict[str, int] = {
"Normal (90-130 litres)": 2,
"Medium (131-170 litres)": 3, # Table 28 Medium → fixed 160 L
"Large (>170 litres)": 4,
}
@ -6642,9 +6683,23 @@ def _pashub_cylinder_size_code(
_PASHUB_SECONDARY_HEATING_TYPE_TO_SAP10: Dict[str, int] = {
# Electric direct-acting (eff 1.00) — modal cohort heater.
"Panel, convector or radiant heaters": 691,
# Electric water/oil-filled radiators (PCDB 694, eff 1.00). Unhyphenated —
# the secondary-heating survey field lodges its own spelling, distinct from
# the hyphenated `_PASHUB_ROOM_HEATER_TYPE_TO_SAP10` main-heating label;
# both entries are legitimately separate, do not alias.
"Water or oil filled radiators": 694,
# Gas room heaters (Table 4a column A).
"Flush fitting live fuel effect open front fire, sealed to fireplace": 605,
"1980 or later - Open front fire with open flue, sealed to fireplace": 603,
# Pre-1980 open-fronted gas fire, open flue, not sealed to the fireplace
# (no qualifier in the label) — PCDB 601, eff 50%. 602 (601 + back boiler)
# rejected: no back-boiler qualifier surveyed.
"Pre 1980 - Open front fire with open flue": 601,
# Balanced-flue (room-sealed) gas fire/wall heater — PCDB 609 ("Gas fire
# or wall heater, balanced flue", flue RS, eff 58%), the only
# balanced-flue code in the 601-613 gas block; distinct from the
# open-flue codes above.
"Fire or wall heater with balanced flue": 609,
# Solid fuel (eff 0.32 column B).
"Open fire in grate": 631,
}
@ -6672,6 +6727,9 @@ def _pashub_secondary_heating_type_code(
# cylinder is billed ZERO storage loss.
_PASHUB_CYLINDER_INSULATION_TO_SAP10: Dict[str, int] = {
"Factory fitted": 1, # factory-applied foam
# Loose jacket → 2 (gov-API coding; Elmhurst "Jacket" sibling). Table 2
# Note 1 gives it its own ~2× higher storage-loss factor.
"Loose jacket": 2,
}
@ -7817,6 +7875,10 @@ _PASHUB_MAIN_FUEL_TO_SAP10: Dict[str, int] = {
# once the extractor captured the `Fuel:`-form label it had been dropping.
"Dual fuel appliance (mineral and wood)": 10,
"Electricity": 30, # the standard-electricity fuel code
# Oil boiler/heater fuel. 28 = epc_codes `main_fuel` "oil (not community)",
# the same code the Elmhurst map pins for the identical label; routes via
# `API_FUEL_TO_TABLE_32` → Table 32 code 4 (heating oil) for cost/CO2/PE.
"Heating oil": 28,
}
# PasHub surveyed secondary-heating `secondary_fuel` label → SAP 10.2 Table 32
@ -7833,6 +7895,11 @@ _PASHUB_SECONDARY_FUEL_TO_SAP10: Dict[str, int] = {
# Solid-fuel secondary heater burning house coal → Table 32 code 11 (House
# coal), the same code the solid-fuel main-heating path pins (SAP 631 → 11).
"House Coal": 11,
# Dual-fuel (mineral + wood) secondary heater, e.g. an open fire in grate.
# Same fuel, same Table 32 code as the main-fuel path pins for the
# sentence-case label (10 — see `_PASHUB_MAIN_FUEL_TO_SAP10`); the
# secondary-heating survey field lodges it title-cased.
"Dual Fuel Appliance (Mineral and Wood)": 10,
}
@ -7928,6 +7995,42 @@ _PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10: Dict[str, int] = {
"Charging system linked to use of community heating, room thermostat only": (
2308
),
# Linked-to-use (DHW 1.00) WITH TRVs — these two DO assert TRVs, so the
# with-TRVs type-3 codes are correct here (unlike the thermostat-only
# label above, where 2306 was the bug): "programmer and TRVs" → 2306,
# "room thermostat and TRVs" → 2314 (PCDB `heat_network_controls`).
"Charging system linked to use of community heating, programmer and TRVs": (
2306
),
"Charging system linked to use of community heating, room thermostat and TRVs": (
2314
),
# Flat-rate charging (DHW 1.05) with room thermostat + TRVs → 2313
# (control type 2, space charging 1.05).
"Flat rate charging, room thermostat and TRVs": 2313,
}
# PasHub `system_type` labels that are SAP 10.2 Table 4e GROUP 2: HEAT PUMPS
# WITH RADIATORS OR UNDERFLOOR HEATING (spec PDF p.172). Their control labels
# carry 22xx codes; the system's efficiency comes from the lodged PCDB product
# (`main_heating_index_number`), not a Table 4a rd row, so
# `_pashub_main_heating_system_code` correctly leaves the code None.
_PASHUB_HEAT_PUMP_SYSTEM_TYPES: frozenset[str] = frozenset({
"heat pump with radiators or underfloor heating",
})
# Table 4a main-heating category 4 (heat pumps) — the calculator's Appendix
# N3.6/N3.7 efficiency, extended-heating and §4f pumps+fans paths all gate on
# `main_heating_category == 4` (same category the Elmhurst mapper lodges via
# `_ELMHURST_HEATING_CATEGORY_HEAT_PUMP`). Without it a PCDB heat pump is
# billed as 100%-efficient direct electric.
_PASHUB_HEATING_CATEGORY_HEAT_PUMP: Final[int] = 4
# PasHub surveyed heat-pump control labels → their SAP 10.2 Table 4e Group 2
# code (PCDB `heat_pump_controls`, control type 2, temp adjustment 0.0).
_PASHUB_HEAT_PUMP_CONTROL_TO_SAP10: Dict[str, int] = {
"Programmer, room thermostat and TRVs": 2210,
}
@ -7965,8 +8068,11 @@ _PASHUB_STORAGE_HEATER_CONTROL_TO_SAP10: Dict[str, int] = {
# PasHub surveyed room-heater control labels → their SAP 10.2 Table 4e Group 6
# code (spec PDF p.173). PasHub lodges the singular "Programmer and room
# thermostat"; Table 4e's Group 6 wording is the plural "Programmer and room
# thermostats" (2605) — same control.
# thermostats" (2605) — same control. 2601 is the lowest-control (manual /
# no-thermostat) Group 6 code: control type 2, +0.3 °C MIT adjustment (PCDB
# `room_heater_systems_controls`), one step below 2602.
_PASHUB_ROOM_HEATER_CONTROL_TO_SAP10: Dict[str, int] = {
"No thermostatic control of room temperature": 2601,
"Appliance thermostats": 2602,
"Programmer and room thermostat": 2605,
}
@ -7986,10 +8092,17 @@ _PASHUB_STORAGE_HEATER_HHR_CODE: int = 409
# PasHub surveyed room-heater emitter class ("Heating System (Other)" field) →
# Table 4a code (spec PDF p.170, both 100% efficient).
# Table 4a code (spec PDF p.170; the electric rows are 100% efficient, the gas
# row bills its Table 4a seasonal efficiency).
_PASHUB_ROOM_HEATER_TYPE_TO_SAP10: Dict[str, int] = {
"Panel, Convector or radiant heaters": 691,
"Water or oil-filled radiators": 694,
# Gas fire, open flue, 1980+, open fronted, sealed to fireplace — Table 4a
# code 603 (PCDB "sitting proud of, and sealed to, fireplace opening",
# eff 63%). 604 (back boiler) / 605 (flush-fitting live fuel effect)
# rejected: neither qualifier appears in the surveyed label. Same PCDB row
# the secondary table pins for its differently-phrased sibling label.
"Gas fire, Open flue, 1980 or later (Open fronted), Sealed to fireplace": 603,
}
@ -8003,9 +8116,10 @@ def _pashub_main_heating_control_code(
The resolution is system-aware: the same control label recurs across Table
4e's system groups under different 2xxx codes, so the code is chosen by the
system's group — Group 1 boiler (`_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10`),
Group 3 heat network (`_PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10`), Group 4
electric storage (`_PASHUB_STORAGE_HEATER_CONTROL_TO_SAP10`) or Group 6 room
heater (`_PASHUB_ROOM_HEATER_CONTROL_TO_SAP10`). Any other system group, or a
Group 2 heat pump (`_PASHUB_HEAT_PUMP_CONTROL_TO_SAP10`), Group 3 heat
network (`_PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10`), Group 4 electric storage
(`_PASHUB_STORAGE_HEATER_CONTROL_TO_SAP10`) or Group 6 room heater
(`_PASHUB_ROOM_HEATER_CONTROL_TO_SAP10`). Any other system group, or a
label the group's lookup does not cover, strict-raises `UnmappedPasHubLabel`
(naming the label and system) rather than silently taking a wrong-group code
the gap is fixed here, never mis-rated or strict-raised deep in the
@ -8015,6 +8129,8 @@ def _pashub_main_heating_control_code(
system = system_type.lower()
if system in _PASHUB_BOILER_SYSTEM_TYPES:
group_map = _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10
elif system in _PASHUB_HEAT_PUMP_SYSTEM_TYPES:
group_map = _PASHUB_HEAT_PUMP_CONTROL_TO_SAP10
elif system in _PASHUB_HEAT_NETWORK_SYSTEM_TYPES:
group_map = _PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10
elif system in _PASHUB_STORAGE_HEATER_SYSTEM_TYPES:
@ -8025,8 +8141,9 @@ def _pashub_main_heating_control_code(
raise UnmappedPasHubLabel(
"main heating control",
f"{control_label!r} on unsupported system {system_type!r} "
f"(only Table 4e Group 1 boiler, Group 3 heat-network, Group 4 "
f"electric-storage + Group 6 room-heater codes are mapped)",
f"(only Table 4e Group 1 boiler, Group 2 heat-pump, Group 3 "
f"heat-network, Group 4 electric-storage + Group 6 room-heater "
f"codes are mapped)",
)
code = group_map.get(control_label)
if code is None:
@ -8071,23 +8188,41 @@ def _pashub_main_heating_system_code(main: PasHubMainHeating) -> Optional[int]:
return None
def _pashub_guard_storage_control_coherence(
# The Manual-Entry (non-HHR) Table 4a storage codes the 2404→2402 coherence
# downgrade below applies to. Deliberately narrow: any other pairing keeps the
# fail-loud raise.
_PASHUB_NON_HHR_STORAGE_CODES: frozenset[int] = frozenset(
_PASHUB_STORAGE_HEATER_TYPE_TO_SAP10.values()
)
def _pashub_resolve_storage_control_coherence(
main_heating_control: Union[int, str], sap_main_heating_code: Optional[int]
) -> None:
) -> Union[int, str]:
"""SAP 10.2 Table 4e footnote: the high-heat-retention storage control code
2404 "applies to high heat retention storage heaters (code 409) only". A
survey pairing 2404 with a non-HHR storage type (402/404) is a genuine
surveyor contradiction fail loud rather than rate a mismatched pair. The
converse (409 with a non-2404 charge control, e.g. 2402) is spec-legal and
passes through."""
2404 "applies to high heat retention storage heaters (code 409) only", and
RdSAP 10 p.80 (survey item 7-3) makes an HHR lodgement carry the PCDB index
of each heater so a Manual Entry survey pairing 2404 with a non-HHR
storage type (402/404) can never be lodged as ticked. The surveyed type
field is authoritative and the control resolves to 2402 (automatic charge
control) the pairing the accredited cert 0764-2894-7173-2720-5535
(62 Mayfair Road, UPRN 77066640 the blocked Wythenshawe fixture's own
dwelling) lodges for exactly this heater description, and the code that
preserves the tick's automatic-charging semantics (a manual-charge 2401
would contradict it). Any other incoherent pairing still fails loud rather
than rating a mismatched pair. The converse (409 with a non-2404 charge
control, e.g. 2402) is spec-legal and passes through."""
is_hhr_control = main_heating_control == 2404
if is_hhr_control and sap_main_heating_code != _PASHUB_STORAGE_HEATER_HHR_CODE:
raise UnmappedPasHubLabel(
"main heating control",
"'Controls for high heat retention storage heaters' (2404) lodged "
f"against non-HHR storage system code {sap_main_heating_code} "
"(Table 4e: 2404 applies to code 409 only)",
)
if not is_hhr_control or sap_main_heating_code == _PASHUB_STORAGE_HEATER_HHR_CODE:
return main_heating_control
if sap_main_heating_code in _PASHUB_NON_HHR_STORAGE_CODES:
return 2402
raise UnmappedPasHubLabel(
"main heating control",
"'Controls for high heat retention storage heaters' (2404) lodged "
f"against non-HHR storage system code {sap_main_heating_code} "
"(Table 4e: 2404 applies to code 409 only)",
)
def _pashub_heat_emitter_code(emitter_label: str) -> Union[int, str]:
@ -8201,6 +8336,7 @@ def _pashub_wall_insulation_type_int(label: Optional[str]) -> Union[int, str]:
_PASHUB_GLAZING_TYPE_TO_SAP10: Dict[str, int] = {
"Double glazing installed before 2002": 3, # double pre-2002 (U 2.8)
"Double glazing installed between 2002 - 2021": 2, # double 2002-2022 (U 2.0)
"Triple glazing installed between 2002 - 2021": 9, # triple 2002-2022 (U 2.0)
"Double glazing, Unknown install date": 3, # unknown → pre-2002 default (U 2.8)
"Double glazing installed during or after 2022": 13, # double 2022+ (U 1.4)
}

View file

@ -157,6 +157,30 @@ class TestCommunityHeatingMapping:
assert main.sap_main_heating_code == 301
assert main.main_fuel_type == 51
def test_community_biomass_boilers_lodge_table_12_biomass_fuel(self) -> None:
# Arrange — PasHub renders the community fuel with the Table 12 row
# wording (`Heat from boilers - biomass`), unlike the Elmhurst §14.1
# labels (`Biomass`).
data = load("pashub_rdsap_site_notes_example1.json")
mh = data["heating_and_hot_water"]["main_heating"]
mh["system_type"] = "Community heating system"
mh["community_heat_source"] = "Community heating - boilers only"
mh["fuel"] = "Heat from boilers - biomass"
mh["controls"] = (
"Charging system linked to use of community heating, room thermostat only"
)
mh["product_id"] = 0
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
# Assert — Table 12 code 43 (heat from boilers — biomass).
assert main.sap_main_heating_code == 301
assert main.main_fuel_type == 43
def test_unknown_heat_source_strict_raises(self) -> None:
# Arrange
data = load("pashub_rdsap_site_notes_example1.json")
@ -170,6 +194,22 @@ class TestCommunityHeatingMapping:
with pytest.raises(UnmappedPasHubLabel, match="community heat source"):
EpcPropertyDataMapper.from_site_notes(survey)
def test_unknown_community_fuel_strict_raises(self) -> None:
# Arrange — a recognised heat source with an uncovered fuel label. It
# must strict-raise at the boundary rather than pass the raw string
# through to the calculator's deep `MissingMainFuelType` (ADR-0015).
data = load("pashub_rdsap_site_notes_example1.json")
mh = data["heating_and_hot_water"]["main_heating"]
mh["system_type"] = "Community heating system"
mh["community_heat_source"] = "Community heating - boilers only"
mh["fuel"] = "Some novel fuel"
mh["controls"] = ""
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act / Assert
with pytest.raises(UnmappedPasHubLabel, match="community fuel"):
EpcPropertyDataMapper.from_site_notes(survey)
class TestRoomInRoofMapping:
"""A surveyed room-in-roof block (RdSAP §3.10 detailed measurement) must
@ -393,6 +433,23 @@ class TestCylinderInsulationTypeCoding:
assert result.sap_heating is not None
assert result.sap_heating.cylinder_insulation_type == 1
def test_loose_jacket_codes_to_jacket(self) -> None:
# Arrange — "Loose jacket" → code 2 (gov-API coding; Elmhurst
# "Jacket" sibling). SAP 10.2 Table 2 Note 1 bills a loose jacket a
# separate, ~2× higher storage-loss factor than factory foam.
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["water_heating"]["insulation_type"] = (
"Loose jacket"
)
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act
result = EpcPropertyDataMapper.from_site_notes(survey)
# Assert
assert result.sap_heating is not None
assert result.sap_heating.cylinder_insulation_type == 2
def test_unknown_label_strict_raises(self) -> None:
# Arrange
data = load("pashub_rdsap_site_notes_example1.json")
@ -575,6 +632,29 @@ class TestFloorExposureMapping:
assert upper.is_exposed_floor is False
assert upper.is_above_partially_heated_space is False
def test_same_dwelling_below_lodges_party_floor_with_no_heat_loss(self) -> None:
# "Same dwelling below" is an internal floor between heated storeys of
# one dwelling — RdSAP 10 §3 gives it NO floor heat loss. Mirrors the
# gov-API code-8 precedent: normalize to the "(another dwelling
# below)" suppression string `heat_transmission.py` substring-matches
# to zero the part's floor loss, and keep both Table 20/§5.14
# exposure flags off (it is neither exposed nor above a partially
# heated space).
# Arrange
data = load("pashub_rdsap_site_notes_example1.json")
data["building_construction"]["floor"]["floor_type"] = "Same dwelling below"
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act
result = EpcPropertyDataMapper.from_site_notes(survey)
# Assert — the party-floor suppression string and no exposure flags.
part = result.sap_building_parts[0]
assert part.floor_type == "(another dwelling below)"
ground = self._ground(part)
assert ground.is_exposed_floor is False
assert ground.is_above_partially_heated_space is False
def test_ground_floor_type_leaves_both_flags_off(self) -> None:
# Arrange — the fixture as lodged ("Ground Floor").
survey = from_dict(
@ -777,6 +857,7 @@ class TestCylinderSizeCoding:
"label, expected_code",
[
("Normal (90-130 litres)", 2),
("Medium (131-170 litres)", 3), # Table 28 Medium → fixed 160 L
("Large (>170 litres)", 4),
("No Access", 2), # fed from the (gas) main → Table 28 "otherwise"
],
@ -832,6 +913,7 @@ class TestMechanicalVentilationKindCoding:
("Mechanical Extract - Centralised", "EXTRACT_OR_PIV_OUTSIDE"),
("Positive Input - From Outside", "EXTRACT_OR_PIV_OUTSIDE"),
("Positive Input - From Loft (As Natural)", None),
("MV - Heat Recovery", "MVHR"),
],
)
def test_label_maps_to_mechanical_kind(
@ -1095,6 +1177,10 @@ class TestSecondaryHeatingTypeCoding:
# Electric direct-acting (PCDB 691, eff 1.00) — the modal cohort
# heater; 691 == the old 693 fallback efficiency, so unchanged.
("Panel, convector or radiant heaters", 691),
# Electric water/oil-filled radiators (PCDB 694, eff 1.00);
# unhyphenated secondary-field spelling, distinct from the
# hyphenated main-heating room-heater label.
("Water or oil filled radiators", 694),
# Gas room heaters (PCDB Table 4a column A).
(
"Flush fitting live fuel effect open front fire, "
@ -1106,6 +1192,14 @@ class TestSecondaryHeatingTypeCoding:
"sealed to fireplace",
603,
),
# Pre-1980 open-fronted gas fire on an open flue, NOT sealed to
# the fireplace (no qualifier in the label) — PCDB 601, eff 50%;
# 602 (with back boiler) rejected, no back-boiler qualifier.
("Pre 1980 - Open front fire with open flue", 601),
# Room-sealed (balanced flue) gas fire/wall heater — PCDB 609
# ("Gas fire or wall heater, balanced flue", flue RS, eff 58%),
# the only balanced-flue code in the 601-613 gas block.
("Fire or wall heater with balanced flue", 609),
# Solid fuel (PCDB 631, eff 0.32 col B).
("Open fire in grate", 631),
("No Secondary Heating", None), # no secondary heater lodged
@ -1133,6 +1227,31 @@ class TestSecondaryHeatingTypeCoding:
EpcPropertyDataMapper.from_site_notes(survey)
class TestSecondaryFuelTypeCodingDualFuel:
"""The secondary-heating survey field lodges the dual-fuel appliance
title-cased ("Dual Fuel Appliance (Mineral and Wood)"), unlike the
sentence-case main-fuel label already mapped exact-string lookups, so
both tables need their own entry. Same physical fuel, same Table 32
code 10 the main-fuel path pins."""
def test_title_case_dual_fuel_maps_to_table_32_code_10(self) -> None:
# Arrange
data = load("pashub_rdsap_site_notes_example1.json")
data["heating_and_hot_water"]["secondary_heating"][
"secondary_fuel"
] = "Dual Fuel Appliance (Mineral and Wood)"
data["heating_and_hot_water"]["secondary_heating"][
"secondary_system"
] = "Open fire in grate"
survey = from_dict(PasHubRdSapSiteNotes, data)
# Act
result = EpcPropertyDataMapper.from_site_notes(survey)
# Assert
assert result.sap_heating.secondary_fuel_type == 10
class TestSecondaryFuelTypeCoding:
"""`from_site_notes` must int-code the PAS Hub secondary-heating
`secondary_fuel` label to the SAP 10.2 Table 32 `secondary_fuel_type`
@ -1223,6 +1342,7 @@ class TestGlazingTypeCoding:
[
("Double glazing installed before 2002", 3), # double pre-2002, U 2.8
("Double glazing installed between 2002 - 2021", 2), # double 2002-2022, U 2.0
("Triple glazing installed between 2002 - 2021", 9), # triple 2002-2022, U 2.0
("Double glazing, Unknown install date", 3), # unknown → pre-2002 default, U 2.8
("Double glazing installed during or after 2022", 13), # double 2022+, U 1.4
("", ""), # no lodging — passes through (calc nulls it)
@ -1265,6 +1385,7 @@ class TestMainFuelCoding:
# was dropping. 10 = epc_codes `main_fuel` dual fuel (mineral + wood).
("Dual fuel appliance (mineral and wood)", 10),
("Electricity", 30), # standard-electricity fuel code
("Heating oil", 28), # oil (not community); Table 32 code 4
],
)
def test_label_maps_to_sap_code(self, label: str, expected_code: int) -> None:
@ -2478,7 +2599,10 @@ class TestPasHubMainHeatingControlCoding:
# mapper against drifting out of sync with the calculator's coverage.
from datatypes.epc.domain.mapper import (
_PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10, # pyright: ignore[reportPrivateUsage]
_PASHUB_HEAT_PUMP_CONTROL_TO_SAP10, # pyright: ignore[reportPrivateUsage]
_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10, # pyright: ignore[reportPrivateUsage]
_PASHUB_ROOM_HEATER_CONTROL_TO_SAP10, # pyright: ignore[reportPrivateUsage]
_PASHUB_STORAGE_HEATER_CONTROL_TO_SAP10, # pyright: ignore[reportPrivateUsage]
)
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_CONTROL_TYPE_BY_CODE, # pyright: ignore[reportPrivateUsage]
@ -2486,7 +2610,10 @@ class TestPasHubMainHeatingControlCoding:
mapped_codes = {
*_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10.values(),
*_PASHUB_HEAT_PUMP_CONTROL_TO_SAP10.values(),
*_PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10.values(),
*_PASHUB_STORAGE_HEATER_CONTROL_TO_SAP10.values(),
*_PASHUB_ROOM_HEATER_CONTROL_TO_SAP10.values(),
}
unknown = {c for c in mapped_codes if c not in _CONTROL_TYPE_BY_CODE}
@ -2569,6 +2696,145 @@ class TestPasHubMainHeatingControlCoding:
with pytest.raises(UnmappedPasHubLabel, match="Heat pump"):
EpcPropertyDataMapper.from_site_notes(survey)
def test_heat_pump_control_maps_to_group2_code(self) -> None:
# Arrange — a Table 4e Group 2 heat pump (`system_type = "Heat pump
# with radiators or underfloor heating"`). Its control label carries
# the Group 2 (22xx) code, not the boiler 2106: "Programmer, room
# thermostat and TRVs" → 2210, control type 2 (PCDB
# `heat_pump_controls`).
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_control_type, # pyright: ignore[reportPrivateUsage]
)
raw = load("pashub_rdsap_site_notes_example1.json")
mh = raw["heating_and_hot_water"]["main_heating"]
mh["system_type"] = "Heat pump with radiators or underfloor heating"
mh["fuel"] = "Electricity"
mh["controls"] = "Programmer, room thermostat and TRVs"
survey = from_dict(PasHubRdSapSiteNotes, raw)
# Act
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
# Assert — the Group 2 code, its spec control type, and Table 4a
# category 4: the calculator's Appendix N3.6/N3.7 heat-pump
# efficiency path gates on `main_heating_category == 4`, so without
# it the lodged PCDB heat pump is billed as 100%-efficient direct
# electric (47 SAP on the two LRHA WAVE 3 heat-pump fixtures).
assert main.main_heating_control == 2210
assert _control_type(main) == 2
assert main.main_heating_category == 4
def test_heat_pump_unmapped_control_label_strict_raises(self) -> None:
# A heat-pump control label the Group 2 lookup does not cover
# strict-raises (naming label + system), never silently mis-rating.
raw = load("pashub_rdsap_site_notes_example1.json")
mh = raw["heating_and_hot_water"]["main_heating"]
mh["system_type"] = "Heat pump with radiators or underfloor heating"
mh["fuel"] = "Electricity"
mh["controls"] = "Some novel control"
survey = from_dict(PasHubRdSapSiteNotes, raw)
with pytest.raises(
UnmappedPasHubLabel, match="Heat pump with radiators"
):
EpcPropertyDataMapper.from_site_notes(survey)
def test_room_heater_no_thermostatic_control_maps_to_group6_code(self) -> None:
# Arrange — a Table 4e Group 6 room-heater dwelling with the lowest-
# control label: "No thermostatic control of room temperature" → 2601
# (PCDB `room_heater_systems_controls`, control type 2, +0.3 °C MIT
# adjustment) — one step below the already-mapped 2602.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_control_type, # pyright: ignore[reportPrivateUsage]
)
raw = load("pashub_rdsap_site_notes_example1.json")
mh = raw["heating_and_hot_water"]["main_heating"]
mh["system_type"] = "Room heaters"
mh["community_heat_source"] = "Panel, Convector or radiant heaters"
mh["fuel"] = "Electricity"
mh["product_id"] = 0
mh["controls"] = "No thermostatic control of room temperature"
survey = from_dict(PasHubRdSapSiteNotes, raw)
# Act
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
# Assert
assert main.main_heating_control == 2601
assert _control_type(main) == 2
def test_gas_room_heater_type_maps_to_table_4a_code_603(self) -> None:
# Arrange — a gas room-heater main system (verbatim fixture
# 500797805807 lodgement): open flue, 1980+, open fronted, sealed to
# fireplace → Table 4a code 603 (PCDB "sitting proud of, and sealed
# to, fireplace opening", eff 63%). 604 (with back boiler) and 605
# (flush-fitting live fuel effect) rejected — neither qualifier is in
# the label.
raw = load("pashub_rdsap_site_notes_example1.json")
mh = raw["heating_and_hot_water"]["main_heating"]
mh["system_type"] = "Room heaters"
mh["community_heat_source"] = (
"Gas fire, Open flue, 1980 or later (Open fronted), "
"Sealed to fireplace"
)
mh["fuel"] = "Mains Gas"
mh["product_id"] = 0
mh["controls"] = "No thermostatic control of room temperature"
survey = from_dict(PasHubRdSapSiteNotes, raw)
# Act
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
# Assert — the gas Table 4a row and the mains-gas fuel code.
assert main.sap_main_heating_code == 603
assert main.main_fuel_type == 26
@pytest.mark.parametrize(
"heater_type, expected_system_code",
[
("Modern slimline storage heater", 402),
("Fan storage heater", 404),
],
)
def test_hhr_control_on_manual_entry_non_hhr_storage_downgrades_to_2402(
self, heater_type: str, expected_system_code: int
) -> None:
# Arrange — a Manual Entry (no PCDB index) storage lodgement whose
# surveyed type is non-HHR but whose Controls tick is "Controls for
# high heat retention storage heaters" (2404). SAP 10.2 Table 4e
# footnote pins 2404 to code 409 only, and RdSAP 10 p.80 (survey item
# 7-3) requires a PCDB index for any HHR lodgement — absent one, the
# type field is authoritative and the control resolves to 2402
# (automatic charge control), the pairing the accredited cert
# 0764-2894-7173-2720-5535 (62 Mayfair Road, UPRN 77066640 — the
# blocked Wythenshawe fixture's dwelling) actually lodges.
raw = load("pashub_rdsap_site_notes_example1.json")
mh = raw["heating_and_hot_water"]["main_heating"]
mh["system_type"] = "Electric storage heaters"
mh["community_heat_source"] = heater_type
mh["fuel"] = "Electricity"
mh["product_id"] = 0
mh["controls"] = "Controls for high heat retention storage heaters"
survey = from_dict(PasHubRdSapSiteNotes, raw)
# Act
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
# Assert — the surveyed type stands; the un-lodgeable HHR control
# downgrades to the non-HHR automatic charge control.
assert main.sap_main_heating_code == expected_system_code
assert main.main_heating_control == 2402
def test_heat_network_control_maps_to_group3_code(self) -> None:
# Arrange — a Table 4e Group 3 heat network (`system_type =
# "Community heating system"`). Its control label carries a Group 3
@ -2602,6 +2868,54 @@ class TestPasHubMainHeatingControlCoding:
assert main.main_heating_control == 2308
assert _control_type(main) == 2
@pytest.mark.parametrize(
"label, code, control_type",
[
# Linked-to-use charging (DHW factor 1.00) with TRVs → type 3.
(
"Charging system linked to use of community heating, "
"programmer and TRVs",
2306,
3,
),
(
"Charging system linked to use of community heating, "
"room thermostat and TRVs",
2314,
3,
),
# Flat-rate charging (DHW factor 1.05) with thermostat + TRVs
# → type 2.
("Flat rate charging, room thermostat and TRVs", 2313, 2),
],
)
def test_heat_network_charging_control_maps_to_group3_code(
self, label: str, code: int, control_type: int
) -> None:
# Arrange — a complete Table 4e Group 3 community lodgement with the
# charging-control label under test.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_control_type, # pyright: ignore[reportPrivateUsage]
)
raw = load("pashub_rdsap_site_notes_example1.json")
mh = raw["heating_and_hot_water"]["main_heating"]
mh["system_type"] = "Community heating system"
mh["community_heat_source"] = "Community heating - boilers only"
mh["fuel"] = "Mains Gas"
mh["product_id"] = 0
mh["controls"] = label
survey = from_dict(PasHubRdSapSiteNotes, raw)
# Act
main = EpcPropertyDataMapper.from_site_notes(
survey
).sap_heating.main_heating_details[0]
# Assert
assert main.main_heating_control == code
assert _control_type(main) == control_type
def test_heat_network_unmapped_control_label_strict_raises(self) -> None:
# A community-system control label the Group 3 lookup does not cover
# strict-raises (naming label + system), never silently mis-rating.