diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 8bd5dfb0..e2860465 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -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 diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 94e43c44..085ad30b 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -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 diff --git a/tests/domain/modelling/test_overlay_applicator.py b/tests/domain/modelling/test_overlay_applicator.py index 17c7eb7e..16228ec5 100644 --- a/tests/domain/modelling/test_overlay_applicator.py +++ b/tests/domain/modelling/test_overlay_applicator.py @@ -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()