mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
parent
6ce6e89de1
commit
9b286e4a22
3 changed files with 70 additions and 0 deletions
|
|
@ -21,6 +21,7 @@ from domain.modelling.simulation import (
|
||||||
EpcSimulation,
|
EpcSimulation,
|
||||||
HeatingOverlay,
|
HeatingOverlay,
|
||||||
LightingOverlay,
|
LightingOverlay,
|
||||||
|
SecondaryHeatingOverlay,
|
||||||
SolarOverlay,
|
SolarOverlay,
|
||||||
VentilationOverlay,
|
VentilationOverlay,
|
||||||
WindowOverlay,
|
WindowOverlay,
|
||||||
|
|
@ -54,12 +55,29 @@ def apply_simulations(
|
||||||
_fold_lighting(result, simulation.lighting)
|
_fold_lighting(result, simulation.lighting)
|
||||||
if simulation.heating is not None:
|
if simulation.heating is not None:
|
||||||
_fold_heating(result, simulation.heating)
|
_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:
|
if simulation.solar is not None:
|
||||||
_fold_solar(result, simulation.solar)
|
_fold_solar(result, simulation.solar)
|
||||||
|
|
||||||
return result
|
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,
|
# `HeatingOverlay` fields grouped by the object they target — the deepest fold,
|
||||||
# spanning the primary `MainHeatingDetail`, `sap_heating`, the top-level
|
# spanning the primary `MainHeatingDetail`, `sap_heating`, the top-level
|
||||||
# `EpcPropertyData`, and `sap_energy_source` (ADR-0024).
|
# `EpcPropertyData`, and `sap_energy_source` (ADR-0024).
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,24 @@ class HeatingOverlay:
|
||||||
mains_gas: Optional[bool] = None
|
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)
|
@dataclass(frozen=True)
|
||||||
class SolarOverlay:
|
class SolarOverlay:
|
||||||
"""All-optional partial of the dwelling's PV-bearing energy source — the
|
"""All-optional partial of the dwelling's PV-bearing energy source — the
|
||||||
|
|
@ -194,4 +212,5 @@ class EpcSimulation:
|
||||||
ventilation: Optional[VentilationOverlay] = None
|
ventilation: Optional[VentilationOverlay] = None
|
||||||
lighting: Optional[LightingOverlay] = None
|
lighting: Optional[LightingOverlay] = None
|
||||||
heating: Optional[HeatingOverlay] = None
|
heating: Optional[HeatingOverlay] = None
|
||||||
|
secondary_heating: Optional[SecondaryHeatingOverlay] = None
|
||||||
solar: Optional[SolarOverlay] = None
|
solar: Optional[SolarOverlay] = None
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from domain.modelling.simulation import (
|
||||||
EpcSimulation,
|
EpcSimulation,
|
||||||
HeatingOverlay,
|
HeatingOverlay,
|
||||||
LightingOverlay,
|
LightingOverlay,
|
||||||
|
SecondaryHeatingOverlay,
|
||||||
SolarOverlay,
|
SolarOverlay,
|
||||||
VentilationOverlay,
|
VentilationOverlay,
|
||||||
WindowOverlay,
|
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
|
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:
|
def test_baseline_heating_is_not_mutated_by_a_heating_overlay() -> None:
|
||||||
# Arrange — 000490 lodges a mains-gas combi (fuel 26, control 2106, no
|
# Arrange — 000490 lodges a mains-gas combi (fuel 26, control 2106, no
|
||||||
# cylinder, mains_gas True).
|
# cylinder, mains_gas True).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue