Model/tests/domain/modelling/_elmhurst_recommendation.py
Khalim Conn-Kowlessar 4c0a907a54 test(modelling): Elmhurst before/after cascade pin for cavity wall (#1154)
Closes #1154 — the Package Scorer's Elmhurst cascade pin. Drives
recommend_cavity_wall on the parsed `before` Summary, scores its
Option's overlay through PackageScorer, and asserts delta 0 (abs<=1e-4
on SAP/CO2/PE) vs the calculator's score on the re-lodged `after`
Summary.

Key finding: the handover's stated parser gate (parse_site_notes_pdf
throwing 'Manufacturer' on cert 001431) does NOT block these pins. The
Elmhurst recommendation Summaries route cleanly through the same
ElmhurstSiteNotesExtractor + EpcPropertyDataMapper chain the worksheet
e2e fixtures use (_elmhurst_worksheet_001431.build_epc). The Textract
path's window bug is unrelated and unused here.

The before→after field change is exactly wall_insulation_type 4
(uninsulated) → 2 (filled cavity), which is precisely the overlay
recommend_cavity_wall emits; the cascade closes at delta 0.000000 on
all three metrics. Before/after Summaries mirrored into
tests/domain/modelling/fixtures/ so the pin does not depend on the
unstaged workspace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:36:53 +00:00

76 lines
3.2 KiB
Python

"""Parse an Elmhurst *recommendation* Summary PDF into an EpcPropertyData.
The Modelling cascade pins use Elmhurst's own before/after measure
re-lodgements as deterministic test vectors: each measure folder under
`sap worksheets/Recommendations Elmhurst Files/` holds a `before` Summary
(the baseline cert) and an `after` Summary (the same cert re-lodged with the
measure applied). Applying the matching Recommendation Generator's overlay to
the parsed `before` must reproduce the calculator's score on the parsed
`after` at delta 0 — proving the overlay is the exact field change Elmhurst
made.
This routes the Summary PDF through the same extractor + mapper chain the
worksheet e2e fixtures use (`_elmhurst_worksheet_001431.build_epc`), NOT the
Textract `parse_site_notes_pdf` path — that path has an unrelated window
extraction bug on cert 001431. The before/after Summaries are mirrored into
`tests/domain/modelling/fixtures/` so the pins do not depend on the unstaged
workspace.
"""
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
_FIXTURES_DIR: Final[Path] = Path(__file__).resolve().parent / "fixtures"
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\\nvalue sequences).
Mirror of the helper in `_elmhurst_worksheet_001431.py`: `pdftotext
-layout` preserves the spatial label/value pairing on each line; we split
on 2+ spaces to surface the tokens, then rejoin newline-delimited.
"""
info: str = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
match = re.search(r"Pages:\s+(\d+)", info)
if match is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(match.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout: str = 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 parse_recommendation_summary(fixture_name: str) -> EpcPropertyData:
"""Parse a before/after recommendation Summary fixture (by file name in
`tests/domain/modelling/fixtures/`) into an EpcPropertyData."""
pdf_path: Path = _FIXTURES_DIR / fixture_name
pages: list[str] = _summary_pdf_to_textract_style_pages(pdf_path)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)