From f7863f986da039c1805e27874014facd65c95dbb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 12:39:54 +0000 Subject: [PATCH] feat(modelling): wire the lighting generator into the candidate pool Slice 4 of the lighting generator (ADR-0023): run recommend_lighting in _candidate_recommendations (no planning gate). Price low_energy_lighting in the offline catalogue + contingency table (0.26, the legacy rate); the _GENERATOR_MEASURE_TYPES forcing test enforces both. A run_modelling test pins the wiring end-to-end (an incandescent-lit dwelling gets the LED upgrade in the optimised package). Downstream updates, all because lighting now fires on any cert with non-LED bulbs: report.py gains the low_energy_lighting trigger (the non-LED counts); the two golden-cert report tests and the multi-measure integration test now expect low_energy_lighting alongside the fabric measures (the sample/golden EPCs lodge low-energy-unknown bulbs); first-run integration seeds a low_energy_lighting MaterialRow. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/contingencies.py | 1 + harness/report.py | 11 ++++++++ harness/sample_catalogue.json | 3 +- orchestration/modelling_orchestrator.py | 2 ++ tests/harness/test_console.py | 25 +++++++++++++++++ tests/harness/test_report.py | 19 +++++++++++-- ...test_ara_first_run_pipeline_integration.py | 28 +++++++++++++++++++ 7 files changed, 85 insertions(+), 4 deletions(-) diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index e0e68151..2854d231 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -17,6 +17,7 @@ _CONTINGENCY_RATES: dict[str, float] = { "internal_wall_insulation": 0.26, "double_glazing": 0.15, "secondary_glazing": 0.15, + "low_energy_lighting": 0.26, } diff --git a/harness/report.py b/harness/report.py index df36a05c..443f27a5 100644 --- a/harness/report.py +++ b/harness/report.py @@ -123,6 +123,17 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]: else epc.sap_ventilation.mechanical_ventilation_kind ) return {"mechanical_ventilation_kind": kind} + if measure_type == "low_energy_lighting": + # lighting_recommendation.py fires on any non-LED bulb. + return { + "incandescent_fixed_lighting_bulbs_count": ( + epc.incandescent_fixed_lighting_bulbs_count + ), + "cfl_fixed_lighting_bulbs_count": epc.cfl_fixed_lighting_bulbs_count, + "low_energy_fixed_lighting_bulbs_count": ( + epc.low_energy_fixed_lighting_bulbs_count + ), + } return {} diff --git a/harness/sample_catalogue.json b/harness/sample_catalogue.json index 95150e16..c9ea14ab 100644 --- a/harness/sample_catalogue.json +++ b/harness/sample_catalogue.json @@ -9,5 +9,6 @@ "external_wall_insulation": { "unit_cost_per_m2": 100.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 } + "secondary_glazing": { "unit_cost_per_m2": 510.0 }, + "low_energy_lighting": { "unit_cost_per_m2": 8.0 } } diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 3f8aa986..f193b771 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -29,6 +29,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.modelling.generators.lighting_recommendation import recommend_lighting from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.sap10_calculator.calculator import SapCalculator from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository @@ -207,6 +208,7 @@ def _candidate_recommendations( recommend_roof_insulation(effective_epc, products), recommend_floor_insulation(effective_epc, products), recommend_glazing(effective_epc, products, planning_restrictions), + recommend_lighting(effective_epc, products), ) 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 8735cbc2..b8cac0f9 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -33,6 +33,7 @@ _GENERATOR_MEASURE_TYPES = ( "mechanical_ventilation", "double_glazing", "secondary_glazing", + "low_energy_lighting", ) @@ -151,6 +152,30 @@ def test_run_modelling_protected_dwelling_yields_secondary_glazing() -> None: # 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 + + +def _incandescent_lit_epc() -> EpcPropertyData: + """The cavity/floor dwelling lit entirely by incandescent bulbs — the + lighting generator's trigger, sized so the LED upgrade reaches the package.""" + epc: EpcPropertyData = _build_uninsulated_cavity_and_floor_epc() + epc.led_fixed_lighting_bulbs_count = 0 + epc.cfl_fixed_lighting_bulbs_count = 0 + epc.incandescent_fixed_lighting_bulbs_count = 10 + epc.low_energy_fixed_lighting_bulbs_count = 0 + return epc + + +def test_run_modelling_recommends_low_energy_lighting_for_non_led_bulbs() -> None: + # Arrange — a dwelling lit by incandescent bulbs; the lighting generator is + # wired into the candidate pool. + epc: EpcPropertyData = _incandescent_lit_epc() + + # Act — Modelling only, no database. + plan = run_modelling(epc, goal_band="C", print_table=False) + + # Assert — the LED upgrade reaches the optimised package. + measure_types = {measure.measure_type for measure in plan.measures} + assert "low_energy_lighting" in measure_types assert "double_glazing" not in measure_types diff --git a/tests/harness/test_report.py b/tests/harness/test_report.py index 0e54350f..5b69bd62 100644 --- a/tests/harness/test_report.py +++ b/tests/harness/test_report.py @@ -82,6 +82,7 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: "cavity_wall_insulation", "mechanical_ventilation", "solid_floor_insulation", + "low_energy_lighting", } # Cavity fill fired because the main wall is an uninsulated cavity. assert triggers["cavity_wall_insulation"].triggers == { @@ -97,22 +98,34 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None: "floor_insulation_thickness": None, "floor_construction_type": "Solid", } + # The LED upgrade fired because the dwelling lodges 10 low-energy-unknown bulbs. + assert triggers["low_energy_lighting"].triggers == { + "incandescent_fixed_lighting_bulbs_count": 0, + "cfl_fixed_lighting_bulbs_count": None, + "low_energy_fixed_lighting_bulbs_count": 10, + } -def test_single_measure_cert_surfaces_only_that_measures_trigger() -> None: +def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None: # Arrange path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json" # Act report: PropertyReport = build_property_report(path) - # Assert — 0036 fires the solid-floor measure alone. + # Assert — 0036 fires solid-floor insulation and the LED upgrade (it lodges + # 7 low-energy-unknown bulbs), and nothing else. triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report) - assert set(triggers) == {"solid_floor_insulation"} + assert set(triggers) == {"solid_floor_insulation", "low_energy_lighting"} assert triggers["solid_floor_insulation"].triggers == { "floor_insulation_thickness": None, "floor_construction_type": "Solid", } + assert triggers["low_energy_lighting"].triggers == { + "incandescent_fixed_lighting_bulbs_count": 0, + "cfl_fixed_lighting_bulbs_count": None, + "low_energy_fixed_lighting_bulbs_count": 7, + } def test_cohort_builder_models_each_path_capturing_errors(tmp_path: Path) -> None: diff --git a/tests/orchestration/test_ara_first_run_pipeline_integration.py b/tests/orchestration/test_ara_first_run_pipeline_integration.py index eba84d5e..96f2b0dd 100644 --- a/tests/orchestration/test_ara_first_run_pipeline_integration.py +++ b/tests/orchestration/test_ara_first_run_pipeline_integration.py @@ -152,6 +152,14 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun( is_active=True, description="Double glazing", ), + MaterialRow( + id=4, + type="low_energy_lighting", + total_cost=8.0, + cost_unit="gbp_per_unit", + is_active=True, + description="LED bulb", + ), ] ) session.commit() @@ -260,6 +268,14 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( is_active=True, description="Mechanical extract ventilation unit", ), + MaterialRow( + id=4, + type="low_energy_lighting", + total_cost=8.0, + cost_unit="gbp_per_unit", + is_active=True, + description="LED bulb", + ), ] ) session.commit() @@ -316,6 +332,9 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( "cavity_wall_insulation", "suspended_floor_insulation", "mechanical_ventilation", + # The sample EPC lodges 8 low-energy-unknown bulbs, so the LED upgrade is + # a cheap positive-SAP candidate the Optimiser also keeps (ADR-0023). + "low_energy_lighting", } # Each persisted measure carries the catalogue id of the Product it installs # (the MaterialRow ids seeded above), replacing the retired @@ -323,6 +342,7 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan( assert by_type["cavity_wall_insulation"].material_id == 1 assert by_type["suspended_floor_insulation"].material_id == 2 assert by_type["mechanical_ventilation"].material_id == 3 + assert by_type["low_energy_lighting"].material_id == 4 for rec in rec_rows: assert rec.default is True assert rec.already_installed is False @@ -411,6 +431,14 @@ def test_modelling_recommends_nothing_when_already_at_the_target_band( is_active=True, description="Mechanical extract ventilation unit", ), + MaterialRow( + id=13, + type="low_energy_lighting", + total_cost=8.0, + cost_unit="gbp_per_unit", + is_active=True, + description="LED bulb", + ), ] ) session.commit()