mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
09cb8ceb9d
commit
b249f69cb2
3 changed files with 162 additions and 8 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
115
tests/orchestration/test_modelling_solar_threading.py
Normal file
115
tests/orchestration/test_modelling_solar_threading.py
Normal 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}
|
||||
Loading…
Add table
Reference in a new issue