diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index c1a9ea44..df2c83ae 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -249,24 +249,86 @@ def _candidate_recommendations( solar_potential: Optional[SolarPotential], considered_measures: Optional[frozenset[MeasureType]], ) -> list[Recommendation]: - """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. ``considered_measures`` - then restricts the survivors to the run's allowlist (None = all).""" - found = ( - recommend_cavity_wall(effective_epc, products), - recommend_solid_wall(effective_epc, products, planning_restrictions), - recommend_roof_insulation(effective_epc, products), - recommend_floor_insulation(effective_epc, products), - recommend_glazing(effective_epc, products, planning_restrictions), - recommend_lighting(effective_epc, products), - recommend_heating(effective_epc, products, planning_restrictions), - recommend_secondary_heating_removal(effective_epc, products), - recommend_solar( - effective_epc, products, solar_potential, planning_restrictions + """Run the applicable Recommendation Generators; 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. + + ``considered_measures`` gates generation *up front*: a generator runs only + when the allowlist admits at least one of the measure types it can emit + (None = every measure), so an excluded measure never reaches the catalogue — + which matters when the live ``material.type`` enum cannot even represent it + (e.g. ``secondary_heating_removal``). ``restrict_to_considered_measures`` + then trims any disallowed Options off the multi-Option survivors.""" + + def admitted(*emits: MeasureType) -> bool: + return considered_measures is None or any( + measure in considered_measures for measure in emits + ) + + # Each generator paired with the measure types it can emit, so the allowlist + # can skip a generator whose every type is excluded before it is invoked. + generators: tuple[ + tuple[bool, Callable[[], Optional[Recommendation]]], ... + ] = ( + ( + admitted(MeasureType.CAVITY_WALL_INSULATION), + lambda: recommend_cavity_wall(effective_epc, products), + ), + ( + admitted( + MeasureType.INTERNAL_WALL_INSULATION, + MeasureType.EXTERNAL_WALL_INSULATION, + ), + lambda: recommend_solid_wall( + effective_epc, products, planning_restrictions + ), + ), + ( + admitted( + MeasureType.LOFT_INSULATION, + MeasureType.SLOPING_CEILING_INSULATION, + MeasureType.FLAT_ROOF_INSULATION, + ), + lambda: recommend_roof_insulation(effective_epc, products), + ), + ( + admitted( + MeasureType.SUSPENDED_FLOOR_INSULATION, + MeasureType.SOLID_FLOOR_INSULATION, + ), + lambda: recommend_floor_insulation(effective_epc, products), + ), + ( + admitted(MeasureType.DOUBLE_GLAZING, MeasureType.SECONDARY_GLAZING), + lambda: recommend_glazing(effective_epc, products, planning_restrictions), + ), + ( + admitted(MeasureType.LOW_ENERGY_LIGHTING), + lambda: recommend_lighting(effective_epc, products), + ), + ( + admitted( + MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS, + MeasureType.AIR_SOURCE_HEAT_PUMP, + MeasureType.GAS_BOILER_UPGRADE, + MeasureType.SYSTEM_TUNE_UP, + MeasureType.SYSTEM_TUNE_UP_ZONED, + ), + lambda: recommend_heating(effective_epc, products, planning_restrictions), + ), + ( + admitted(MeasureType.SECONDARY_HEATING_REMOVAL), + lambda: recommend_secondary_heating_removal(effective_epc, products), + ), + ( + admitted(MeasureType.SOLAR_PV), + lambda: recommend_solar( + effective_epc, products, solar_potential, planning_restrictions + ), ), ) + found = [thunk() for is_admitted, thunk in generators if is_admitted] applicable = [ recommendation for recommendation in found if recommendation is not None ] diff --git a/scripts/run_modelling_e2e.py b/scripts/run_modelling_e2e.py index 6ca59c76..1b900ed5 100644 --- a/scripts/run_modelling_e2e.py +++ b/scripts/run_modelling_e2e.py @@ -191,6 +191,21 @@ def _parse_measures(raw: Optional[str]) -> Optional[frozenset[MeasureType]]: ) +def _resolve_considered( + allowlist: Optional[frozenset[MeasureType]], + excluded: Optional[frozenset[MeasureType]], +) -> Optional[frozenset[MeasureType]]: + """Combine the `--measures` allowlist with the `--exclude-measures` set. With + no exclusions the allowlist is returned unchanged (None = every measure). + With exclusions the result is (the allowlist, or every measure) minus the + excluded types — so `--exclude-measures secondary_heating_removal` considers + every measure except that one, without enumerating the rest.""" + if not excluded: + return allowlist + base = allowlist if allowlist is not None else frozenset(MeasureType) + return base - excluded + + def _context_summary( spatial: Optional[SpatialReference], solar_insights: Optional[dict[str, Any]] ) -> str: @@ -277,6 +292,12 @@ def main() -> None: default=None, help="comma-separated measure types to consider (default: all)", ) + parser.add_argument( + "--exclude-measures", + default=None, + help="comma-separated measure types to exclude (default: none) — e.g. " + "secondary_heating_removal, which the live catalogue does not yet stock", + ) parser.add_argument( "--portfolio-id", type=int, @@ -307,7 +328,9 @@ def main() -> None: geospatial = GeospatialS3Repository(_s3_parquet_reader(os.environ["DATA_BUCKET"])) solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"]) engine = _engine() - considered = _parse_measures(args.measures) + considered = _resolve_considered( + _parse_measures(args.measures), _parse_measures(args.exclude_measures) + ) uprns = _uprns_for(engine, args.property_ids) # One read-only session for the live `material` catalogue, reused across the # batch so both store and no-store runs price against the same DB rows. diff --git a/tests/orchestration/test_modelling_solar_threading.py b/tests/orchestration/test_modelling_solar_threading.py index 68884d27..988f4229 100644 --- a/tests/orchestration/test_modelling_solar_threading.py +++ b/tests/orchestration/test_modelling_solar_threading.py @@ -116,6 +116,53 @@ def test_candidate_recommendations_excludes_solar_without_potential() -> None: 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.