From dbbfb8ea28ee823412566f905cd3a0e0ef65e94c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 12:23:27 +0000 Subject: [PATCH] feat(modelling): recommend_lighting upgrades all non-LED bulbs to LED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of the lighting generator (ADR-0023): detect non-LED bulbs (incandescent + CFL + low-energy-unknown > 0) and emit one "Lighting" Recommendation whose single low_energy_lighting Option converts every bulb to LED — the overlay sets led = total, the other three counts 0. Priced as a flat per-bulb average x the non-LED count, contingency 0.26; the description names "LED" while the measure_type stays MEASURE_MAP-aligned. None when already all-LED or no bulb counts are lodged. Co-Authored-By: Claude Opus 4.8 --- .../generators/lighting_recommendation.py | 63 +++++++++ .../modelling/test_lighting_recommendation.py | 121 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 domain/modelling/generators/lighting_recommendation.py create mode 100644 tests/domain/modelling/test_lighting_recommendation.py diff --git a/domain/modelling/generators/lighting_recommendation.py b/domain/modelling/generators/lighting_recommendation.py new file mode 100644 index 00000000..e6c7df63 --- /dev/null +++ b/domain/modelling/generators/lighting_recommendation.py @@ -0,0 +1,63 @@ +"""The lighting Recommendation Generator (LED upgrade). + +Detects a dwelling's non-LED fixed-lighting bulbs and emits one "Lighting" +Recommendation whose single Option converts **every** bulb to LED (ADR-0023). +SAP 10.2 RdSAP §12-1 rates lamp efficacy LED > low-energy-unknown > CFL > +incandescent, so converting every non-LED type — incandescent, CFL, and the +"low energy, type unknown" (LEL) bulbs alike — strictly improves the Appendix L +lighting energy (worksheet line (232)). + +Unlike the fabric generators this is a **whole-dwelling** Measure: its overlay +writes the four top-level bulb counts directly (`led = total`, the rest 0). It +is a free Optimiser candidate — an LED upgrade improves SAP at low cost, so the +Optimiser keeps or leaves it for least-cost-to-target (contrast ventilation's +forced dependency). Detection + pricing only; impact is produced later by +scoring (ADR-0016). +""" + +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation, LightingOverlay +from repositories.product.product_repository import ProductRepository + +_LIGHTING_MEASURE_TYPE: Final[str] = "low_energy_lighting" + + +def recommend_lighting( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a lighting Recommendation upgrading every non-LED bulb to LED — its + single Option — else None when the dwelling has no non-LED bulbs (already + all-LED, or no bulb counts lodged).""" + led: int = epc.led_fixed_lighting_bulbs_count or 0 + cfl: int = epc.cfl_fixed_lighting_bulbs_count or 0 + incandescent: int = epc.incandescent_fixed_lighting_bulbs_count or 0 + low_energy: int = epc.low_energy_fixed_lighting_bulbs_count or 0 + + non_led: int = cfl + incandescent + low_energy + if non_led == 0: + return None + + product = products.get(_LIGHTING_MEASURE_TYPE) + overlay = EpcSimulation( + lighting=LightingOverlay( + led_fixed_lighting_bulbs_count=led + non_led, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + low_energy_fixed_lighting_bulbs_count=0, + ) + ) + cost = Cost( + total=non_led * product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + option = MeasureOption( + measure_type=_LIGHTING_MEASURE_TYPE, + description="Replace all non-LED bulbs with LED", + overlay=overlay, + cost=cost, + material_id=product.id, + ) + return Recommendation(surface="Lighting", options=(option,)) diff --git a/tests/domain/modelling/test_lighting_recommendation.py b/tests/domain/modelling/test_lighting_recommendation.py new file mode 100644 index 00000000..4386c6ee --- /dev/null +++ b/tests/domain/modelling/test_lighting_recommendation.py @@ -0,0 +1,121 @@ +"""Behaviour of the lighting Recommendation Generator: detecting non-LED bulbs +and emitting one "Lighting" Recommendation whose single Option upgrades every +bulb to LED (ADR-0023). A free Optimiser candidate (it improves SAP), unlike +ventilation's forced dependency. Detection + pricing only, no scores (ADR-0016). +""" + +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.lighting_recommendation import recommend_lighting +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import LightingOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed per-bulb lighting price. + + `unit_cost_per_m2` carries the catalogue row's fully-loaded total, reused as + a flat average price per bulb (ADR-0023).""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=5.0, + contingency_rate=0.26, + id=7, + ) + + +def _with_bulbs( + epc: EpcPropertyData, *, led: int, cfl: int, incandescent: int, low_energy: int +) -> EpcPropertyData: + """Return a copy of `epc` with the four fixed-lighting bulb counts set.""" + clone: EpcPropertyData = copy.deepcopy(epc) + clone.led_fixed_lighting_bulbs_count = led + clone.cfl_fixed_lighting_bulbs_count = cfl + clone.incandescent_fixed_lighting_bulbs_count = incandescent + clone.low_energy_fixed_lighting_bulbs_count = low_energy + return clone + + +def test_dwelling_with_non_led_bulbs_yields_a_lighting_recommendation() -> None: + # Arrange — a mixed inventory: 2 LED + 3 CFL + 4 incandescent + 1 LEL. + baseline: EpcPropertyData = _with_bulbs( + build_epc(), led=2, cfl=3, incandescent=4, low_energy=1 + ) + + # Act + recommendation: Recommendation | None = recommend_lighting( + baseline, _StubProducts() + ) + + # Assert — one all-LED Option: led = total (10), every other count zeroed. + assert recommendation is not None + assert recommendation.surface == "Lighting" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "low_energy_lighting" + assert option.overlay.lighting == LightingOverlay( + led_fixed_lighting_bulbs_count=10, + cfl_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=0, + low_energy_fixed_lighting_bulbs_count=0, + ) + + +def test_already_all_led_dwelling_yields_no_recommendation() -> None: + # Arrange — every bulb is already LED; nothing to upgrade. + baseline: EpcPropertyData = _with_bulbs( + build_epc(), led=9, cfl=0, incandescent=0, low_energy=0 + ) + + # Act + recommendation: Recommendation | None = recommend_lighting( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_dwelling_with_no_lodged_bulbs_yields_no_recommendation() -> None: + # Arrange — no bulb counts lodged (the calculator's L5b fallback case): with + # no inventory to size against, no Recommendation is offered (ADR-0023). + baseline: EpcPropertyData = _with_bulbs( + build_epc(), led=0, cfl=0, incandescent=0, low_energy=0 + ) + + # Act + recommendation: Recommendation | None = recommend_lighting( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_recommendation_prices_a_flat_average_per_non_led_bulb() -> None: + # Arrange — 8 non-LED bulbs (3 CFL + 4 incandescent + 1 LEL) at £5 each; the + # 2 existing LEDs are not priced (not replaced). + baseline: EpcPropertyData = _with_bulbs( + build_epc(), led=2, cfl=3, incandescent=4, low_energy=1 + ) + + # Act + recommendation: Recommendation | None = recommend_lighting( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert cost.total == 8 * 5.0 + assert cost.contingency_rate == 0.26 + assert recommendation.options[0].material_id == 7