mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
244 lines
9 KiB
Python
244 lines
9 KiB
Python
"""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
|