Model/tests/domain/modelling/test_solar_recommendation.py
Khalim Conn-Kowlessar 8606cab5f0 fix(pv): credit PV only when connected to the dwelling's meter 🟩
Gate PV generation/credit in cert_to_inputs on gov-API pv_connection:
credit only when ==2 ('connected'); ==1 ('present but not connected to the
dwelling's meter') contributes zero to the dwelling's cost/CO2/PE per
RdSAP 10 §11.1 / SAP 10.2 Appendix M. Non-int (None / site-notes str) keeps
the credit-if-array behaviour, so the Elmhurst/Summary + synthetic paths are
unchanged (no regression).

Corpus: all 5 pv_connection=1 PV certs move inside ±0.5 (e.g. 100051118081
+6.5→+0.5); MAE 0.760→0.740, within-0.5 73.8→74.3%, no regression
(pv_connection=2 certs keep their credit).

Also corrects a now-load-bearing latent bug: the solar-recommendation
overlay tagged recommended arrays pv_connection=1 ('not connected') — which
the new gate would zero. A new install connects to the dwelling's meter, so
it must be 2; pinned by the overlay test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:40:51 +00:00

244 lines
9 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)
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