diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf new file mode 100644 index 00000000..8c42d24a Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf differ diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case7.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case7.py new file mode 100644 index 00000000..b538b320 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case7.py @@ -0,0 +1,134 @@ +"""Mapper-driven cascade fixture for the Elmhurst P960-0001-001431 +"simulated case 7" worksheet — the CONDENSING-OIL-COMBI variant of +[[case 6]], generated to validate the combi HW + space efficiency path +that golden cert 0240-0200-5706-2365-8010 exercises. + +Routes the Summary PDF through ElmhurstSiteNotesExtractor + +from_elmhurst_site_notes (no hand-built EpcPropertyData) so the pin +exercises the whole extractor + mapper + calculator pipeline. + +WHY THIS FIXTURE EXISTS +----------------------- +Case 6 is SAP code 127 ("Condensing oil *boiler*", regular) + a 110 L +cylinder — so it never exercised the COMBI instantaneous-DHW efficiency +path. 0240 is SAP code 130 ("Condensing combi oil boiler") with NO +cylinder. Case 7 is case 6 with that single difference swapped in: + + - both mains → SAP code 130 (Table 4b winter 82 / summer 73); + - NO hot-water cylinder → combi instantaneous DHW (WHC 901), Table 3a + keep-hot combi loss (61), no primary/storage loss; + - boiler interlock PRESENT (combi + room thermostat 2106, no cylinder) + → NO −5pp penalty, base eff 82/73 — the OPPOSITE of case 6. + +The dual-main rads(2106, 51%) + UFH(2110, 49%) different-parts structure, +the 6 "Roof of Room" rooflights, and the fabric are unchanged from case 6. + +WHAT IT PROVED +-------------- +The cascade reproduces the case-7 worksheet EXACTLY at abs=1e-4 on every +top-level output with ZERO calculator changes — the condensing-combi +(130) + no-cylinder + dual-main + Appendix D Eq D1 path is already +correct. This fixture is a regression lock on that path; it did NOT +require a fix. (It also exonerates the combi mechanism as the source of +0240's API-path residual — see docs/HANDOVER_0240_CLOSURE.md.) + +Combi-path worksheet line refs (P960-0001-001431, Block 1): +- (206)/(207) main space-heating efficiency = 82.0000 / 82.0000 (base, + interlock present, no −5pp). +- (216) water-heater efficiency (summer base) = 73.0000. +- (217)m water-heater monthly efficiency = combi blend 73.00 → 80.18. +- (61)m combi loss = 50.9589 (Jan) … = 600 kWh/yr flat (Table 3a + keep-hot, daily HW volume > 100 L every month so the "no keep-hot" + fu-scaling collapses to 1.0). +- (59)m primary loss = 0 and storage loss = 0 (combi, no cylinder). +- (211) space-heating fuel main 1 = 7865.4304. +- (213) space-heating fuel main 2 = 7556.9821. +- (219) water-heating fuel = 3496.8121. +- (64) HW demand total = 2712.0619 (smaller dwelling than 0240's + 2842.82 — case 7 validates the combi *mechanism*, not 0240's absolute + demand). + +Per [[feedback-zero-error-strict]]: e2e pins are abs=1e-4 against the PDF +(see test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case7"]). + +Source: user-simulated PDFs at `sap worksheets/golden fixture +debugging/simulated case 7/`. Summary mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf`. +""" + +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 + +# 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_case7.pdf" +) + +# Worksheet (211)/(213) per-system space-heating fuel (kWh/yr). Both mains +# are condensing oil combis (SAP code 130, Table 4b 82/73) at base +# efficiency — interlock present (combi + room thermostat, no cylinder), +# so NO −5pp penalty (the case-6 boiler+cylinder had no cylinder stat → a +# −5pp penalty; the combi removes it). +LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7865.4304 +LINE_213_MAIN_2_FUEL_KWH: Final[float] = 7556.9821 + +# Worksheet (219) water-heating fuel (kWh/yr). Combi instantaneous DHW +# (WHC 901) — SAP 10.2 Appendix D Eq D1 blends the monthly water-heater +# efficiency (217)m by the DHW boiler's (204) space share; Table 3a +# keep-hot combi loss (61) = 600 kWh/yr; no primary/storage loss. +LINE_219_HOT_WATER_FUEL_KWH: Final[float] = 3496.8121 + +# Worksheet (206)/(207) main space-heating efficiency — base 82, no +# −5pp (interlock present). Watch these if the pin ever regresses: a +# silent interlock flip drops them to 77/68. +LINE_206_MAIN_1_EFFICIENCY_PCT: Final[float] = 82.0 +LINE_207_MAIN_2_EFFICIENCY_PCT: Final[float] = 82.0 + + +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 (mirror of the case-6 helper).""" + 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-7 Summary through extractor + mapper.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 93df288e..1d36e443 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -43,6 +43,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_6035 as _w001431_6035, _elmhurst_worksheet_001431_case5 as _w001431_case5, _elmhurst_worksheet_001431_case6 as _w001431_case6, + _elmhurst_worksheet_001431_case7 as _w001431_case7, ) from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( ALL_FIXTURES as _ELMHURST_FIXTURES, @@ -259,6 +260,24 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = { lighting_kwh_per_yr=357.6571, pumps_fans_kwh_per_yr=356.0, ), + # Mapper-driven cohort entry — Summary_001431_case7.pdf → extractor → + # mapper → calculator. Case 6 with the heating swapped to a CONDENSING + # OIL COMBI (SAP code 130, Table 4b 82/73) with NO cylinder — combi + # instantaneous DHW (WHC 901), Table 3a keep-hot combi loss (61), no + # primary/storage loss, boiler interlock PRESENT (no −5pp). Validates + # the combi HW + space efficiency path that golden cert 0240 uses; + # reproduces every line ref EXACTLY with no calculator change. + # main_heating_fuel_kwh_per_yr is the (211)+(213) two-system sum. + "001431_case7": FixtureCascadePins( + sap_score=73, sap_score_continuous=72.6153, ecf=1.9631, + total_fuel_cost_gbp=1123.3372, co2_kg_per_yr=5738.9315, + space_heating_kwh_per_yr=12646.3783, + main_heating_fuel_kwh_per_yr=15422.4125, + secondary_heating_fuel_kwh_per_yr=0.0, + hot_water_kwh_per_yr=3496.8121, + lighting_kwh_per_yr=357.6571, + pumps_fans_kwh_per_yr=356.0, + ), } @@ -276,6 +295,7 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = { "001431_6035": _w001431_6035, "001431_case5": _w001431_case5, "001431_case6": _w001431_case6, + "001431_case7": _w001431_case7, }