From b2b6f8e95488660a22635ddd995f9a2fe394dcd7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 15 Jun 2026 23:57:25 +0000 Subject: [PATCH] fix(mapper): map Elmhurst "Value known" cylinder to measured volume (code 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Elmhurst Summary §15.1 lodges "Cylinder Size: Value known" with the measured volume in the "Cylinder Volume (l)" line — the Summary-path equivalent of the gov-API "Exact" descriptor. The mapper had no entry for "Value known" so `_elmhurst_cylinder_size_code` raised UnmappedElmhurstLabel, and even once mapped the measured volume was never threaded through, so the cascade dropped the cylinder storage loss (~468 kWh/yr) from (219) water heating on every measured-volume-cylinder Summary. Per RdSAP 10 §10.5 Table 28 (p.55) a measured cylinder volume is used directly. Map "Value known" → cascade code 6 (Exact) and thread the §15.1 "Cylinder Volume (l)" value into SapHeating.cylinder_volume_measured_l, which `_cylinder_volume_l_from_code` (cert_to_inputs.py:5281) already reads for code 6 — mirroring the gov-API path (mapper.py:1575/1885). Pins simulated case 39 (P960-0001-001431): an age-A mid-terrace on direct- acting electric room heaters (SAP code 691, cat 10, control 2602) with electric-immersion DHW off a 117 L "Value known" cylinder. The full extractor→mapper→calculator cascade now reproduces the worksheet's SAP-rating block EXACTLY — SAP value 36.6365 (band F) and (272) CO2 2056.0731 kg/yr, with (219) water heating 2637.5049 and (255) total energy cost 1802.0039. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 13 ++ .../tests/fixtures/Summary_001431_case39.pdf | Bin 0 -> 79548 bytes datatypes/epc/domain/mapper.py | 15 +++ datatypes/epc/surveys/elmhurst_site_notes.py | 5 + .../epc/domain/test_mapper_cylinder_size.py | 60 +++++++++ .../_elmhurst_worksheet_001431_case39.py | 121 ++++++++++++++++++ 6 files changed, 214 insertions(+) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf create mode 100644 tests/datatypes/epc/domain/test_mapper_cylinder_size.py create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 55dd04d6..11e94fba 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1528,6 +1528,18 @@ class ElmhurstSiteNotesExtractor: first = cylinder_ins_thickness_raw.split()[0] if first.isdigit(): cylinder_insulation_thickness_mm = int(first) + # §15.1 "Cylinder Volume (l)" — the measured volume lodged alongside + # a "Value known" Cylinder Size. The value is written as a decimal + # ("117.00"); take the integer part for the cascade's measured-volume + # field (gov-API "Exact" descriptor, code 6). + cylinder_volume_raw = self._local_val(cylinder_lines, "Cylinder Volume (l)") + cylinder_volume_measured_l: Optional[int] = None + if cylinder_volume_raw: + first = cylinder_volume_raw.split()[0] + try: + cylinder_volume_measured_l = int(float(first)) + except ValueError: + cylinder_volume_measured_l = None cylinder_thermostat_raw = self._local_val( cylinder_lines, "Cylinder Thermostat", ) @@ -1560,6 +1572,7 @@ class ElmhurstSiteNotesExtractor: cylinder_size_label=cylinder_size_label, cylinder_insulation_label=cylinder_insulation_label, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, + cylinder_volume_measured_l=cylinder_volume_measured_l, cylinder_thermostat=cylinder_thermostat, immersion_type=immersion_type, ) diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf new file mode 100644 index 0000000000000000000000000000000000000000..137985f25d8ccbc179b01134beb2aed99f0ffa2b GIT binary patch literal 79548 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 SapHeating: ), cylinder_insulation_type=cylinder_insulation_type_field, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm_field, + # §15.1 "Cylinder Volume (l)" — measured volume for a "Value known" + # cylinder (cascade code 6 / Exact). None unless a cylinder is + # present; the cascade reads it only when `cylinder_size == 6`. + cylinder_volume_measured_l=( + survey.water_heating.cylinder_volume_measured_l + if survey.water_heating.hot_water_cylinder_present + else None + ), # Cascade reads `cylinder_thermostat == "Y"` (string compare) per # `cert_to_inputs.py:2252` / `:2218`. Map the bool to the Y/N # string the cascade expects; None when no cylinder is present. diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 3d5b2b21..eded346f 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -369,6 +369,11 @@ class WaterHeating: cylinder_insulation_label: Optional[str] = None # §15.1 "Insulation Thickness" lodging in mm (an integer or None). cylinder_insulation_thickness_mm: Optional[int] = None + # §15.1 "Cylinder Volume (l)" lodging — the measured cylinder volume in + # litres, present when "Cylinder Size" is lodged as "Value known" + # (the Summary-path equivalent of the gov-API "Exact" descriptor, + # cascade code 6). None when no cylinder is present or the line is absent. + cylinder_volume_measured_l: Optional[int] = None # §15.1 "Cylinder Thermostat" lodging (Yes / No). False or absent # keeps the cascade's no-thermostat Table 2b temperature factor. cylinder_thermostat: Optional[bool] = None diff --git a/tests/datatypes/epc/domain/test_mapper_cylinder_size.py b/tests/datatypes/epc/domain/test_mapper_cylinder_size.py new file mode 100644 index 00000000..b8d03a9a --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_cylinder_size.py @@ -0,0 +1,60 @@ +"""Mapper boundary: the Elmhurst §15.1 "Cylinder Size" label. + +A cylinder lodged "Value known" carries a measured volume in the §15.1 +"Cylinder Volume (l)" line — the Summary-path equivalent of the gov-API +"Exact" descriptor. Per RdSAP 10 §10.5 Table 28 (p.55) the measured volume +is used directly; cascade code 6 routes `_cylinder_volume_l_from_code` to +the lodged `cylinder_volume_measured_l`. Before this was mapped the label +raised `UnmappedElmhurstLabel`, blocking every measured-volume-cylinder +Summary. +""" + +from datatypes.epc.domain.mapper import ( + UnmappedElmhurstLabel, + _elmhurst_cylinder_size_code, # pyright: ignore[reportPrivateUsage] +) + + +def test_value_known_label_maps_to_exact_code_6() -> None: + # Arrange + label = "Value known" + + # Act + code = _elmhurst_cylinder_size_code(label, cylinder_present=True) + + # Assert + assert code == 6 + + +def test_value_known_label_with_no_cylinder_maps_to_none() -> None: + # Arrange + label = "Value known" + + # Act + code = _elmhurst_cylinder_size_code(label, cylinder_present=False) + + # Assert + assert code is None + + +def test_normal_label_still_maps_to_code_2() -> None: + # Arrange + label = "Normal" + + # Act + code = _elmhurst_cylinder_size_code(label, cylinder_present=True) + + # Assert + assert code == 2 + + +def test_unknown_label_still_raises() -> None: + # Arrange + label = "Spray-on unicorn cylinder" + + # Act / Assert + try: + _elmhurst_cylinder_size_code(label, cylinder_present=True) + except UnmappedElmhurstLabel: + return + raise AssertionError("expected UnmappedElmhurstLabel for an unknown label") diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py new file mode 100644 index 00000000..88547fbc --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py @@ -0,0 +1,121 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 39" worksheet — an age-A (pre-1900) mid-terrace heated by +**direct-acting electric room heaters** (SAP code 691, category 10, control +2602 appliance thermostats), with an electric room-heater secondary (also +691) and electric-immersion DHW (WHC 903) off a **measured-volume hot-water +cylinder** ("Cylinder Size: Value known", 117 L, foam 38 mm), on a single +(standard) electricity meter. + +This case was generated to probe the API-corpus's worst-served cohort +(category-10 direct-acting electric, 46% within-0.5). It exposed a real +Summary-path gap: the §15.1 "Cylinder Size: Value known" lodging (the +Summary equivalent of the gov-API "Exact" descriptor) was unmapped, so the +extractor/mapper raised `UnmappedElmhurstLabel` and — once that was mapped — +the measured "Cylinder Volume (l)" was not threaded through, dropping the +cylinder storage loss (~468 kWh/yr) from (219) water heating. Wiring the +measured volume (cascade code 6 → `_cylinder_volume_l_from_code`) closes the +whole cascade EXACTLY. + +Like 000565 / the _rr cases / case 20 / 21 / 38, this fixture does NOT hand- +build the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises +the WHOLE extractor + mapper + calculator pipeline. + +Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/ +simulated case 39/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf` so the +test runs without depending on the unstaged workspace. + +Worksheet pin targets (P960-0001-001431, "11a. SAP rating" / "12a. CO2 +emissions" block — the UK-average-climate rating block our cascade +reproduces; the P960's separate postcode-climate EPC block (272)=1803.19 is +a known regional-climate gap, not a SAP-rating divergence): +- SAP value (un-rounded, before (258) integer rounding) = 36.6365 (band F) +- (272) Total CO2, kg/year = 2056.0731 + +Per [[feedback-zero-error-strict]] + [[feedback-continuous-sap-tolerance]]: +pins are abs <= 1e-3 against the worksheet PDF (printed to 4 dp). +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import calculate_sap_from_inputs +from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case39.pdf" +) + +LINE_258_SAP_VALUE_CONTINUOUS: Final[float] = 36.6365 +LINE_272_TOTAL_CO2_KG_PER_YR: Final[float] = 2056.0731 +_PIN_ABS: Final[float] = 1e-3 + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label/value token sequences). + Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-39 Summary through extractor + mapper. + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + +def test_case39_measured_volume_cylinder_reproduces_the_worksheet_sap_and_co2() -> None: + # Arrange — the full extractor -> mapper -> calculator pipeline on the + # simulated case-39 Summary (direct-electric room heaters + electric + # immersion DHW off a "Value known" 117 L measured-volume cylinder). + epc = build_epc() + + # Act + result = calculate_sap_from_inputs(cert_to_inputs(epc)) + + # Assert — the SAP-rating block reproduces the worksheet exactly. + assert ( + abs(result.sap_score_continuous - LINE_258_SAP_VALUE_CONTINUOUS) + <= _PIN_ABS + ) + assert abs(result.co2_kg_per_yr - LINE_272_TOTAL_CO2_KG_PER_YR) <= _PIN_ABS