Model/domain/modelling/scoring/overlay_applicator.py
Jun-te Kim 608e96bb09 Remap every window's glazing from a landlord glazing override 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:21:42 +00:00

235 lines
9.9 KiB
Python

"""The Overlay Applicator — folds an ordered set of Simulation Overlays onto
a baseline EpcPropertyData and returns a new one for the calculator.
Sequential fold: overlays are applied in order and a later overlay wins on a
field it shares with an earlier one. The baseline is never mutated; the
returned EpcPropertyData is throwaway (handed to the calculator for scoring,
then discarded). See ADR-0016.
"""
import copy
from dataclasses import fields
from typing import Optional, Sequence
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
SapVentilation,
SapWindow,
WindowTransmissionDetails,
)
from domain.modelling.simulation import (
EpcSimulation,
GlazingOverlay,
HeatingOverlay,
LightingOverlay,
SecondaryHeatingOverlay,
SolarOverlay,
VentilationOverlay,
WindowOverlay,
)
def apply_simulations(
baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
) -> EpcPropertyData:
"""Return a copy of ``baseline`` with every Simulation Overlay's non-``None``
fields written onto the building part it targets, applied in order. A
whole-dwelling ``ventilation`` overlay folds onto ``sap_ventilation``
(creating one if the baseline lodged none)."""
result: EpcPropertyData = copy.deepcopy(baseline)
parts_by_id = {part.identifier: part for part in result.sap_building_parts}
for simulation in simulations:
for identifier, overlay in simulation.building_parts.items():
part = parts_by_id[identifier]
for overlay_field in fields(overlay):
value = getattr(overlay, overlay_field.name)
if value is not None:
setattr(part, overlay_field.name, value)
for index, window_overlay in simulation.windows.items():
_fold_window(result.sap_windows[index], window_overlay)
if simulation.ventilation is not None:
result.sap_ventilation = _fold_ventilation(
result.sap_ventilation, simulation.ventilation
)
if simulation.lighting is not None:
_fold_lighting(result, simulation.lighting)
if simulation.glazing is not None:
_fold_glazing(result, simulation.glazing)
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)
if simulation.property_type is not None:
result.property_type = simulation.property_type
if simulation.built_form is not None:
result.built_form = simulation.built_form
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).
_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",
"fan_flue_present",
"boiler_flue_type",
)
_SAP_HEATING_FIELDS: tuple[str, ...] = (
"water_heating_code",
"water_heating_fuel",
"cylinder_size",
"cylinder_insulation_type",
"cylinder_insulation_thickness_mm",
"cylinder_thermostat",
"immersion_heating_type",
)
_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)
# `main_heating_index_number` (PCDB-resolved, e.g. a heat pump) and
# `sap_main_heating_code` (Table 4a-resolved, e.g. storage heaters) are
# mutually-exclusive efficiency anchors: a whole-system replacement to one
# must clear the other, else a stale code from the old system wins the
# calculator's dispatch (e.g. a gas-boiler code 104 left beside a heat-pump
# index makes hot water use boiler efficiency, not the HP SCOP).
if overlay.main_heating_index_number is not None:
main.sap_main_heating_code = None
elif overlay.sap_main_heating_code is not None:
main.main_heating_index_number = None
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)
# `SolarOverlay` fields all live on `sap_energy_source` (the home of the SAP
# Appendix M PV inputs) — the sixth overlay surface (ADR-0026).
_ENERGY_SOURCE_SOLAR_FIELDS: tuple[str, ...] = (
"photovoltaic_arrays",
"pv_diverter_present",
"pv_connection",
"is_dwelling_export_capable",
"pv_batteries",
)
def _fold_solar(epc: EpcPropertyData, overlay: SolarOverlay) -> None:
"""Write a `SolarOverlay`'s non-``None`` fields onto the (copied) dwelling's
``sap_energy_source`` — the PV arrays, diverter, connection, export
capability and battery a Solar PV Measure Option installs (ADR-0026). The
arrays are an absolute target: they replace the dwelling's existing
``photovoltaic_arrays`` (empty for a non-PV dwelling)."""
for field_name in _ENERGY_SOURCE_SOLAR_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
`EpcPropertyData`, so the fold writes onto it, not a nested object."""
for overlay_field in fields(overlay):
value = getattr(overlay, overlay_field.name)
if value is not None:
setattr(epc, overlay_field.name, value)
def _fold_window(window: SapWindow, overlay: WindowOverlay) -> None:
"""Write a `WindowOverlay`'s non-``None`` fields onto a (copied) window:
``glazing_type`` flat on the window, ``u_value`` / ``solar_transmittance``
into its `WindowTransmissionDetails` (where the cascade reads them), starting
a fresh one when the window lodged none."""
if overlay.glazing_type is not None:
window.glazing_type = overlay.glazing_type
if overlay.u_value is None and overlay.solar_transmittance is None:
return
details: Optional[WindowTransmissionDetails] = window.window_transmission_details
if details is None:
# data_source 1 = manufacturer-lodged (the case the cascade's per-window
# U path keys on); both values must be present to start fresh.
window.window_transmission_details = WindowTransmissionDetails(
u_value=overlay.u_value if overlay.u_value is not None else 0.0,
data_source=1,
solar_transmittance=(
overlay.solar_transmittance
if overlay.solar_transmittance is not None
else 0.0
),
)
return
if overlay.u_value is not None:
details.u_value = overlay.u_value
if overlay.solar_transmittance is not None:
details.solar_transmittance = overlay.solar_transmittance
def _fold_glazing(epc: EpcPropertyData, overlay: GlazingOverlay) -> None:
"""Expand a whole-dwelling `GlazingOverlay` across every window: set each
window's `glazing_type` to the corrected SAP10 code AND clear its lodged
transmission U, so `heat_transmission`'s Table-24 cascade re-derives U from
the new type (the lodged U was for the old, mis-recorded glazing). A landlord
glazing override carries no per-window geometry, so it applies uniformly —
the expansion lives here because the baseline window list is known only at
fold time."""
if overlay.glazing_type is None:
return
for window in epc.sap_windows:
window.glazing_type = overlay.glazing_type
window.window_transmission_details = None
def _fold_ventilation(
baseline: Optional[SapVentilation], overlay: VentilationOverlay
) -> SapVentilation:
"""Write the overlay's non-``None`` fields onto a (copied) ``SapVentilation``,
starting a fresh one when the baseline lodged none."""
folded: SapVentilation = (
copy.deepcopy(baseline) if baseline is not None else SapVentilation()
)
for overlay_field in fields(overlay):
value = getattr(overlay, overlay_field.name)
if value is not None:
setattr(folded, overlay_field.name, value)
return folded