From 0735c7e81c77dd9eda659f2f45a19bebe6220d8d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 26 May 2026 21:45:07 +0000 Subject: [PATCH] Slice 99e: PV pitch enum-not-degrees + cert 9501 Layer 2 chain test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EpcPropertyData.PhotovoltaicArray.pitch` is the RdSAP 10 §11.1 integer code (1=0°, 2=30°, 3=45°, 4=60°, 5=90°) — NOT degrees. The cascade's `cert_to_inputs._PV_PITCH_DEG_BY_CODE` reads the code, not the value. Slice 99d's mapper passed the raw degrees (45) directly, which fell through to the default 30° lookup (Appendix U3.3 S(SW, 30°) ≈ 1029 kWh/m²/yr vs S(SW, 45°) ≈ 1004 — 2.5% over-credit on the PV generation, manifesting as -£6.27 over-credit on total cost → +0.23 SAP delta). Added `_elmhurst_pv_pitch_code` helper that maps the lodged degrees to the nearest tabulated code (snap-to-nearest fallback for non- tabulated tilts; defaults to code 2 / 30° per the cascade's own `_PV_PITCH_DEG_DEFAULT`). Effect on cert 9501 Summary path: - pv_export_credit £256.30 → £250.02 (= worksheet 250.02 exact) - total_fuel_cost £842.94 → £849.21 (= worksheet 849.21 exact) - sap_continuous 68.7577 → **68.5252** (= worksheet 68.5252 exact; Δ -0.0000 at 1e-4) `test_summary_9501_full_chain_sap_matches_worksheet_pdf_exactly` added — the second flat-shaped cert pinned to worksheet SAP at 1e-4 after the cert 0330 / 001479 boiler-house chain tests. Third boiler validation cert closed. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 30 ++++++++++++++++++- datatypes/epc/domain/mapper.py | 24 ++++++++++++++- 2 files changed, 52 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 82594163..9e60c163 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -403,10 +403,38 @@ def test_summary_9501_pv_array_surfaced_from_elmhurst_section_19() -> None: assert len(arrays) == 1 assert abs(arrays[0].peak_power - 2.36) <= 1e-4 assert arrays[0].orientation == 6 # SAP octant: South-West - assert arrays[0].pitch == 45 + assert arrays[0].pitch == 3 # RdSAP §11.1 pitch enum: code 3 = 45° assert arrays[0].overshading == 1 # RdSAP code: None or very little +def test_summary_9501_full_chain_sap_matches_worksheet_pdf_exactly() -> None: + # Arrange — cert 9501-3059-8202-7356-0204 (Summary_000784.pdf / + # dr87-0001-000784.pdf) is the third boiler validation cert and + # the first FLAT in the per-cert mapper validation cohort. + # Mains-gas Vaillant PCDB idx 19007, mid-terrace top-floor flat + # with Room-in-Roof + measured PV (2.36 kWp SW @ 45°). TFA 113.08 + # m². Worksheet PDF "SAP value" line lodges unrounded SAP + # **68.5252**. + # + # Slices 99a-99e jointly closed the Summary path from Δ -5.25 to + # 1e-4: 99a extractor attachment fix (built_form=''), 99b dwelling + # _type identifies top-floor flat (cascade exposure routing), 99c + # RR gables external for flats + SO Solid Brick wall code, 99d + # surface PV array from §19.0, 99e PV pitch enum-not-degrees. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000784_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 — 1e-4 pin (project memory `feedback_zero_error_strict`). + worksheet_unrounded_sap = 68.5252 + assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4 + + def test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None: # Arrange — cert 001479 (Summary_001479.pdf / P960-0001-001479.pdf) # is the first cohort cert with a real GOV.UK EPB API counterpart diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 91a3a888..9482d41c 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2938,13 +2938,35 @@ def _elmhurst_pv_arrays( return [ PhotovoltaicArray( peak_power=renewables.pv_peak_power_kw, - pitch=renewables.pv_elevation_deg, + pitch=_elmhurst_pv_pitch_code(renewables.pv_elevation_deg), orientation=_elmhurst_orientation_int(renewables.pv_orientation), overshading=_elmhurst_pv_overshading_int(renewables.pv_overshading), ) ] +# RdSAP 10 §11.1 PV pitch enum (degrees → integer code consumed by +# `cert_to_inputs._PV_PITCH_DEG_BY_CODE` in the Appendix U3.3 surface- +# flux cascade). Elmhurst lodges the actual degrees value +# ("Elevation: 45°"); the cascade reads the integer code. +_ELMHURST_PV_PITCH_DEG_TO_CODE: Dict[int, int] = { + 0: 1, 30: 2, 45: 3, 60: 4, 90: 5, +} + + +def _elmhurst_pv_pitch_code(elevation_deg: int) -> int: + """Map elevation in degrees → RdSAP 10 §11.1 pitch code (1..5). + Snaps to the nearest tabulated tilt; missing/unknown → code 2 (30°) + to mirror `cert_to_inputs._PV_PITCH_DEG_DEFAULT`.""" + if elevation_deg in _ELMHURST_PV_PITCH_DEG_TO_CODE: + return _ELMHURST_PV_PITCH_DEG_TO_CODE[elevation_deg] + nearest = min( + _ELMHURST_PV_PITCH_DEG_TO_CODE.keys(), + key=lambda d: abs(d - elevation_deg), + ) + return _ELMHURST_PV_PITCH_DEG_TO_CODE[nearest] + + def _elmhurst_pv_overshading_int(description: Optional[str]) -> int: """Map an Elmhurst PV-overshading description to the RdSAP integer code. Falls back to 1 (None or very little, ZPV=1.0) when missing