Slice 99e: PV pitch enum-not-degrees + cert 9501 Layer 2 chain test

`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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 21:45:07 +00:00
parent 4264e0ad4b
commit 0735c7e81c
2 changed files with 52 additions and 2 deletions

View file

@ -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

View file

@ -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