Fix six PasHub from_site_notes extractor/mapper defects (#1649)

All six are `from_site_notes`-only (gov-API RdSAP corpus unmoved at 78.9%),
graded against the DB PasHub-assessment oracle (energy_rating_current,
source='lodged'), not the surveyor-entered pre_sap.

A. Manual-Entry boiler efficiency. Extractor read PCDF-Search columns that a
   manual survey lacks; mapper returned None for all boilers → the generic
   0.80 gas default. Now read `Type of boiler:` / `Heating System (Boiler):`
   / `Is there an open flue?` and resolve the descriptor to its SAP 10.2
   Table 4b code. Moves all 7 Wythenshawe manual boilers to the oracle
   (4a Hollyhedge back boiler 63.6→53.4=53; group MAE 2.42→0.42).

B. Room-in-roof gable type None → strict-raise. A form variant omits the
   gable-type line (genuine field-absence); default None → party `gable_wall`
   (U=0.25), mirroring `_api_type_1_gable_kind(None)`. Present-but-unknown
   labels still strict-raise. Unblocks 8 & 34 Bucklow Drive.

C. Secondary `Closed room heater` unmapped → add Table 4a code 633.
   Unblocks 8 Brinkshaw Avenue.

D. Alternative-wall insulation thickness never extracted → parse
   `Wall insulation thickness:` and pass it through instead of the RdSAP 10
   §5.4 100 mm favourable default.

E. `Number of heated rooms?` label mismatch → the PDF lodges
   `Please enter the number of HEATED rooms:`.

F. HW cylinder thermostat dropped (NEW, systematic). `_map_sap_heating`
   never set `cylinder_thermostat`, so a surveyed thermostat defaulted None
   and the calculator applied the SAP 10.2 Table 2b Note a) ×1.3 penalty.
   Map it ("Y"/"N", gated on a lodged cylinder) as the Elmhurst path does.
   Fixes the two worst DB-oracle under-raters (52 Bucklow 59.4→61.8=62;
   13 Kerne Grove 65.0→67.1=67).

DB-oracle cohort (148 matched): within-0.5 83.8%→87.8%, MAE 0.427→0.303.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 11:30:00 +00:00
parent f69ab0b31c
commit b7f127caec
5 changed files with 454 additions and 6 deletions

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

@ -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]: