Model/tests/orchestration/test_modelling_solar_threading.py
2026-06-12 12:52:36 +00:00

138 lines
4.8 KiB
Python

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