feat(modelling): HeatingOverlay surface + _fold_heating (5-location fold)

The 5th EpcSimulation overlay surface and the deepest applicator fold yet: a
heating bundle is a whole-system replacement, so _fold_heating routes its
absolute-target fields across main_heating_details[0] (fuel/emitter/control +
sap_main_heating_code OR index+category), sap_heating (water_heating_* +
cylinder), the top-level EpcPropertyData (has_hot_water_cylinder), and
sap_energy_source (meter_type, mains_gas). ADR-0024.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 18:59:26 +00:00
parent a61b6f90c9
commit 2f6a1e2479
3 changed files with 174 additions and 1 deletions

View file

@ -19,6 +19,7 @@ from datatypes.epc.domain.epc_property_data import (
)
from domain.modelling.simulation import (
EpcSimulation,
HeatingOverlay,
LightingOverlay,
VentilationOverlay,
WindowOverlay,
@ -50,10 +51,56 @@ def apply_simulations(
)
if simulation.lighting is not None:
_fold_lighting(result, simulation.lighting)
if simulation.heating is not None:
_fold_heating(result, simulation.heating)
return result
# `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).
_MAIN_HEATING_FIELDS: tuple[str, ...] = (
"main_fuel_type",
"heat_emitter_type",
"main_heating_control",
"sap_main_heating_code",
"main_heating_index_number",
"main_heating_category",
)
_SAP_HEATING_FIELDS: tuple[str, ...] = (
"water_heating_code",
"water_heating_fuel",
"cylinder_size",
"cylinder_insulation_type",
"cylinder_insulation_thickness_mm",
)
_ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "mains_gas")
def _fold_heating(epc: EpcPropertyData, overlay: HeatingOverlay) -> None:
"""Write a `HeatingOverlay`'s non-``None`` fields onto the (copied) dwelling,
routing each to its home: the primary ``main_heating_details[0]``, the
``sap_heating`` hot-water fields, the top-level ``has_hot_water_cylinder``,
and the ``sap_energy_source`` meter/mains-gas fields. The bundle targets the
primary system only (index 0)."""
main = epc.sap_heating.main_heating_details[0]
for field_name in _MAIN_HEATING_FIELDS:
value = getattr(overlay, field_name)
if value is not None:
setattr(main, field_name, value)
for field_name in _SAP_HEATING_FIELDS:
value = getattr(overlay, field_name)
if value is not None:
setattr(epc.sap_heating, field_name, value)
if overlay.has_hot_water_cylinder is not None:
epc.has_hot_water_cylinder = overlay.has_hot_water_cylinder
for field_name in _ENERGY_SOURCE_FIELDS:
value = getattr(overlay, field_name)
if value is not None:
setattr(epc.sap_energy_source, field_name, value)
def _fold_lighting(epc: EpcPropertyData, overlay: LightingOverlay) -> None:
"""Write a `LightingOverlay`'s non-``None`` bulb counts onto the (copied)
dwelling's top-level fields by name — the four counts live directly on

View file

@ -9,7 +9,7 @@ the exact `SapBuildingPart` (the main wall vs an extension). See CONTEXT.md.
"""
from dataclasses import dataclass, field
from typing import Mapping, Optional
from typing import Mapping, Optional, Union
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
@ -84,6 +84,49 @@ class LightingOverlay:
low_energy_fixed_lighting_bulbs_count: Optional[int] = None
@dataclass(frozen=True)
class HeatingOverlay:
"""All-optional partial of a dwelling's whole heating + hot-water system —
the change a heating Measure Option makes (e.g. a high-heat-retention
storage or ASHP bundle ADR-0024). The deepest overlay surface: a heating
bundle is a whole-system replacement, so its fields target **five**
locations, and `_fold_heating` routes each to its home:
- ``main_heating_details[0]`` (the primary system) fuel, heat emitter,
control, and the efficiency anchor (`sap_main_heating_code` for table-
resolved systems like storage heaters, or `main_heating_index_number` +
`main_heating_category` for PCDB-resolved systems like heat pumps);
- ``sap_heating`` (top-level) the implied hot-water arrangement
(`water_heating_*`, cylinder size + insulation);
- the top-level `EpcPropertyData` `has_hot_water_cylinder`;
- ``sap_energy_source`` `meter_type` (an off-peak tariff for storage) and
`mains_gas` (cleared when the dwelling goes all-electric).
The values are **absolute target states**, not deltas (the bundle replaces
the system regardless of the before). A `None` field means "leave the
baseline value unchanged".
"""
# main_heating_details[0]
main_fuel_type: Optional[Union[int, str]] = None
heat_emitter_type: Optional[Union[int, str]] = None
main_heating_control: Optional[Union[int, str]] = None
sap_main_heating_code: Optional[int] = None
main_heating_index_number: Optional[int] = None
main_heating_category: Optional[int] = None
# sap_heating (top-level)
water_heating_code: Optional[int] = None
water_heating_fuel: Optional[int] = None
cylinder_size: Optional[Union[int, str]] = None
cylinder_insulation_type: Optional[Union[int, str]] = None
cylinder_insulation_thickness_mm: Optional[int] = None
# EpcPropertyData (top-level)
has_hot_water_cylinder: Optional[bool] = None
# sap_energy_source
meter_type: Optional[str] = None
mains_gas: Optional[bool] = None
def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:
return {}
@ -105,3 +148,4 @@ class EpcSimulation:
windows: Mapping[int, WindowOverlay] = field(default_factory=_no_windows)
ventilation: Optional[VentilationOverlay] = None
lighting: Optional[LightingOverlay] = None
heating: Optional[HeatingOverlay] = None

