From 11ecac94dc098252bc9c423f8440eef1f9e7eb5b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 00:09:42 +0000 Subject: [PATCH] Slice S0380.127: resolve Elmhurst "No Access" cylinder via RdSAP 10 Table 28 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elmhurst Summary §15.1 sometimes lodges "Cylinder Size: No Access" (the inaccessible-cylinder lodging form). Pre-slice the mapper strict-raised `UnmappedElmhurstLabel` because `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10` only carried the three lodged-size labels (Normal/Medium/Large). Per RdSAP 10 Specification Table 28 page 55 ("Cylinder size"): > "Inaccessible: > - if off-peak electric dual immersion: 210 litres > - if from solid fuel boiler: 160 litres > - otherwise: 110 litres" And per §10.5.1 page 53: > "An electric immersion is assumed dual in the following cases: > - cylinder is inaccessible and electricity tariff is dual" So the 210-L "off-peak electric dual immersion" branch fires automatically when both (a) cylinder is inaccessible AND (b) water heating is electric AND (c) meter type is dual / off-peak (no separate dual-immersion lodging required). New helper `_resolve_elmhurst_inaccessible_cylinder_size` keys off §15.0 "Water Heating Fuel Type" + §14.2 "Electricity meter type": - solid fuel water heating fuel (Anthracite, House coal, Wood, etc.) → 160 L → SAP10 cylinder_size enum 3 (Medium) - "Electricity" + dual/18-hour/24-hour/off-peak meter → 210 L → SAP10 cylinder_size enum 4 (Large) - otherwise → 110 L → SAP10 cylinder_size enum 2 (Normal) `_elmhurst_cylinder_size_code` extended with optional water_heating_fuel + meter_type kwargs; the single call site at line 4459 threads `survey.water_heating.water_heating_fuel_type` and `survey.meters.electricity_meter_type`. Property 001431 (the heating-systems corpus dwelling) lodges `pcdb 1` with §14.0 Potterton oil boiler (PCDF 716) + §15.0 "Water Heating Fuel Type: Heating oil" + §14.2 "Electricity meter type: 18 Hour" — water fuel is oil (not electric, not solid fuel) → "otherwise" branch → 110 L → enum 2 (Normal). `pcdb 1` now cascade-executes (corpus tally 34 → 35 OK / 41 populated). Extended handover suite at HEAD post-slice: **831 pass, 0 fail** (was 830 + 1 new AAA test). Pyright net-zero on touched files (45 → 45 — pre-existing errors unrelated). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 33 +++++++ datatypes/epc/domain/mapper.py | 87 ++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 3abbd7f5..925c3294 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -236,6 +236,39 @@ def test_summary_001479_mapper_extensions_count_matches_extension_bps() -> None: assert len(epc.sap_building_parts) == 3 +def test_summary_001431_pcdb_1_inaccessible_cylinder_resolves_to_normal_per_rdsap_10_table_28() -> None: + # Arrange — Heating-systems corpus fixture 001431 / "pcdb 1" lodges + # §15.1 "Cylinder Size: No Access" (the Elmhurst inaccessible-cylinder + # lodging form). Per RdSAP 10 Specification Table 28 page 55: + # + # "Inaccessible: + # - if off-peak electric dual immersion: 210 litres + # - if from solid fuel boiler: 160 litres + # - otherwise: 110 litres" + # + # pcdb 1 lodges §14.0 Main Heating as a Potterton oil boiler (PCDF + # 716) + §15.0 Water Heating Fuel Type "Heating oil" → not an + # electric dual immersion, not a solid fuel boiler → the spec's + # "otherwise" branch → **110 litres** = SAP10 cylinder_size enum 2 + # (Normal per `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`). + # + # Pre-slice the mapper strict-raised `UnmappedElmhurstLabel` on the + # "No Access" string because `_elmhurst_cylinder_size_code` only + # carried the three lodged-size dict entries (Normal/Medium/Large). + summary_pdf = ( + Path(__file__).parents[3] + / "sap worksheets/heating systems examples/pcdb 1/Summary_001431.pdf" + ) + pages = _summary_pdf_to_textract_style_pages(summary_pdf) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + assert epc.sap_heating.cylinder_size == 2 + + def test_summary_001431_electric_1_underfloor_heating_resolves_to_in_screed_per_rdsap_10_section_10_11() -> None: # Arrange — Heating-systems corpus fixture 001431 / "electric 1" lodges # §14.0 "Heat Emitter: Underfloor Heating" (bare form, no subtype diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2ce63201..33f996a1 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4127,8 +4127,73 @@ _ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10: Dict[str, int] = { } +# Elmhurst §15.0 "Water Heating Fuel Type" labels that route to solid- +# fuel Table 32 codes (Anthracite, House coal, Wood logs/pellets, etc.). +# Used by `_resolve_elmhurst_inaccessible_cylinder_size` to detect the +# "from solid fuel boiler" branch of RdSAP 10 Table 28 page 55. +_ELMHURST_SOLID_FUEL_WATER_HEATING_LABELS: frozenset[str] = frozenset({ + "Anthracite", + "House coal", + "Manufactured smokeless fuel", + "Wood logs", + "Wood pellets", + "Wood chips", + "Dual fuel (mineral and wood)", + "Coal", +}) + +# Elmhurst §14.2 "Electricity meter type" labels that signify off-peak +# / dual metering (where an inaccessible electric immersion is assumed +# dual per RdSAP 10 §10.5.1 → Table 28 "off-peak electric dual +# immersion" 210 L branch). +_ELMHURST_DUAL_OFF_PEAK_METER_LABELS: frozenset[str] = frozenset({ + "Dual", + "Dual (24 hour)", + "18 Hour", + "Off-peak 18 hour", + "10 Hour", +}) + + +def _resolve_elmhurst_inaccessible_cylinder_size( + water_heating_fuel_label: str, + meter_type_label: str, +) -> int: + """RdSAP 10 Specification Table 28 page 55 — derive cylinder size + when §15.1 lodges "No Access" / Inaccessible. + + Spec rule verbatim: + + "Inaccessible: + - if off-peak electric dual immersion: 210 litres + - if from solid fuel boiler: 160 litres + - otherwise: 110 litres" + + Returns SAP10 cylinder_size enum (per + `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`): + 2 (Normal) → 110 L — modal "otherwise" branch + 3 (Medium) → 160 L — solid fuel boiler + 4 (Large) → 210 L — off-peak electric dual immersion + + Per RdSAP 10 §10.5.1: "An electric immersion is assumed dual in the + following cases: cylinder is inaccessible and electricity tariff + is dual" — so the 210-L branch fires automatically when both + conditions hold (no separate "is_dual_immersion" lodging needed). + """ + if water_heating_fuel_label in _ELMHURST_SOLID_FUEL_WATER_HEATING_LABELS: + return 3 # Medium / 160 L + is_electric = water_heating_fuel_label.startswith("Electricity") + is_off_peak = meter_type_label in _ELMHURST_DUAL_OFF_PEAK_METER_LABELS + if is_electric and is_off_peak: + return 4 # Large / 210 L + return 2 # Normal / 110 L + + def _elmhurst_cylinder_size_code( - cylinder_size_label: Optional[str], cylinder_present: bool, + cylinder_size_label: Optional[str], + cylinder_present: bool, + water_heating_fuel_label: Optional[str] = None, + meter_type_label: Optional[str] = None, ) -> Optional[int]: """Map an Elmhurst §15.1 "Cylinder Size" label to the SAP10 cascade enum. Returns None when no cylinder is present or the @@ -4136,9 +4201,25 @@ def _elmhurst_cylinder_size_code( `UnmappedElmhurstLabel` when the label IS lodged but isn't in `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10` — that's a mapper-coverage gap that should be made explicit so the next fixture forces a dict - entry, not silently routed off the HW-with-cylinder cascade path.""" + entry, not silently routed off the HW-with-cylinder cascade path. + + The bare lodging "No Access" (Inaccessible) routes through + `_resolve_elmhurst_inaccessible_cylinder_size` per RdSAP 10 + Table 28 page 55.""" if not cylinder_present or cylinder_size_label is None: return None + if cylinder_size_label == "No Access": + if water_heating_fuel_label is None or meter_type_label is None: + raise UnmappedElmhurstLabel( + "cylinder_size", + ( + "lodged 'No Access' requires water_heating fuel + " + "meter context to apply RdSAP 10 Table 28 (p.55)" + ), + ) + return _resolve_elmhurst_inaccessible_cylinder_size( + water_heating_fuel_label, meter_type_label, + ) code = _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10.get(cylinder_size_label) if code is None: raise UnmappedElmhurstLabel("cylinder_size", cylinder_size_label) @@ -4459,6 +4540,8 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating: cylinder_size=_elmhurst_cylinder_size_code( survey.water_heating.cylinder_size_label, survey.water_heating.hot_water_cylinder_present, + water_heating_fuel_label=survey.water_heating.water_heating_fuel_type, + meter_type_label=survey.meters.electricity_meter_type, ), cylinder_insulation_type=_elmhurst_cylinder_insulation_code( survey.water_heating.cylinder_insulation_label,