Model/tests/orchestration/test_modelling_solar_threading.py
Khalim Conn-Kowlessar 5c19737fc5 feat(modelling): gate generation by the considered-measures allowlist
`restrict_to_considered_measures` filtered candidates only *after* every
generator had run, so an excluded measure still queried the catalogue.
That crashed properties with a lodged secondary heater: the live
`material.type` enum has no `secondary_heating_removal` value, so the
query raised a psycopg2 `InvalidTextRepresentation` before the allowlist
could drop it.

`_candidate_recommendations` now pairs each generator with the measure
types it can emit and runs it only when the allowlist admits one of them
(None = all), so an excluded measure never reaches the catalogue.
`restrict_to_considered_measures` still trims disallowed Options off the
multi-Option survivors. Add `--exclude-measures` to run_modelling_e2e
(allowlist minus the excluded set) for excluding one measure without
enumerating the rest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:56:09 +00:00

185 lines
6.6 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}
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}