diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index f15ff102..c11285ca 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -21,6 +21,7 @@ from domain.modelling.simulation import ( EpcSimulation, HeatingOverlay, LightingOverlay, + SecondaryHeatingOverlay, SolarOverlay, VentilationOverlay, WindowOverlay, @@ -54,12 +55,29 @@ def apply_simulations( _fold_lighting(result, simulation.lighting) if simulation.heating is not None: _fold_heating(result, simulation.heating) + if simulation.secondary_heating is not None: + _fold_secondary_heating(result, simulation.secondary_heating) if simulation.solar is not None: _fold_solar(result, simulation.solar) return result +def _fold_secondary_heating( + epc: EpcPropertyData, overlay: SecondaryHeatingOverlay +) -> None: + """Strip the dwelling's lodged secondary heating system (ADR-0028) — the one + fold that sets fields to *absent* rather than to a target state. Clears + `secondary_heating_type` + `secondary_fuel_type` on `sap_heating`, so the + calculator's Table 11 split routes 100% of space heating to the main (or, on + an electric-storage main, re-forces the §A.2.2 default — a no-op the + Optimiser de-selects).""" + if not overlay.remove: + return + epc.sap_heating.secondary_heating_type = None + epc.sap_heating.secondary_fuel_type = None + + # `HeatingOverlay` fields grouped by the object they target — the deepest fold, # spanning the primary `MainHeatingDetail`, `sap_heating`, the top-level # `EpcPropertyData`, and `sap_energy_source` (ADR-0024). diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index abf192e9..083f8898 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -140,6 +140,24 @@ class HeatingOverlay: mains_gas: Optional[bool] = None +@dataclass(frozen=True) +class SecondaryHeatingOverlay: + """The change the Secondary Heating Removal Measure makes (ADR-0028): strip + the dwelling's lodged secondary heating system so the main serves 100% of + space heating. Unlike every other overlay — which writes a *target state* + and treats ``None`` as "leave unchanged" — this overlay *clears* the + secondary fields (`secondary_heating_type`, `secondary_fuel_type`) to + absent. Its presence on an `EpcSimulation` is the signal; `remove` carries + the intent explicitly. + + On an electric-storage main RdSAP §A.2.2 forces a default secondary back, so + removal is a no-op there — the Optimiser de-selects those (it owns the + economics); eligibility still offers them. + """ + + remove: bool = True + + @dataclass(frozen=True) class SolarOverlay: """All-optional partial of the dwelling's PV-bearing energy source — the @@ -194,4 +212,5 @@ class EpcSimulation: ventilation: Optional[VentilationOverlay] = None lighting: Optional[LightingOverlay] = None heating: Optional[HeatingOverlay] = None + secondary_heating: Optional[SecondaryHeatingOverlay] = None solar: Optional[SolarOverlay] = None diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index fe1c4dfe..1dce4067 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -17,6 +17,7 @@ from domain.modelling.simulation import ( EpcSimulation, HeatingOverlay, LightingOverlay, + SecondaryHeatingOverlay, SolarOverlay, VentilationOverlay, WindowOverlay, @@ -292,6 +293,38 @@ def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None: assert result.sap_energy_source.mains_gas is False +def test_secondary_heating_overlay_clears_the_lodged_secondary() -> None: + # Arrange — 000490 lodges a secondary system (SAP code 691, electric panel/ + # convector/radiant heaters). Pin a fuel on it too so we prove the fold + # clears BOTH the type and the fuel (ADR-0028). + baseline: EpcPropertyData = build_epc() + baseline.sap_heating.secondary_fuel_type = 30 + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act — fold a removal overlay. + result: EpcPropertyData = apply_simulations( + baseline, [EpcSimulation(secondary_heating=SecondaryHeatingOverlay())] + ) + + # Assert — the secondary is gone from the dwelling handed to the calculator. + assert result.sap_heating.secondary_heating_type is None + assert result.sap_heating.secondary_fuel_type is None + + +def test_secondary_heating_removal_does_not_mutate_the_baseline() -> None: + # Arrange — 000490 lodges secondary SAP code 691. + baseline: EpcPropertyData = build_epc() + assert baseline.sap_heating.secondary_heating_type == 691 + + # Act — fold a removal overlay. + _: EpcPropertyData = apply_simulations( + baseline, [EpcSimulation(secondary_heating=SecondaryHeatingOverlay())] + ) + + # Assert — the baseline's secondary is untouched (the fold copies first). + assert baseline.sap_heating.secondary_heating_type == 691 + + def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None: # Arrange — 000490 lodges a mains-gas combi (fuel 26, control 2106, no # cylinder, mains_gas True).