View file

@ -12,6 +12,7 @@ from datatypes.epc.domain.epc_property_data import (
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
HeatingOverlay,
LightingOverlay,
VentilationOverlay,
WindowOverlay,
@ -245,6 +246,87 @@ def test_apply_writes_dwelling_lighting_onto_top_level_bulb_counts() -> None:
assert result.low_energy_fixed_lighting_bulbs_count == 0
def test_apply_folds_a_heating_overlay_across_all_five_locations() -> None:
# Arrange — a whole-system HHR storage bundle replacing 000490's gas combi
# (fuel 26, control 2106, no cylinder, mains_gas True). The heating overlay
# is the deepest surface: it writes across main_heating_details[0],
# sap_heating, the top-level EpcPropertyData, and sap_energy_source at once
# (ADR-0024).
baseline: EpcPropertyData = build_epc()
simulation = EpcSimulation(
heating=HeatingOverlay(
main_fuel_type=30,
sap_main_heating_code=409,
main_heating_control=2404,
water_heating_code=903,
water_heating_fuel=30,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=120,
has_hot_water_cylinder=True,
meter_type="18 Hour",
mains_gas=False,
)
)
# Act
result: EpcPropertyData = apply_simulations(baseline, [simulation])
# Assert — every targeted field routed to its home object.
main = result.sap_heating.main_heating_details[0]
assert main.main_fuel_type == 30
assert main.sap_main_heating_code == 409
assert main.main_heating_control == 2404
assert result.sap_heating.water_heating_code == 903
assert result.sap_heating.water_heating_fuel == 30
assert result.sap_heating.cylinder_size == 2
assert result.sap_heating.cylinder_insulation_type == 1
assert result.sap_heating.cylinder_insulation_thickness_mm == 120
assert result.has_hot_water_cylinder is True
assert result.sap_energy_source is not None
assert result.sap_energy_source.meter_type == "18 Hour"
assert result.sap_energy_source.mains_gas is False
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).
baseline: EpcPropertyData = build_epc()
original_fuel = baseline.sap_heating.main_heating_details[0].main_fuel_type
original_control = baseline.sap_heating.main_heating_details[0].main_heating_control
original_wh_code: int | None = baseline.sap_heating.water_heating_code
original_cylinder = baseline.has_hot_water_cylinder
assert baseline.sap_energy_source is not None
original_mains_gas = baseline.sap_energy_source.mains_gas
# Act — fold an HHR storage bundle.
_: EpcPropertyData = apply_simulations(
baseline,
[
EpcSimulation(
heating=HeatingOverlay(
main_fuel_type=30,
sap_main_heating_code=409,
main_heating_control=2404,
water_heating_code=903,
has_hot_water_cylinder=True,
mains_gas=False,
)
)
],
)
# Assert — the baseline's heating is untouched.
assert baseline.sap_heating.main_heating_details[0].main_fuel_type == original_fuel
assert (
baseline.sap_heating.main_heating_details[0].main_heating_control
== original_control
)
assert baseline.sap_heating.water_heating_code == original_wh_code
assert baseline.has_hot_water_cylinder == original_cylinder
assert baseline.sap_energy_source.mains_gas == original_mains_gas
def test_baseline_lighting_is_not_mutated_by_a_lighting_overlay() -> None:
# Arrange — 000490 lodges 8 low-energy-unknown bulbs, 0 LED.
baseline: EpcPropertyData = build_epc()