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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 14:56:09 +00:00
parent 53d9f21f73
commit 5c19737fc5
3 changed files with 149 additions and 17 deletions

View file

@ -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
]

View file

@ -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.

View file

@ -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.