feat(modelling): thread SolarPotential into the orchestrator's solar Generator

Slice 8 of the Solar PV Recommendation Generator (ADR-0026). The
ModellingOrchestrator now reads each Property's persisted Google Solar
buildingInsights JSON (uow.solar), projects it once per Property into a typed
SolarPotential via `_solar_potential_for` (None for a missing or error
payload), and threads it into `recommend_solar` alongside planning_restrictions
— mirroring the ASHP wiring. Solar fires only when a feasible potential is
present, so dwellings without fetched solar data are unaffected.

FakeSolarRepo now returns None for an unseeded Property (was raising) and
supports `by_property` seeding, so the orchestrator's new solar read is exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 12:22:56 +00:00
parent 09cb8ceb9d
commit b249f69cb2
3 changed files with 162 additions and 8 deletions

View file

@ -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(

View file

@ -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):

View file

@ -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}