feat(modelling): recommend_lighting upgrades all non-LED bulbs to LED

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 12:23:27 +00:00
parent 139c90c885
commit dbbfb8ea28
2 changed files with 184 additions and 0 deletions

View file

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

View file

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