mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
`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>
185 lines
6.6 KiB
Python
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}
|