diff --git a/tests/domain/modelling/fixtures/solar_pv_001431_before.pdf b/tests/domain/modelling/fixtures/solar_pv_001431_before.pdf new file mode 100644 index 00000000..190f4aad Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_001431_before.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_1.pdf b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_1.pdf new file mode 100644 index 00000000..f38fbe1c Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_1.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_2.pdf b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_2.pdf new file mode 100644 index 00000000..0cc5da41 Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_2.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_3.pdf b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_3.pdf new file mode 100644 index 00000000..bf3f1e36 Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_no_battery_001431_after_3.pdf differ diff --git a/tests/domain/modelling/fixtures/solar_pv_with_battery_001431_after.pdf b/tests/domain/modelling/fixtures/solar_pv_with_battery_001431_after.pdf new file mode 100644 index 00000000..5a76ea7e Binary files /dev/null and b/tests/domain/modelling/fixtures/solar_pv_with_battery_001431_after.pdf differ diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index b4e564be..63a84026 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -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)