diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 4773b2f1..40e29696 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -31,10 +31,13 @@ from domain.modelling.generators.solid_wall_recommendation import recommend_soli from domain.modelling.generators.glazing_recommendation import recommend_glazing from domain.modelling.generators.lighting_recommendation import recommend_lighting from domain.modelling.generators.heating_recommendation import recommend_heating +from domain.modelling.generators.solar_recommendation import recommend_solar +from domain.modelling.solar_potential import SolarPotential from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.sap10_calculator.calculator import SapCalculator from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository from repositories.product.product_repository import ProductRepository +from repositories.solar.solar_repository import SolarRepository from repositories.unit_of_work import UnitOfWork # The PortfolioGoal value that targets a SAP band (cf. @@ -100,6 +103,13 @@ class ModellingOrchestrator: scenarios: list[Scenario] = uow.scenario.get_many(scenario_ids) for property_id, prop in zip(property_ids, properties, strict=True): effective_epc: EpcPropertyData = prop.effective_epc + # The Property's Google Solar potential (raw buildingInsights + # JSON persisted by Ingestion), projected once per Property and + # threaded into the solar Generator (ADR-0026). None when no + # solar data was fetched — the Generator then offers nothing. + solar_potential: Optional[SolarPotential] = _solar_potential_for( + uow.solar, property_id + ) for scenario in scenarios: plan = self._plan_for( scorer, @@ -109,6 +119,7 @@ class ModellingOrchestrator: scenario, current_market_value=prop.current_market_value, planning_restrictions=prop.planning_restrictions, + solar_potential=solar_potential, ) uow.plan.save( plan, @@ -129,11 +140,12 @@ class ModellingOrchestrator: *, current_market_value: Optional[float], planning_restrictions: PlanningRestrictions, + solar_potential: Optional[SolarPotential], ) -> Plan: """Generate → score → optimise → re-score/repair → attribute → bill → assemble the Plan for one Property + Scenario.""" groups: list[list[ScoredOption]] = _scored_candidate_groups( - scorer, effective_epc, products, planning_restrictions + scorer, effective_epc, products, planning_restrictions, solar_potential ) # Forced Measure Dependencies (ventilation) are excluded from the pool # but injected into the package before the re-score (ADR-0016). @@ -195,14 +207,28 @@ def _bill_for(bill_derivation: BillDerivation, score: Score) -> Bill: return bill_derivation.derive(EnergyBreakdown.from_sap_result(score.sap_result)) +def _solar_potential_for( + solar_repo: SolarRepository, property_id: int +) -> Optional[SolarPotential]: + """Project the Property's persisted Google Solar `buildingInsights` JSON + into a typed `SolarPotential` (ADR-0026), or None when none was fetched / + the lookup returned an error payload (no `solarPotential` block).""" + insights = solar_repo.get(property_id) + if not insights or "solarPotential" not in insights: + return None + return SolarPotential.from_building_insights(insights) + + def _candidate_recommendations( effective_epc: EpcPropertyData, products: ProductRepository, planning_restrictions: PlanningRestrictions, + solar_potential: Optional[SolarPotential], ) -> list[Recommendation]: - """Run every fabric Recommendation Generator; keep the ones that apply. - Solid-wall insulation and glazing are additionally gated by the Property's - planning protections (ADR-0019 / ADR-0022).""" + """Run every Recommendation Generator; keep the ones that apply. Solid-wall + insulation, glazing, heating and solar are additionally gated by the + Property's planning protections (ADR-0019 / ADR-0022 / ADR-0024 / ADR-0026); + solar also needs the Property's Google solar potential.""" found = ( recommend_cavity_wall(effective_epc, products), recommend_solid_wall(effective_epc, products, planning_restrictions), @@ -211,6 +237,9 @@ def _candidate_recommendations( recommend_glazing(effective_epc, products, planning_restrictions), recommend_lighting(effective_epc, products), recommend_heating(effective_epc, products, planning_restrictions), + recommend_solar( + effective_epc, products, solar_potential, planning_restrictions + ), ) return [recommendation for recommendation in found if recommendation is not None] @@ -232,12 +261,13 @@ def _scored_candidate_groups( effective_epc: EpcPropertyData, products: ProductRepository, planning_restrictions: PlanningRestrictions, + solar_potential: Optional[SolarPotential], ) -> list[list[ScoredOption]]: """One group per Recommendation: each Option scored independently against the baseline (role-1 warm-start signal, ADR-0016).""" groups: list[list[ScoredOption]] = [] for recommendation in _candidate_recommendations( - effective_epc, products, planning_restrictions + effective_epc, products, planning_restrictions, solar_potential ): options = list(recommendation.options) impacts: list[MeasureImpact] = independent_option_impacts( diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index 3efacd29..9e637034 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -101,14 +101,23 @@ class FakeEpcRepo(EpcRepository): class FakeSolarRepo(SolarRepository): - def __init__(self) -> None: + """In-memory Google Solar insights store. Seed `by_property` to hydrate a + Property's potential for a Modelling read; `saved` records writes for an + Ingestion-side assertion. Returns None for an unseeded Property (no solar + data fetched) — the solar Generator then offers nothing.""" + + def __init__( + self, by_property: Optional[dict[int, dict[str, Any]]] = None + ) -> None: self.saved: list[tuple[int, dict[str, Any]]] = [] + self._by_property: dict[int, dict[str, Any]] = dict(by_property or {}) def save(self, property_id: int, insights: dict[str, Any]) -> None: self.saved.append((property_id, insights)) + self._by_property[property_id] = insights - def get(self, property_id: int) -> Optional[dict[str, Any]]: # pragma: no cover - raise NotImplementedError + def get(self, property_id: int) -> Optional[dict[str, Any]]: + return self._by_property.get(property_id) class FakeSpatialRepo(SpatialRepository): diff --git a/tests/orchestration/test_modelling_solar_threading.py b/tests/orchestration/test_modelling_solar_threading.py new file mode 100644 index 00000000..aab5d41d --- /dev/null +++ b/tests/orchestration/test_modelling_solar_threading.py @@ -0,0 +1,115 @@ +"""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.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, property_id=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_property={7: {"error": "ENTITY_NOT_FOUND"}}) + + # Act / Assert + assert _solar_potential_for(solar, property_id=7) is None + + +def test_solar_potential_for_projects_valid_insights() -> None: + # Arrange + solar = FakeSolarRepo(by_property={7: _insights()}) + + # Act + potential = _solar_potential_for(solar, property_id=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_property={1: _insights()}), property_id=1 + ) + + # Act + recommendations: list[Recommendation] = _candidate_recommendations( + epc, _StubProducts(), PlanningRestrictions(), potential + ) + + # 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 + ) + + # Assert + assert "Solar PV" not in {r.surface for r in recommendations}