From 456a81df0aedf02e028c4fea6856dd2b4f21beaf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 09:29:09 +0000 Subject: [PATCH] feat(modelling): wire glazing generator into the candidate pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- domain/modelling/contingencies.py | 2 + harness/sample_catalogue.json | 4 +- orchestration/modelling_orchestrator.py | 6 ++- tests/harness/test_console.py | 46 +++++++++++++++++++ ...test_ara_first_run_pipeline_integration.py | 12 ++++- 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index 37af47e6..e0e68151 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -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, } diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index e3ae97ec..95150e16 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -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 } } diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 795f012e..3f8aa986 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -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] diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index 4ef5059c..136007a7 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -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) diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index 23f38efd..eba84d5e 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -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()