feat(modelling): SecondaryHeatingOverlay clears the lodged secondary (ADR-0028)

The first overlay surface that sets fields to *absent* rather than to a
target state: _fold_secondary_heating clears sap_heating.secondary_heating_type
+ secondary_fuel_type, so the calculator's Table 11 secondary-fraction split
(SAP 10.2 §9a) routes 100% of space heating to the main. On an electric-storage
main RdSAP §A.2.2 re-forces a default secondary, making removal a no-op there —
left to the Optimiser to de-select (ADR-0028 decisions 2-3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 13:13:20 +00:00
parent 6ce6e89de1
commit 9b286e4a22
3 changed files with 70 additions and 0 deletions

View file

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

View file

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

View file

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