Model/domain/modelling/simulation.py
Khalim Conn-Kowlessar f869a1f6a7 modelling: glazing overlay models draught-proofing + frame-factor re-lodge
Fitting sealed glazing units changes two things beyond the pane's U/g
that the cascade reads, which the overlay didn't model — leaving the
double/secondary before→after pins ~0.7 SAP short (xfail):

1. Draught-proofing (RdSAP 10 §8.1). Sealed units draught-proof the panes
   they replace, re-lodging the dwelling-level `percent_draughtproofed`
   (cert 001431: 84 → 100). The §2 cascade reads that dwelling-level
   value, so the overlay now carries it. `_recompute_percent_draughtproofed`
   anchors on the lodged before-% — `after = round((round(before%/100 × N)
   + flips) / N × 100)`, N = openable windows (vertical + roof) + doors,
   flips = upgraded panes that were not draught-proofed — so it's robust
   to incomplete window extraction (unchanged openings are already in the
   aggregate). ~0.3 SAP.

2. Frame factor (§6 solar gains). A replacement unit re-lodges its own
   FF=0.70, overriding the pane it replaced — the two "single glazing,
   known data" panes lodge FF 1.00 / 0.50 (one is 6.6 m²), so leaving them
   unchanged understated solar gains by ~+150 kWh space heating. `WindowOverlay`
   now carries `frame_factor`, written flat onto the window. ~0.4 SAP.

Wiring: `EpcSimulation.percent_draughtproofed` + `WindowOverlay.frame_factor`
new fields; `apply_simulations` / `_fold_window` write them; the glazing
generator computes both from the upgraded set and cert 001431's after.

Un-xfails `test_{double,secondary}_glazing_overlay_reproduces_the_relodged_after`
— both now pin SAP/CO2/PE to the relodged after within tolerance. Updates
the two `test_glazing_recommendation` overlay expectations for the new
`frame_factor`. 96 modelling tests pass; zero new pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:02:48 +00:00

116 lines
4.9 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 Mapping, Optional
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
@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`. `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 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
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
# 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