mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
b249f69cb2
commit
2f3b1dbd3f
6 changed files with 193 additions and 1 deletions
BIN
tests/domain/modelling/fixtures/solar_pv_001431_before.pdf
Normal file
BIN
tests/domain/modelling/fixtures/solar_pv_001431_before.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue