Model/domain/modelling/simulation.py
Jun-te Kim 0135f0f27b Resolve a landlord age-band override onto the main building part 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:41:20 +00:00

259 lines
12 KiB
Python

"""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
# 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
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`. A `None` field means "leave the baseline value unchanged".
"""
glazing_type: Optional[int] = None
u_value: Optional[float] = None
solar_transmittance: 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
@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