From 43a86d66c2e77c3bf2096386e6aaf9c0b6765b46 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 20:44:13 +0000 Subject: [PATCH] Slice S0380.9: multi-array PV support + close cert 0350 to ASHP spec floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors Elmhurst `Renewables` PV detail from four scalar fields (pv_peak_power_kw / pv_orientation / pv_elevation_deg / pv_overshading — single-array shape) to `pv_arrays: List[ElmhurstPvArray]`, then walks the §19.0 PV Panel block in 4-tuples so dwellings with multiple PV arrays surface every array. Forced by cert 0350-2968-2650-2796-5255 (Summary_000903.pdf), the second ASHP cohort cert through the Summary path and first to lodge multiple PV arrays — the dr87 worksheet pins 2 arrays at 1.50 kWp each (one SE at 45°, one NW at 45°). Pre-slice the extractor's hardcoded "break at len(values) == 4" capped output at one array regardless of how many the PDF lodged. Three-layer end-to-end change: 1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add `ElmhurstPvArray` dataclass (kw, orientation, elevation_deg, overshading); replace four `Renewables.pv_*` scalars with `pv_arrays: List[ElmhurstPvArray] = field(default_factory=list)`. 2. `backend/documents_parser/elmhurst_extractor.py` — rename `_extract_pv_array_detail` → `_extract_pv_arrays`; walk values after the "Photovoltaic panel details" anchor in 4-tuples until a stop token ("batteries"/"export"/etc.) or a §-header closes the block. §-header regex tightened to `\d{1,2}\.\d\s+\w` so kWp values like "1.50" don't trip the close (without the `\s+\w` the regex matched both "20.0 Wind Turbine" AND "1.50"). 3. `datatypes/epc/domain/mapper.py` — `_elmhurst_pv_arrays` iterates the list and emits one `PhotovoltaicArray` per row; collapses empty list → None so the cascade keeps its no-PV fallback. Forcing function: cert 0350 first-attempt Summary SAP closes from Δ -4.5829 (Slice 8 baseline) to Δ **+0.0458** — within the ±0.07 ASHP-cohort spec-precision floor. PV export credit GBP moves from 158.91 (one array surfaced) to 265.99 (both arrays surfaced) — the extra ~107 GBP of avoided cost lifts cert 0350's SAP by ~4.6 points. This validates the structural-debt-amortizes hypothesis: cert 0350 needed only TWO new slices (S0380.8 inheritance + S0380.9 multi-PV) beyond the cert 0380 closure work, vs cert 0380's 6 slices from scratch. Subsequent cohort certs should converge similarly fast as fixture-specific gaps are paid down. Added two tests: - `test_summary_0350_surfaces_two_pv_arrays` — unit test pinning the multi-array contract on the mapper boundary. - `test_summary_0350_full_chain_sap_within_spec_floor_of_worksheet` — chain test pinning Δ < ±0.07 (matches cert 0380's chain test). Cert 0380 (single-array, 3 kWp) continues to pass its chain test + all 6 unit-level pins — the refactor preserves single-array behaviour. Pyright net-zero across all four edited files: datatypes/epc/domain/mapper.py: 32 (baseline) datatypes/epc/surveys/elmhurst_site_notes.py: 0 backend/documents_parser/elmhurst_extractor.py: 0 backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0 Regression suite: 677 pass + 10 fail (= handover baseline 669 + 10 + 8 new GREEN unit+chain tests across Slices S0380.2..S0380.9). Fixtures added: `backend/documents_parser/tests/fixtures/Summary_ 000903.pdf` (copied from `sap worksheets/Additional data with api/ 0350-2968-2650-2796-5255/`). Spec refs: - SAP 10.2 Appendix M (PDF p.103) — multiple PV arrays sum to total electricity generation per Equation M-1 (each array's surface flux computed independently per Appendix U3.3). - SAP 10.2 Appendix U3.3 (PDF p.124) — per-array surface flux keyed on orientation + tilt + overshading. - Cert 0350 worksheet `dr87-0001-000903.pdf` (29a Main 19.4575 W/K + Ext1 1.3025 W/K = 20.7600 ≡ Summary cascade walls_w_per_k; (39) avg HTC 173.4202 ≡ Summary cascade; (64) HW 2084.66 ÷ (216) HW eff 1.7285 = 1206.04 ≡ Summary cascade hot_water_kwh_per_yr). Co-Authored-By: Claude Opus 4.7 --- .../documents_parser/elmhurst_extractor.py | 102 +++++++++++------- .../tests/fixtures/Summary_000903.pdf | Bin 0 -> 79675 bytes .../tests/test_summary_pdf_mapper_chain.py | 61 +++++++++++ datatypes/epc/domain/mapper.py | 35 +++--- datatypes/epc/surveys/elmhurst_site_notes.py | 25 +++-- 5 files changed, 161 insertions(+), 62 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_000903.pdf diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index e8a90d91..4e222bc8 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -21,6 +21,7 @@ from datatypes.epc.surveys.elmhurst_site_notes import ( Shower, SurveyorInfo, VentilationAndCooling, + ElmhurstPvArray, WallDetails, WaterHeating, Window, @@ -1153,8 +1154,6 @@ class ElmhurstSiteNotesExtractor: hydro_raw = self._next_val("Electricity generated [kWh/year]") hydro = float(hydro_raw) if hydro_raw else 0.0 - pv = self._extract_pv_array_detail() - return Renewables( solar_water_heating=self._bool_val("Solar Water Heating"), wwhrs_present=self._bool_val("Is WWHRS present in the property?"), @@ -1164,69 +1163,94 @@ class ElmhurstSiteNotesExtractor: wind_turbine_present=self._bool_val("Wind turbine present?"), wind_turbines_terrain_type=terrain, hydro_electricity_generated_kwh=hydro, - pv_peak_power_kw=pv[0], - pv_orientation=pv[1], - pv_elevation_deg=pv[2], - pv_overshading=pv[3], + pv_arrays=self._extract_pv_arrays(), ) - def _extract_pv_array_detail( - self, - ) -> tuple[Optional[float], Optional[str], Optional[int], Optional[str]]: + def _extract_pv_arrays(self) -> List[ElmhurstPvArray]: """Parse the Elmhurst Summary §19.0 PV Panel section. Returns - (kw_peak, orientation, elevation_deg, overshading) when the cert - lodges measured PV; (None, None, None, None) when absent. + one `ElmhurstPvArray` per lodged array, or [] when absent. - The Summary's PV block looks like: + The Summary's PV block looks like (single-array, e.g. cert 0380): Photovoltaic panel details PV Cells kW Peak Orientation Elevation Overshading - 2.36 - South-West + 3.00 + South-East 45° None Or Little - — the 4 values follow the header block in a known order, one - per line. Anchor on "Photovoltaic panel details" → skip the - header lines → read 4 values. + Multi-array (e.g. cert 0350 lodges 2 arrays): + ... + 1.50 + South-East + 45° + None Or Little + 1.50 + North-West + 45° + None Or Little + + — each array is 4 values in (kW Peak, Orientation, Elevation, + Overshading) order. Anchor on "Photovoltaic panel details", + skip header lines, then read values in 4-tuples until the + section breaks at the next §header or end-of-array tokens + (Batteries / Export / Capacity / etc.). """ anchor = "Photovoltaic panel details" try: idx = next(i for i, l in enumerate(self._lines) if l == anchor) except StopIteration: - return (None, None, None, None) - # The 4 header lines after the anchor are: - # "PV Cells kW Peak Orientation", "Elevation", "Overshading" - # followed by 4 value lines. Slice the next ~10 lines and - # filter the first 4 entries that look like values (not - # headers). - tail = self._lines[idx + 1 : idx + 12] + return [] + # The header lines after the anchor are: "PV Cells kW Peak + # Orientation", "Elevation", "Overshading". Subsequent lines + # carry values for one OR MORE arrays. Stop at the next + # §-header (a "20.0" or "21.0") or post-PV section tokens + # ("Batteries", "Connected to", "Diverter", "Capacity", etc.). header_tokens = {"pv cells", "kw peak", "orientation", "elevation", "overshading"} + stop_tokens = { + "batteries", "capacity known", "capacity", + "connected to the dwelling's meter", "diverter present", + "export capable meter", + } values: List[str] = [] - for line in tail: + for line in self._lines[idx + 1:]: stripped = line.strip() if not stripped: continue lower = stripped.lower() + if lower in stop_tokens: + break + # Next §-header (e.g. "20.0 Wind Turbine") closes the block — + # match "." so kWp values + # like "1.50" don't trip the close. + if re.match(r"^\d{1,2}\.\d\s+\w", stripped): + break if any(h in lower for h in header_tokens): continue values.append(stripped) - if len(values) == 4: - break - if len(values) < 4: - return (None, None, None, None) - try: - kwp = float(values[0]) - except ValueError: - return (None, None, None, None) - orientation = values[1] - # Elevation lodged as "45°" — strip trailing degree symbol. - m = re.match(r"^(\d+)", values[2]) - elevation = int(m.group(1)) if m else None - overshading = values[3] - return (kwp, orientation, elevation, overshading) + # Walk values in 4-tuples; an incomplete trailing tuple is dropped. + arrays: List[ElmhurstPvArray] = [] + for i in range(0, len(values) - 3, 4): + try: + kwp = float(values[i]) + except ValueError: + continue + orientation = values[i + 1] + # Elevation lodged as "45°" — strip trailing degree symbol. + m = re.match(r"^(\d+)", values[i + 2]) + if m is None: + continue + elevation = int(m.group(1)) + overshading = values[i + 3] + arrays.append(ElmhurstPvArray( + peak_power_kw=kwp, + orientation=orientation, + elevation_deg=elevation, + overshading=overshading, + )) + return arrays def extract(self) -> ElmhurstSiteNotes: emissions_raw = self._next_val("Emissions (t/year)") diff --git a/backend/documents_parser/tests/fixtures/Summary_000903.pdf b/backend/documents_parser/tests/fixtures/Summary_000903.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0f37659005362ddd9be2e72a2b984b753edb0d4b GIT binary patch literal 79675 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 None: assert epc.sap_heating.cylinder_thermostat == "Y" +def test_summary_0350_surfaces_two_pv_arrays() -> None: + # Arrange — cert 0350's Summary §19.0 Photovoltaic Panel block + # lodges TWO arrays (L 503-510): + # 1.50 kWp / South-East / 45° / None Or Little + # 1.50 kWp / North-West / 45° / None Or Little + # The Elmhurst extractor's `_extract_pv_array_detail` hardcodes a + # single 4-value reader (loop breaks at `len(values) == 4`) and + # the `Renewables` dataclass exposes only 4 scalar PV fields — + # together they cap output at one array regardless of how many the + # PDF lodges. Cert 0380 (single-array) is unaffected; cert 0350 + # is the first multi-array cohort cert. Without both arrays the + # cascade halves the PV export credit and the SAP score drops. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000903_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + assert epc.sap_energy_source is not None + arrays = epc.sap_energy_source.photovoltaic_arrays + assert arrays is not None + assert len(arrays) == 2 + # Both arrays at 1.5 kWp; order matches PDF row order. + assert arrays[0].peak_power == 1.5 + assert arrays[1].peak_power == 1.5 + + def test_summary_0350_ext1_inherits_main_wall_insulation_thickness() -> None: # Arrange — cert 0350-2968-2650-2796-5255 is a multi-bp dwelling # (Main + 1st Extension). Its Summary §7 Walls block lodges @@ -650,6 +678,39 @@ def test_summary_0350_ext1_inherits_main_wall_insulation_thickness() -> None: assert ext1_bp.wall_insulation_thickness == "100mm" +def test_summary_0350_full_chain_sap_within_spec_floor_of_worksheet() -> None: + # Arrange — cert 0350-2968-2650-2796-5255 (Summary_000903.pdf / + # dr87-0001-000903.pdf) is the second heat-pump cert under per-cert + # Summary-path mapper validation and the first multi-bp cohort + # cert: Mitsubishi PUZ-WM50VHA ASHP (PCDB index 104568), main + # dwelling + 1 extension, 2 PV arrays (2x 1.5 kWp at SE / NW). + # Worksheet PDF "SAP value" line lodges unrounded SAP **84.1367**. + # + # First-attempt closure (validating the structural-debt-amortizes + # hypothesis): after Slices S0380.2..S0380.6 (which were forced by + # cert 0380) the cohort HP routing + cylinder block were already + # in place; cert 0350 needed only TWO new slices: + # - Slice S0380.8: extension "As Main Wall" inheritance copies + # `insulation_thickness_mm` (cert 0380 was single-bp, didn't + # exercise the inheritance path). + # - Slice S0380.9: refactor Elmhurst `Renewables` to support + # multiple PV arrays per dwelling (cert 0380 was single-array, + # didn't exercise multi-array PV). + # Both fixes are structural and apply cohort-wide. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000903_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Act + result = calculate_sap_from_inputs( + cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + + # Assert — ±0.07 ASHP-cohort spec-floor tolerance. + worksheet_unrounded_sap = 84.1367 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < _ASHP_COHORT_CHAIN_TOLERANCE + + def test_summary_0380_full_chain_sap_within_spec_floor_of_worksheet() -> None: # Arrange — cert 0380-2471-3250-2596-8761 (Summary_000899.pdf / # dr87-0001-000899.pdf) is the first heat-pump cert under per-cert diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c381adea..d3ccbd83 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3067,26 +3067,29 @@ def _elmhurst_pv_arrays( ) -> Optional[List[PhotovoltaicArray]]: """Build the Appendix M / Appendix U3.3 cost-offset cascade's input list from the Elmhurst Summary §19.0 PV detail. Returns None when - the cert hasn't lodged measured PV (no kW Peak value) — the cohort - PV-absent path the cascade already handles correctly. + the cert hasn't lodged measured PV — the cohort PV-absent path the + cascade already handles correctly. - All four §19.0 inputs (kW peak + orientation + elevation + - overshading) are required for a meaningful Appendix M output; - missing any of them collapses to None so the cascade defers to - the legacy `photovoltaic_supply.percent_roof_area` fallback. + Each lodged §19.0 row (a `ElmhurstPvArray`) becomes one + `PhotovoltaicArray` entry. Single-array dwellings (cohort cert + 0380: 3 kWp) and multi-array dwellings (cohort cert 0350: 2x 1.5 + kWp at distinct orientations) go through the same iterator. """ - if renewables.pv_peak_power_kw is None or renewables.pv_peak_power_kw <= 0.0: + if not renewables.pv_arrays: return None - if renewables.pv_orientation is None or renewables.pv_elevation_deg is None: - return None - return [ - PhotovoltaicArray( - peak_power=renewables.pv_peak_power_kw, - pitch=_elmhurst_pv_pitch_code(renewables.pv_elevation_deg), - orientation=_elmhurst_orientation_int(renewables.pv_orientation), - overshading=_elmhurst_pv_overshading_int(renewables.pv_overshading), + out: List[PhotovoltaicArray] = [] + for arr in renewables.pv_arrays: + if arr.peak_power_kw <= 0.0: + continue + out.append( + PhotovoltaicArray( + peak_power=arr.peak_power_kw, + pitch=_elmhurst_pv_pitch_code(arr.elevation_deg), + orientation=_elmhurst_orientation_int(arr.orientation), + overshading=_elmhurst_pv_overshading_int(arr.overshading), + ) ) - ] + return out or None # RdSAP 10 §11.1 PV pitch enum (degrees → integer code consumed by diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 85b63c07..27833d14 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -259,13 +259,24 @@ class Renewables: wind_turbines_terrain_type: str hydro_electricity_generated_kwh: float # PV array detail (Elmhurst Summary §19.0 "Photovoltaic Panel" - # block: kW Peak, Orientation, Elevation, Overshading). Populated - # when the cert lodges measured PV; absent (None / "" / 0.0) - # otherwise. Drives Appendix M / Appendix U3.3 cost-offset cascade. - pv_peak_power_kw: Optional[float] = None - pv_orientation: Optional[str] = None # e.g. "South-West" - pv_elevation_deg: Optional[int] = None # e.g. 45 - pv_overshading: Optional[str] = None # e.g. "None Or Little" + # block: a list of (kW Peak, Orientation, Elevation, Overshading) + # rows). Empty list when the cert hasn't lodged measured PV. + # Drives Appendix M / Appendix U3.3 cost-offset cascade — both the + # single-array (cohort cert 0380) and multi-array (cohort cert + # 0350: 2x 1.5 kWp) layouts go through the same list. + pv_arrays: List["ElmhurstPvArray"] = field( + default_factory=lambda: [] # type: ignore[reportUnknownLambdaType] + ) + + +@dataclass +class ElmhurstPvArray: + """One Photovoltaic array row from Summary §19.0. The four fields + match the columns in the PDF's PV Panel block.""" + peak_power_kw: float + orientation: str # e.g. "South-West" + elevation_deg: int # e.g. 45 + overshading: str # e.g. "None Or Little" @dataclass