diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py index 52c6d4d54..d734b17d7 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py @@ -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 = ( diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_wythenshawe.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_wythenshawe.py index eef150c8a..350cb7bc6 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_wythenshawe.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_wythenshawe.py @@ -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 " diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 4159bf658..3d79ab863 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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) } diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index d5dc35ffc..7d8dedfc6 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -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.