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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 12:39:54 +00:00
parent ca397e1e3f
commit f7863f986d
7 changed files with 85 additions and 4 deletions

View file

@ -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,
}

View file

@ -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 {}

View file

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

View file

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

View file

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

View file

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

View file

@ -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()