Model/tests/domain/modelling/test_solar_recommendation.py
Khalim Conn-Kowlessar 09cb8ceb9d feat(modelling): recommend_solar — eligibility + competing array Options
Slice 6 of the Solar PV Recommendation Generator (ADR-0026). `recommend_solar`
emits one "Solar PV" Recommendation of up to five conservatively-sized configs
× {no battery, battery} = ≤10 competing Options (a free Optimiser candidate).
Each Option folds a SolarOverlay built from the chosen config: one
PhotovoltaicArray per non-north segment (peak_power = panels × panelCapacityW /
1000; orientation/pitch from geometry; generation-calibrated overshading),
is_dwelling_export_capable set True absolutely, a diverter when the dwelling
has a cylinder (None for a combi), a 5 kWh battery for the battery variant, and
the per-config composite cost from Products.solar_bundle_cost.

Eligibility = house/bungalow ∧ not listed/heritage (blocks_internal, the same
gate as ASHP — a conservation area does NOT block PV) ∧ no existing PV ∧ a
feasible SolarPotential. Flats and existing-PV top-up are deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:14:24 +00:00

238 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)
return SolarPotential.from_building_insights(data)
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
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