test(modelling): Solar PV Elmhurst cascade pins + battery tripwire

Slice 9 of the Solar PV Recommendation Generator (ADR-0026). Pins the
overlay→calculator PV cascade against Elmhurst's before/after re-lodgements of
cert 001431, across the orientation/pitch/overshading config space (the certs
lodge synthetic 1.00 kWp test vectors):
 - SE/SW, shaded (overshading 3/2), pitch 30°/45°
 - E/W, unshaded, pitch 60°/45°
 - NW/N, unshaded, pitch 60°/45° (the low-yield orientations)
Each hand-built SolarOverlay reproduces the relodged after at abs ≤ 1e-4 on
SAP / CO2 / primary energy.

Battery tripwire (per user): the "with battery" cert lodges a §19 5 kWh battery
the current extractor does NOT parse, so it scores identically to its
no-battery twin — the no-battery overlay reproduces it today, and the pin will
fail (alerting us to switch to the with-battery overlay) once the extractor
parses the battery. A companion test pins that the calculator already models
the 5 kWh battery (it raises SAP), so the fix target is established.

All five certs share an EES 'WGK' / SAP-code-502 main-heating lodgement the
mapper doesn't yet derive a fuel for; the pins patch the shared fuel (mains gas
26) identically on before+after to isolate the PV delta (the solar overlay
never touches heating), and `test_solar_before_baselines` xfails as the
forcing-function tripwire for that separate mapper-front gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 12:42:24 +00:00
parent b249f69cb2
commit 2f3b1dbd3f
6 changed files with 193 additions and 1 deletions

View file

