mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
a61b6f90c9
commit
2f6a1e2479
3 changed files with 174 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue