Model/tests/domain/modelling/test_solar_recommendation.py
Khalim Conn-Kowlessar 5d0a7751e6 A small-roof house gets Sub-Ladder solar Options instead of nothing 🟩
End-to-end pin at the generator seam (recommend_solar) — passed on first
run since option construction is unchanged by design (ADR-0058).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:09:18 +00:00

291 lines
11 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 (
SolarPanelConfiguration,
SolarPotential,
SolarRoofSegment,
)
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
def test_small_roof_house_gets_sub_ladder_options_instead_of_nothing() -> None:
# ADR-0058 end-to-end at the generator seam — the 824/1278 audit's
# property-750701 shape: Google max 5 (cap 3.5), ladder starting at 4,
# which yielded NO recommendation before Sub-Ladder Configurations.
# Arrange — an eligible house with a small-roof Solar Potential.
baseline = _eligible_house()
small_roof = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=5,
configurations=(
SolarPanelConfiguration(
panels_count=4,
yearly_energy_dc_kwh=1278.0,
segments=(
SolarRoofSegment(
segment_index=0,
panels_count=4,
azimuth_degrees=180.0,
pitch_degrees=30.0,
yearly_energy_dc_kwh=1278.0,
),
),
),
),
)
# Act
recommendation: Optional[Recommendation] = recommend_solar(
baseline, _StubProducts(), small_roof
)
# Assert — rungs 2 and 3 x {no battery, battery} = 4 competing Options,
# each overlay's array peak power matching its rung (0.8 / 1.2 kWp).
assert recommendation is not None
assert len(recommendation.options) == 4
peak_powers = sorted(
sum(a.peak_power for a in o.overlay.solar.photovoltaic_arrays)
for o in recommendation.options
)
assert peak_powers == [0.8, 0.8, 1.2, 1.2]