"""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