mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Add the first boiler-upgrade option to the single "Heating & Hot Water" Recommendation (ADR-0024 expansion): a dwelling whose existing wet boiler heats a hot-water cylinder is offered a new gas condensing boiler, with the cylinder jacketed when under-insulated and given a thermostat when absent. One competing Option (the Optimiser picks <=1), folded into one composite Plan line. The end-state is read from the Elmhurst before/after re-lodgements (cert 001431, gas boiler upgrade - with cylinder), which REVISE ADR-0024: - Target is always a gas condensing boiler, not fuel-preserving: every after lodges fuel 26. Gas->gas always; a non-gas wet boiler ->gas only with a mains-gas connection; electric boilers are left alone (electrification is the upgrade path). Eligibility = wet-boiler SAP code (Table 4a/4b 101-141 / 151-161 / 191-196) + not an electric boiler + mains gas present. - End-state is a Table 4b SAP code, not a PCDB index: code 102 (regular boiler + cylinder). The calculator derives the condensing seasonal efficiency from the code, so no efficiency input exists or is needed. - A modern condensing boiler has a fanned flue: the after flips `fan_flue_present` False->True on every cert (SAP 10.2 Table 4f flue-fan + the Table 4b condensing-efficiency basis). Added as a new HeatingOverlay field, routed to main_heating_details[0]. - Cylinder thermostat is always added when absent (user-locked); the jacket is the 80 mm `cylinder_insulation_type=2` end-state, applied only when the cylinder is below 80 mm (never downgrading a better one). Both are conditional per-dwelling components, not a frozen overlay. Cascade-pinned delta-0 (SAP/CO2/PE) against the relodged after via `_assert_overlay_reproduces_after`. NB the absolute SAP on this dwelling is subject to a separate Summary-path mapper roof-fidelity gap (we read the roof better-insulated than Elmhurst, scoring ~75 vs the printed 56); the gap is identical on before+after (the boiler measure never touches the roof) so it cancels and the pin still proves the exact heating field-delta. Tracked on the calculator branch. Wires the new `gas_boiler_upgrade` MeasureType through contingencies (0.26), the offline sample catalogue, the catalogue-coverage list, and the ARA first-run integration seed (the option fires on any mains-gas boiler+cylinder dwelling). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
8.2 KiB
Python
193 lines
8.2 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".
|
|
"""
|
|
|
|
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 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
|
|
# 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
|
|
# 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 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
|
|
heating: Optional[HeatingOverlay] = None
|
|
solar: Optional[SolarOverlay] = None
|