diff --git a/backend/documents_parser/extractor.py b/backend/documents_parser/extractor.py index d06017dba..c9f26e254 100644 --- a/backend/documents_parser/extractor.py +++ b/backend/documents_parser/extractor.py @@ -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], ) diff --git a/backend/documents_parser/tests/test_extractor.py b/backend/documents_parser/tests/test_extractor.py index 3a57fcb56..a855d8016 100644 --- a/backend/documents_parser/tests/test_extractor.py +++ b/backend/documents_parser/tests/test_extractor.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3d79ab863..8b2c4ef9a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 7d8dedfc6..07b93ff5f 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -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" diff --git a/datatypes/epc/surveys/pashub_rdsap_site_notes.py b/datatypes/epc/surveys/pashub_rdsap_site_notes.py index 121b7ca17..988ef01ad 100644 --- a/datatypes/epc/surveys/pashub_rdsap_site_notes.py +++ b/datatypes/epc/surveys/pashub_rdsap_site_notes.py @@ -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]: