"""Slice 8 — the ModellingOrchestrator threads a Property's Google Solar potential (SolarRepository → typed SolarPotential) into the solar Recommendation Generator (ADR-0026), mirroring how planning_restrictions is threaded. Tests the new branching directly: the projection guard (`_solar_potential_for`) and the candidate wiring (`_candidate_recommendations` includes a Solar PV Recommendation only when a feasible potential is present). The end-to-end run-through-repos path is covered by the DB integration tests; here we keep the seam fast and isolated. """ import json from pathlib import Path from typing import Any from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.measure_type import MeasureType from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation from orchestration.modelling_orchestrator import ( _candidate_recommendations, # pyright: ignore[reportPrivateUsage] _solar_potential_for, # pyright: ignore[reportPrivateUsage] ) from repositories.product.product_repository import ProductRepository from tests.domain.modelling._elmhurst_recommendation import ( parse_recommendation_summary, ) from tests.orchestration.fakes import FakeSolarRepo _INSIGHTS_FIXTURE: Path = ( Path(__file__).resolve().parents[1] / "domain" / "modelling" / "fixtures" / "google_building_insights_001431.json" ) def _insights() -> dict[str, Any]: with _INSIGHTS_FIXTURE.open(encoding="utf-8") as handle: data: dict[str, Any] = json.load(handle) return data class _StubProducts(ProductRepository): 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 _eligible_house() -> EpcPropertyData: return parse_recommendation_summary("solar_pv_001431_before.pdf") def test_solar_potential_for_returns_none_when_no_insights() -> None: # Arrange — an unseeded Property: Ingestion fetched no solar data. solar = FakeSolarRepo() # Act / Assert assert _solar_potential_for(solar, uprn=42) is None def test_solar_potential_for_returns_none_for_an_error_payload() -> None: # Arrange — the Solar API found no building; Ingestion persisted the error # dict (no `solarPotential` block). solar = FakeSolarRepo(by_uprn={7: {"error": "ENTITY_NOT_FOUND"}}) # Act / Assert assert _solar_potential_for(solar, uprn=7) is None def test_solar_potential_for_projects_valid_insights() -> None: # Arrange solar = FakeSolarRepo(by_uprn={7: _insights()}) # Act potential = _solar_potential_for(solar, uprn=7) # Assert — the real London example projects to the 46-rung ladder. assert potential is not None assert abs(potential.panel_capacity_watts - 400.0) <= 1e-4 assert len(potential.configurations) == 46 def test_candidate_recommendations_includes_solar_when_potential_present() -> None: # Arrange — a solar-eligible house with a feasible potential. epc = _eligible_house() potential = _solar_potential_for( FakeSolarRepo(by_uprn={1: _insights()}), uprn=1 ) # Act recommendations: list[Recommendation] = _candidate_recommendations( epc, _StubProducts(), PlanningRestrictions(), potential, None ) # Assert — a "Solar PV" Recommendation is among the candidates. assert "Solar PV" in {r.surface for r in recommendations} def test_candidate_recommendations_excludes_solar_without_potential() -> None: # Arrange — same house, but no solar potential threaded. epc = _eligible_house() # Act recommendations = _candidate_recommendations( epc, _StubProducts(), PlanningRestrictions(), None, None ) # Assert assert "Solar PV" not in {r.surface for r in recommendations} class _ProductsRaisingFor(ProductRepository): """A catalogue that raises for one measure type — mirroring the live DB, whose ``material.type`` enum does not carry ``secondary_heating_removal``. Invoking that measure's generator would raise, so this proves an excluded generator is never run.""" def __init__(self, forbidden: MeasureType) -> None: self._forbidden = forbidden def get(self, measure_type: str) -> Product: if measure_type == self._forbidden: raise ValueError(f"catalogue cannot represent {measure_type!r}") return Product( measure_type=measure_type, unit_cost_per_m2=0.0, contingency_rate=0.15, id=909, ) def test_an_excluded_measures_generator_is_not_invoked() -> None: # Arrange — a dwelling with a lodged secondary heating system (so the # secondary-heating generator is eligible to fire) priced against a catalogue # that raises for that type, exactly as the live `material.type` enum does. epc = _eligible_house() epc.sap_heating.secondary_heating_type = 631 allowlist = frozenset(MeasureType) - {MeasureType.SECONDARY_HEATING_REMOVAL} # Act — excluding the measure must stop its generator running at all (it would # otherwise query the catalogue and raise). recommendations = _candidate_recommendations( epc, _ProductsRaisingFor(MeasureType.SECONDARY_HEATING_REMOVAL), PlanningRestrictions(), None, allowlist, ) # Assert — the run completes and no secondary-heating option leaks through. option_types = { option.measure_type for recommendation in recommendations for option in recommendation.options } assert MeasureType.SECONDARY_HEATING_REMOVAL not in option_types def test_considered_measures_restricts_candidates_to_the_allowlist() -> None: # Arrange — a solar-eligible house, with its solar potential present, so the # unrestricted run offers Solar PV alongside any fabric/heating candidates. epc = _eligible_house() potential = _solar_potential_for( FakeSolarRepo(by_uprn={1: json.loads(_INSIGHTS_FIXTURE.read_text())}), 1 ) # Act — restrict the run to Solar PV only. recommendations = _candidate_recommendations( epc, _StubProducts(), PlanningRestrictions(), potential, frozenset({MeasureType.SOLAR_PV}) ) # Assert — every surviving Option is solar_pv; nothing else leaks through. option_types = { option.measure_type for recommendation in recommendations for option in recommendation.options } assert option_types == {MeasureType.SOLAR_PV}