feat(modelling): wire glazing generator into the candidate pool

Slice 4 of the glazing generator (ADR-0022): run recommend_glazing in
_candidate_recommendations, threading the Property's PlanningRestrictions so a
protected dwelling is offered secondary glazing instead of double (mirrors
recommend_solid_wall). Price both Measure Types in the offline catalogue
(double £600/window, secondary £510 -- the legacy 0.85x scaling) and the
contingency table (0.15, the legacy windows_glazing rate); the
_GENERATOR_MEASURE_TYPES forcing test enforces both entries exist.

run_modelling tests pin the wiring end-to-end on an all-single-glazed dwelling:
double when unrestricted, secondary when listed. The first-run integration test
seeds a double_glazing Product because its lodged EPC has a single-glazed
window. _single_glazed_epc() deep-copies build_epc() (which shares its window
objects) so the mutation can't leak into other tests' baselines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 09:29:09 +00:00
parent 276dd1a500
commit 456a81df0a
5 changed files with 66 additions and 4 deletions

View file

@ -15,6 +15,8 @@ _CONTINGENCY_RATES: dict[str, float] = {
"mechanical_ventilation": 0.26,
"external_wall_insulation": 0.26,
"internal_wall_insulation": 0.26,
"double_glazing": 0.15,
"secondary_glazing": 0.15,
}

View file

@ -7,5 +7,7 @@
"solid_floor_insulation": { "unit_cost_per_m2": 45.0 },
"mechanical_ventilation": { "unit_cost_per_m2": 450.0 },
"external_wall_insulation": { "unit_cost_per_m2": 100.0 },
"internal_wall_insulation": { "unit_cost_per_m2": 90.0 }
"internal_wall_insulation": { "unit_cost_per_m2": 90.0 },
"double_glazing": { "unit_cost_per_m2": 600.0 },
"secondary_glazing": { "unit_cost_per_m2": 510.0 }
}

View file

@ -28,6 +28,7 @@ from domain.modelling.scoring.scoring import (
)
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
from domain.modelling.generators.solid_wall_recommendation import recommend_solid_wall
from domain.modelling.generators.glazing_recommendation import recommend_glazing
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.sap10_calculator.calculator import SapCalculator
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
@ -198,13 +199,14 @@ def _candidate_recommendations(
planning_restrictions: PlanningRestrictions,
) -> list[Recommendation]:
"""Run every fabric Recommendation Generator; keep the ones that apply.
Solid-wall insulation is additionally gated by the Property's planning
protections (ADR-0019)."""
Solid-wall insulation and glazing are additionally gated by the Property's
planning protections (ADR-0019 / ADR-0022)."""
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),
)
return [recommendation for recommendation in found if recommendation is not None]

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import copy
import dataclasses
import pytest
@ -31,6 +32,8 @@ _GENERATOR_MEASURE_TYPES = (
"suspended_floor_insulation",
"solid_floor_insulation",
"mechanical_ventilation",
"double_glazing",
"secondary_glazing",
)
@ -112,6 +115,49 @@ def test_run_modelling_listed_building_yields_no_wall_insulation() -> None:
)
def _single_glazed_epc() -> EpcPropertyData:
"""The cavity/floor dwelling with all windows single-glazed — the glazing
generator's trigger, sized so the upgrade reaches the optimised package.
`build_epc()` shares its window objects across calls, so deep-copy before
mutating to avoid leaking single-glazing into other tests' baselines."""
epc: EpcPropertyData = copy.deepcopy(_build_uninsulated_cavity_and_floor_epc())
for window in epc.sap_windows:
window.glazing_type = 1 # SAP10.2 Table U2 code 1 = single.
return epc
def test_run_modelling_recommends_double_glazing_for_single_glazed_windows() -> None:
# Arrange — a single-glazed dwelling; the glazing generator is wired into
# the candidate pool.
epc: EpcPropertyData = _single_glazed_epc()
# Act — Modelling only, no database, unrestricted.
plan = run_modelling(epc, goal_band="C", print_table=False)
# Assert — double glazing reaches the optimised package.
measure_types = {measure.measure_type for measure in plan.measures}
assert "double_glazing" in measure_types
def test_run_modelling_protected_dwelling_yields_secondary_glazing() -> None:
# Arrange — the same single-glazed dwelling, listed (blocks external work).
epc: EpcPropertyData = _single_glazed_epc()
# Act — thread a listed-building restriction through to the generator.
plan = run_modelling(
epc,
goal_band="C",
print_table=False,
planning_restrictions=PlanningRestrictions(is_listed=True),
)
# Assert — the picked glazing Measure is secondary, never double.
measure_types = {measure.measure_type for measure in plan.measures}
assert "secondary_glazing" in measure_types
assert "double_glazing" not in measure_types
def test_sample_catalogue_prices_every_generator_measure_type() -> None:
# Arrange — the default offline catalogue.
products: ProductJsonRepository = ProductJsonRepository(DEFAULT_CATALOGUE)

View file

@ -123,7 +123,9 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
# The sample EPC's solid floor is uninsulated, so the floor generator
# fires during candidate generation and prices against this Product. The
# ventilation Measure Dependency is built for every not-yet-ventilated
# dwelling, so its Product must exist too (ADR-0016).
# dwelling, so its Product must exist too (ADR-0016). The EPC also lodges
# a single-glazed window, so the glazing generator fires and reaches for
# the double-glazing Product (ADR-0022).
session.add_all(
[
MaterialRow(
@ -142,6 +144,14 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
is_active=True,
description="Mechanical extract ventilation unit",
),
MaterialRow(
id=3,
type="double_glazing",
total_cost=600.0,
cost_unit="gbp_per_unit",
is_active=True,
description="Double glazing",
),
]
)
session.commit()