"""The Simulation Overlay (`EpcSimulation`) — the change a single Measure Option makes to a Property's EpcPropertyData. An all-optional partial mirror of EpcPropertyData / SapBuildingPart, covering the retrofit-relevant surface only (wall fields first). It is *not* an EpcPropertyData — composition, not inheritance — and carries no scores. Building parts are targeted by `BuildingPartIdentifier` so a measure addresses the exact `SapBuildingPart` (the main wall vs an extension). See CONTEXT.md. """ from dataclasses import dataclass, field from typing import List, Mapping, Optional, Union from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, PhotovoltaicArray, PvBatteries, ) @dataclass(frozen=True) class BuildingPartOverlay: """All-optional partial of `SapBuildingPart` (wall surface first). A `None` field means "leave the baseline value unchanged". """ # The wall material (RdSAP `wall_construction` code). Left `None` by Measures # — insulating a wall doesn't change its material — but set by a Landlord # Override that corrects the construction itself (ADR-0032). # RdSAP England-&-Wales construction age band — the letter code A..M the # calculator's U-value cascades key on (`SapBuildingPart.construction_age_band`). # Left `None` by Measures (retrofits don't change build era); set by a Landlord # Override that corrects the lodged age band, which re-derives this part's # fabric U-value defaults. Folds onto the part via the generic field loop. construction_age_band: Optional[str] = None wall_construction: Optional[int] = None wall_insulation_type: Optional[int] = None # Disambiguates the RdSAP `wall_construction == 6` code collision: gov-EPC # code 6 = "Basement wall" but RdSAP `WALL_SYSTEM_BUILT` is also 6, and # `SapBuildingPart.main_wall_is_basement` falls back to the code-6 heuristic # when the flag is `None`. A Landlord Override that sets a System-built wall # (construction 6) must therefore set this `False` so the override isn't # mis-read as a basement — the overlay-path mirror of the gov-API mapper's # `_clear_basement_flag_when_system_built`. wall_is_basement: Optional[bool] = None # Added solid-wall insulation depth (mm) — drives the calculator's Table 6 # bucket / §5.8 documentary U-value for EWI (`wall_insulation_type=1`) and # IWI (`wall_insulation_type=3`); λ defaults to 0.04 W/m·K in the calculator. wall_insulation_thickness: Optional[int] = None roof_insulation_thickness: Optional[int] = None # The roof shape string the calculator's flat-roof path keys on # (`heat_transmission.py`: `"flat" in roof_construction_type`). Left `None` # by Measures (insulating a roof doesn't change its shape); set by a Landlord # Override that corrects a roof to flat so the `_FLAT_ROOF_BY_AGE` age-band # default applies (ADR-0033). Folds onto the part via the generic field loop. roof_construction_type: Optional[str] = None floor_insulation_thickness: Optional[int] = None floor_insulation_type_str: Optional[str] = None @dataclass(frozen=True) class VentilationOverlay: """All-optional partial of `SapVentilation` — the whole-dwelling ventilation change a Measure Option makes (e.g. retrofit MEV). Unlike a `BuildingPartOverlay` this targets no building part; it folds onto the dwelling's single `sap_ventilation`. `mechanical_ventilation_kind` names the SAP10.2 §2 mechanical-ventilation kind (the `MechanicalVentilationKind` enum name, e.g. ``"EXTRACT_OR_PIV_OUTSIDE"`` for decentralised MEV). A `None` field means "leave the baseline value unchanged". """ mechanical_ventilation_kind: Optional[str] = None @dataclass(frozen=True) class WindowOverlay: """All-optional partial of one `SapWindow` — the change a glazing Measure makes to a single window (ADR-0022). `glazing_type` is the SAP10.2 Table U2 code (drives only the §5 daylight factor when a per-window U is lodged). `u_value` and `solar_transmittance` are written into the window's `WindowTransmissionDetails` — where the calculator reads heat loss and solar gain from — because our calculator consumes the lodged values directly rather than deriving them from `glazing_type`. `frame_factor` is written flat on the window (the §6 solar-gain area factor); a replacement unit re-lodges its own FF, which can differ from the pane it replaced. A `None` field means "leave the baseline value unchanged". """ glazing_type: Optional[int] = None u_value: Optional[float] = None solar_transmittance: Optional[float] = None frame_factor: Optional[float] = None @dataclass(frozen=True) class GlazingOverlay: """All-optional partial of the dwelling's whole-glazing state — the correction a Landlord Override makes when the lodged glazing is wrong. Unlike a per-window `WindowOverlay` (keyed by `sap_windows` index), this targets no single window: a landlord describes the dwelling's glazing as a whole ("Double glazing, 2002 or later") with no per-window geometry, so the overlay builder (which never sees the baseline window list) emits one of these and `_fold_glazing` expands it across every `sap_windows` entry. `glazing_type` is the SAP10 glazing-type code (Table 24 / `u_window` cascade: 1=single, 2=double 2002-2021, 3=double pre-2002, 9=triple 2002+, …). The fold sets it on every window AND clears each window's lodged transmission U-value, so the Table-24 cascade re-derives the corrected U from the new type (the lodged U was for the OLD, mis-recorded glazing). A `None` field means "leave the baseline value unchanged". """ glazing_type: Optional[int] = None @dataclass(frozen=True) class LightingOverlay: """All-optional partial of the dwelling's fixed-lighting bulb counts — the whole-dwelling lighting change a Measure Option makes (e.g. an all-LED upgrade — ADR-0023). Unlike a `BuildingPartOverlay` or `WindowOverlay` this targets no building part or window; its fields are the four **top-level** `EpcPropertyData` bulb counts, folded directly by name. The counts are absolute target states, not deltas (an all-LED upgrade sets ``led = total``, the rest 0). A `None` field means "leave the baseline value unchanged". """ led_fixed_lighting_bulbs_count: Optional[int] = None cfl_fixed_lighting_bulbs_count: Optional[int] = None incandescent_fixed_lighting_bulbs_count: Optional[int] = None 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 # A modern condensing boiler has a fanned (room-sealed) flue; the boiler # upgrade sets this True (SAP 10.2 Table 4f flue-fan electricity + the # Table 4b condensing-boiler seasonal-efficiency basis depend on it). fan_flue_present: Optional[bool] = None # The boiler's flue type (Elmhurst enum) — a new condensing boiler lodges # type 2 (room-sealed/balanced). SAP-inert, but written for fidelity so the # end-state matches the installed boiler. boiler_flue_type: 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 cylinder_thermostat: Optional[str] = None # The cylinder's immersion-heater arrangement (e.g. single off-peak # immersion = 1). Drives the SAP 10.2 Table 13 HW high-rate fraction on # off-peak tariffs — without it the calculator cannot resolve # `immersion_single` and bills HW 100% at the low rate. immersion_heating_type: Optional[Union[int, str]] = None # EpcPropertyData (top-level) has_hot_water_cylinder: Optional[bool] = None # sap_energy_source meter_type: Optional[str] = None mains_gas: Optional[bool] = None # A landlord heating-system override DESCRIBES the existing dwelling, so its # assumed off-peak (`meter_type`) is a coherent default, not a re-metering: # it must not downgrade a cert that already lodges a MORE off-peak meter # (e.g. a 24-hour all-low tariff → the overlay's 7-hour E7). A heating # MEASURE re-meters for real (Elmhurst re-lodges 18-hour → Dual on a storage # install), so it leaves this False. `_fold_heating` reads it. keep_existing_off_peak_meter: bool = False @dataclass(frozen=True) class SecondaryHeatingOverlay: """The change the Secondary Heating Removal Measure makes (ADR-0028): strip the dwelling's lodged secondary heating system so the main serves 100% of space heating. Unlike every other overlay — which writes a *target state* and treats ``None`` as "leave unchanged" — this overlay *clears* the secondary fields (`secondary_heating_type`, `secondary_fuel_type`) to absent. Its presence on an `EpcSimulation` is the signal; `remove` carries the intent explicitly. On an electric-storage main RdSAP §A.2.2 forces a default secondary back, so removal is a no-op there — the Optimiser de-selects those (it owns the economics); eligibility still offers them. """ remove: bool = True @dataclass(frozen=True) class SolarOverlay: """All-optional partial of the dwelling's PV-bearing energy source — the whole-dwelling change a Solar PV Measure Option makes (ADR-0026). Like the ventilation/lighting overlays it targets no building part; `_fold_solar` writes its fields onto `sap_energy_source`, the home of the SAP Appendix M PV inputs. The values are **absolute target states**, not deltas: - ``photovoltaic_arrays`` is the *installed potential* — one `PhotovoltaicArray` per non-north roof segment — and **replaces** the dwelling's existing `photovoltaic_arrays` (empty for a non-PV dwelling), never the EPC's own existing PV; - ``pv_diverter_present`` routes surplus PV to a hot-water cylinder immersion (App G4); set only when the dwelling has a cylinder to divert to (a combi has none); - ``is_dwelling_export_capable`` is set ``True`` absolutely — an export meter is ensured post-install, driving the SEG export credit regardless of the before; - ``pv_batteries`` carries the battery variant's storage. A `None` field means "leave the baseline value unchanged". """ photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None pv_diverter_present: Optional[bool] = None pv_connection: Optional[Union[int, str]] = None is_dwelling_export_capable: Optional[bool] = None pv_batteries: Optional[PvBatteries] = None def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: return {} def _no_windows() -> dict[int, WindowOverlay]: return {} @dataclass(frozen=True) class EpcSimulation: """A Simulation Overlay: the per-building-part changes a Measure Option makes, keyed by `BuildingPartIdentifier`; per-window changes keyed by the `sap_windows` index; plus an optional whole-dwelling `ventilation` change (the Measure Dependency surface — ADR-0016).""" building_parts: Mapping[BuildingPartIdentifier, BuildingPartOverlay] = field( default_factory=_no_building_parts ) windows: Mapping[int, WindowOverlay] = field(default_factory=_no_windows) ventilation: Optional[VentilationOverlay] = None lighting: Optional[LightingOverlay] = None glazing: Optional[GlazingOverlay] = None heating: Optional[HeatingOverlay] = None secondary_heating: Optional[SecondaryHeatingOverlay] = None solar: Optional[SolarOverlay] = None # Whole-dwelling categorical corrections from a Landlord Override (ADR-0032). # Measures never set these; a landlord may correct the lodged property type / # built form (property_type drives party-wall heat loss + measure eligibility). property_type: Optional[str] = None built_form: Optional[str] = None # Whole-dwelling RdSAP 10 §8.1 draught-proofing percentage, when a # Measure changes it (e.g. glazing: sealed units draught-proof the # panes they replace). The §2 cascade reads this dwelling-level value, # so the overlay sets it directly. `None` leaves the baseline's value. percent_draughtproofed: Optional[int] = None