@ -22,6 +22,9 @@ import pytest
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
PhotovoltaicArray,
PvBatteries,
PvBattery,
)
from domain.modelling.scoring.package_scorer import PackageScorer, Score
from domain.modelling.product import Product
@ -30,7 +33,11 @@ from domain.modelling.generators.floor_recommendation import recommend_floor_ins
from domain.modelling.generators.roof_recommendation import (
recommend_roof_insulation,
)
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
SolarOverlay,
)
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.generators.solid_wall_recommendation import (
@ -747,3 +754,188 @@ def test_gas_boiler_instant_hw_before_baselines() -> None:
# Act / Assert — currently raises MissingMainFuelType.
Sap10Calculator().calculate(before)
# --- Solar PV cascade pins (ADR-0026) -------------------------------------
#
# The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp,
# varied orientation/pitch/overshading) — deterministic test vectors chosen to
# exercise the overlay -> calculator PV path across the config space, NOT the
# Google-derived production arrays. So these pins hand-build the SolarOverlay
# matching each after-cert's lodged arrays (the generator's own overlay is
# Google-sourced and validated separately in test_solar_recommendation /
# test_solar_overshading); the cascade proves `_fold_solar` + the calculator
# reproduce Elmhurst's PV re-lodgement exactly.
#
# All five certs share one main-heating system lodged with EES code 'WGK' /
# Main Heating SAP code 502, which the Elmhurst mapper does not yet derive a
# main_fuel_type for (it maps to '' -> MissingMainFuelType). The solar overlay
# never touches heating, so the pins patch the shared fuel to mains gas (26) on
# both before and after identically — the heating contribution is then equal on
# both sides and the delta isolates the PV change. The unresolved raw baseline
# is a separate mapper-front gap, tripwired by `test_solar_before_baselines`.
_SOLAR_MAINS_GAS_FUEL: Final[int] = 26
def _parse_solar(fixture_name: str) -> EpcPropertyData:
"""Parse a solar before/after Summary, patching the shared main-heating
fuel to mains gas (the EES 'WGK' / SAP code 502 mapper-front gap see the
section note). Applied identically on before + after, so it cancels in the
PV delta."""
epc: EpcPropertyData = parse_recommendation_summary(fixture_name)
main = epc.sap_heating.main_heating_details[0]
if not main.main_fuel_type:
main.main_fuel_type = _SOLAR_MAINS_GAS_FUEL
return epc
def test_solar_overlay_reproduces_relodged_after_se_sw_shaded() -> None:
# Arrange — two shaded planes: SE (octant 4) at 30° pitch under significant
# shading (code 3), and SW (octant 6) at 45° pitch under modest shading
# (code 2); each a 1.00 kWp array. Exercises the overshading + orientation
# spread.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_1.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=2, orientation=4, overshading=3),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=6, overshading=2),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solar_overlay_reproduces_relodged_after_e_w_unshaded() -> None:
# Arrange — E (octant 3) at 60° pitch and W (octant 7) at 45° pitch, both
# unshaded (code 1). Exercises the steeper pitches with no shading.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_2.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=3, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=7, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solar_overlay_reproduces_relodged_after_nw_n_unshaded() -> None:
# Arrange — NW (octant 8) at 60° pitch and N (octant 1) at 45° pitch, both
# unshaded. The least-productive orientations (the N plane in particular)
# exercise the low-yield end of the SAP Appendix M output.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_3.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_battery_cert_currently_reproduced_by_the_no_battery_overlay() -> None:
# Tripwire (user-requested): the "with battery" cert lodges a §19 5 kWh
# battery, but the current Elmhurst extractor does NOT parse it (the parsed
# EpcPropertyData has pv_batteries=None). So the cert currently scores
# identically to its no-battery twin, and the *no-battery* overlay (same NW/N
# arrays) reproduces it exactly. When the extractor learns to parse the §19
# Batteries block, the after-cert will gain ~+1.1 SAP from the 5 kWh battery
# and THIS PIN WILL FAIL — the fix is then to switch to the with-battery
# overlay below (which the calculator already models, see the next test).
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_with_battery_001431_after.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_battery_overlay_raises_sap_above_its_no_battery_twin() -> None:
# The calculator DOES model a PV battery (App M monthly self-consumption), so
# the recommendation's battery variant is a meaningful, higher-SAP Option —
# even though the example cert's battery is not yet parsed. This pins the fix
# target for the tripwire above: once the extractor parses the §19 battery,
# the with-battery overlay should reproduce the (then battery-bearing) cert.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
arrays = [
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
]
no_battery = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=arrays,
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
with_battery = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=arrays,
pv_diverter_present=True,
is_dwelling_export_capable=True,
pv_batteries=PvBatteries(pv_battery=PvBattery(battery_capacity=5.0)),
)
)
# Act
scorer = PackageScorer(Sap10Calculator())
sap_no_battery: float = scorer.score(before, [no_battery]).sap_continuous
sap_with_battery: float = scorer.score(before, [with_battery]).sap_continuous
# Assert — the 5 kWh battery raises SAP by a meaningful margin.
assert sap_with_battery > sap_no_battery + 1e-3
_SOLAR_FUEL_GAP_REASON: Final[str] = (
"Blocked on the Elmhurst mapper deriving main_fuel_type for the main heating "
"lodged with EES code 'WGK' / Main Heating SAP code 502: it currently maps to "
"'' (empty), so Sap10Calculator raises MissingMainFuelType when baselining the "
"raw solar before. The solar overlay never touches heating, so the cascade "
"pins above patch the shared fuel to mains gas (26) on before + after to "
"isolate the PV delta — only baselining the unmodified before is blocked. "
"Flips green once the mapper derives mains gas from the WGK/502 lodgement. "
"Owner: mapper/extractor front."
)
@pytest.mark.xfail(strict=True, reason=_SOLAR_FUEL_GAP_REASON)
def test_solar_before_baselines() -> None:
# The Modelling pipeline baselines the dwelling before modelling it, so the
# before must be scorable on its own. This solar cert is not yet: its main
# fuel is unresolved (see reason). A failing tripwire for the mapper fix.
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"solar_pv_001431_before.pdf"
)
# Act / Assert — currently raises MissingMainFuelType.
Sap10Calculator().calculate(before)