mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
parent
f69ab0b31c
commit
b7f127caec
5 changed files with 454 additions and 6 deletions
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue