"""Behaviour of the Solar PV Recommendation Generator (ADR-0026): one "Solar PV" Recommendation of competing whole-array Options — up to five conservatively-sized configs × {no battery, battery} — built from a typed `SolarPotential`. Detection + pricing only; impact is produced by scoring. """ import json from dataclasses import replace from pathlib import Path from typing import Any, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData, PhotovoltaicArray from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.generators.solar_recommendation import recommend_solar from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation from domain.modelling.solar_potential import SolarPotential from repositories.product.product_repository import ProductRepository from tests.domain.modelling._elmhurst_recommendation import ( parse_recommendation_summary, ) _FIXTURE: Path = ( Path(__file__).resolve().parent / "fixtures" / "google_building_insights_001431.json" ) _SOLAR_MEASURE_TYPE = "solar_pv" _BATTERY_CAPACITY_KWH = 5.0 class _StubProducts(ProductRepository): """In-memory ProductRepository returning a fixed solar_pv catalogue row.""" def get(self, measure_type: str) -> Product: return Product( measure_type=measure_type, unit_cost_per_m2=0.0, contingency_rate=0.15, id=909, ) def _solar_potential() -> SolarPotential: with _FIXTURE.open(encoding="utf-8") as handle: data: dict[str, Any] = json.load(handle) potential = SolarPotential.from_building_insights(data) assert potential is not None return potential def _eligible_house() -> EpcPropertyData: """The solar 001431 before cert: a House with a hot-water cylinder, no existing PV — solar-eligible.""" return parse_recommendation_summary("solar_pv_001431_before.pdf") def test_eligible_house_yields_a_solar_pv_recommendation_of_competing_options() -> None: # Arrange — a house with feasible Google solar potential (5 conservative # configs) and a cylinder. baseline = _eligible_house() # Act recommendation: Optional[Recommendation] = recommend_solar( baseline, _StubProducts(), _solar_potential() ) # Assert — one "Solar PV" Recommendation, 5 configs × {no battery, battery} # = 10 competing Options, all measure_type solar_pv. assert recommendation is not None assert recommendation.surface == "Solar PV" assert len(recommendation.options) == 10 assert {o.measure_type for o in recommendation.options} == {_SOLAR_MEASURE_TYPE} assert all(o.material_id == 909 for o in recommendation.options) def test_each_option_overlay_installs_per_segment_arrays_and_ensures_export() -> None: # Arrange baseline = _eligible_house() # Act recommendation = recommend_solar(baseline, _StubProducts(), _solar_potential()) # Assert — every option folds a SolarOverlay: one PhotovoltaicArray per # config segment, export ensured, diverter set (the dwelling has a cylinder). assert recommendation is not None for option in recommendation.options: overlay = option.overlay.solar assert overlay is not None assert overlay.is_dwelling_export_capable is True assert overlay.pv_diverter_present is True # A newly-installed recommended array is connected to the dwelling's own # meter, so it must be tagged pv_connection=2 ("connected") — the value # the SAP cascade credits. (1 = present-but-not-connected → zero credit.) assert overlay.pv_connection == 2 arrays = overlay.photovoltaic_arrays assert arrays is not None and len(arrays) >= 1 assert all(isinstance(a, PhotovoltaicArray) for a in arrays) assert all(1 <= a.orientation <= 8 for a in arrays) assert all(1 <= a.pitch <= 5 for a in arrays) assert all(1 <= a.overshading <= 4 for a in arrays) def test_smallest_config_array_peak_power_matches_panels_times_capacity() -> None: # Arrange — the smallest conservative config is 4 panels × 400 W = 1.6 kWp # on one SE plane (≈32° → pitch code 2), back-solved to a heavy-ish bucket. baseline = _eligible_house() # Act recommendation = recommend_solar(baseline, _StubProducts(), _solar_potential()) # Assert — find the no-battery option whose single array totals 1.6 kWp. assert recommendation is not None no_battery_arrays: list[list[PhotovoltaicArray]] = [] for option in recommendation.options: overlay = option.overlay.solar assert overlay is not None if overlay.pv_batteries is None and overlay.photovoltaic_arrays is not None: no_battery_arrays.append(overlay.photovoltaic_arrays) smallest = min( no_battery_arrays, key=lambda arrays: sum(a.peak_power for a in arrays) ) assert len(smallest) == 1 assert abs(smallest[0].peak_power - 1.6) <= 1e-9 assert smallest[0].orientation == 4 # SE assert smallest[0].pitch == 2 # ~32° → 30° def test_battery_variant_adds_a_five_kwh_battery_and_costs_more() -> None: # Arrange baseline = _eligible_house() # Act recommendation = recommend_solar(baseline, _StubProducts(), _solar_potential()) # Assert — for the same array size, the battery variant carries a 5 kWh # battery and a higher cost than its no-battery twin. assert recommendation is not None by_size: dict[float, dict[bool, float]] = {} for option in recommendation.options: overlay = option.overlay.solar assert overlay is not None and option.cost is not None size = round(sum(a.peak_power for a in (overlay.photovoltaic_arrays or [])), 6) has_battery = overlay.pv_batteries is not None by_size.setdefault(size, {})[has_battery] = option.cost.total if has_battery: assert overlay.pv_batteries is not None assert ( abs( overlay.pv_batteries.pv_battery.battery_capacity - _BATTERY_CAPACITY_KWH ) <= 1e-9 ) for size, costs in by_size.items(): assert costs[True] > costs[False], size def test_combi_dwelling_gets_no_diverter() -> None: # Arrange — the same house without a cylinder (a combi has nothing to divert # surplus PV to), so the diverter field is left unset. baseline = _eligible_house() baseline.has_hot_water_cylinder = False # Act recommendation = recommend_solar(baseline, _StubProducts(), _solar_potential()) # Assert assert recommendation is not None for option in recommendation.options: assert option.overlay.solar is not None assert option.overlay.solar.pv_diverter_present is None def test_flat_is_not_eligible() -> None: # Arrange — a flat needs building-level shared-roof coordination (deferred). baseline = _eligible_house() baseline.property_type = "Flat" # Act / Assert assert recommend_solar(baseline, _StubProducts(), _solar_potential()) is None def test_listed_building_blocks_solar() -> None: # Arrange — a listed building protects the fabric (blocks_internal). baseline = _eligible_house() # Act / Assert assert ( recommend_solar( baseline, _StubProducts(), _solar_potential(), PlanningRestrictions(is_listed=True), ) is None ) def test_conservation_area_does_not_block_solar() -> None: # Arrange — a conservation area blocks external work generally, but PV is # offered (installed sympathetically) — same gate as ASHP, not blocks_external. baseline = _eligible_house() # Act recommendation = recommend_solar( baseline, _StubProducts(), _solar_potential(), PlanningRestrictions(in_conservation_area=True), ) # Assert assert recommendation is not None assert len(recommendation.options) == 10 def test_existing_pv_dwelling_is_not_eligible() -> None: # Arrange — a dwelling that already has PV (existing-PV top-up is deferred). baseline = _eligible_house() baseline.sap_energy_source.photovoltaic_arrays = [ PhotovoltaicArray(peak_power=2.0, pitch=2, orientation=5, overshading=1) ] # Act / Assert assert recommend_solar(baseline, _StubProducts(), _solar_potential()) is None def test_no_solar_potential_yields_no_recommendation() -> None: # Arrange — no Google solar data (or no feasible config) → no recommendation. baseline = _eligible_house() # Act / Assert assert recommend_solar(baseline, _StubProducts(), None) is None def test_infeasible_potential_yields_no_recommendation() -> None: # Arrange — a potential whose only config faces due north (dropped → empty). baseline = _eligible_house() potential = _solar_potential() north_only = replace(potential, configurations=()) # Act / Assert assert recommend_solar(baseline, _StubProducts(), north_only) is None