mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
53d9f21f73
commit
5c19737fc5
3 changed files with 149 additions and 17 deletions
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue