This commit is contained in:
KhalimCK 2026-06-05 19:33:04 +01:00 committed by GitHub
commit 7782183a4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 98 additions and 28 deletions

View file

@ -21,7 +21,7 @@ scoring (ADR-0016).
from dataclasses import dataclass
from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, WindowOverlay
@ -47,6 +47,7 @@ class _GlazingTarget:
glazing_type: int
u_value: float
solar_transmittance: float
frame_factor: float
# Unrestricted: replace the units with double glazing (gt=5 "Double post 2022";
@ -57,6 +58,10 @@ _DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
glazing_type=5,
u_value=1.40,
solar_transmittance=0.72,
# Replacement double units re-lodge a standard FF=0.70 (cert 001431's
# after), overriding the panes they replace (e.g. FF 1.00 / 0.50 on the
# "single glazing, known data" windows) — feeds §6 solar gains.
frame_factor=0.70,
)
# Protected (conservation/listed/heritage): fit an internal secondary pane
# (gt=11 "Secondary glazing - Normal emissivity", what cert 001431 re-lodges;
@ -69,9 +74,61 @@ _SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
glazing_type=11,
u_value=2.90,
solar_transmittance=0.85,
frame_factor=0.70, # cert 001431's after re-lodges FF=0.70.
)
def _is_draught_proofed(window: SapWindow) -> bool:
"""`SapWindow.draught_proofed` is `Union[bool, str]` (bool from the
site-notes mapper, the string "true"/"false" from the API). Normalise
to a bool."""
flag = window.draught_proofed
if isinstance(flag, bool):
return flag
return flag.strip().lower() in {"true", "yes"}
def _recompute_percent_draughtproofed(
epc: EpcPropertyData, upgraded_indices: tuple[int, ...]
) -> Optional[int]:
"""RdSAP 10 §8.1 draught-proofing percentage after a glazing upgrade.
§8.1: "[(number of draughtproofed openable windows & doors) / (total
number of openable windows & doors)] × 100", as an integer. Sealed
double/secondary units are draught-proofed, so every upgraded window
that was NOT draught-proofed flips into the numerator.
Anchored on the lodged dwelling-level `percent_draughtproofed` (the
value the §2 cascade reads) rather than re-deriving the before-count
from per-window flags: the unchanged openings are already folded into
that aggregate, so this is robust to incomplete window extraction.
d0 = round(before% / 100 × N) # draught-proofed before
after = round((d0 + flips) / N × 100) # RdSAP 10 §8.1, integer
N counts every openable window (vertical + roof) plus external doors.
Returns None when no before% is lodged or there are no openings.
"""
before = epc.percent_draughtproofed
if before is None:
return None
n_openings = (
len(epc.sap_windows)
+ len(epc.sap_roof_windows or [])
+ (epc.door_count or 0)
)
if n_openings <= 0:
return None
flips = sum(
1
for index in upgraded_indices
if not _is_draught_proofed(epc.sap_windows[index])
)
draught_proofed_before = round(before / 100 * n_openings)
after = round((draught_proofed_before + flips) / n_openings * 100)
return max(0, min(100, after))
def recommend_glazing(
epc: EpcPropertyData,
products: ProductRepository,
@ -98,9 +155,15 @@ def recommend_glazing(
glazing_type=target.glazing_type,
u_value=target.u_value,
solar_transmittance=target.solar_transmittance,
frame_factor=target.frame_factor,
)
for index in single_indices
}
},
# Sealed units draught-proof the panes they replace — RdSAP 10
# §8.1 re-lodges the dwelling's percentage (cert 001431: 84 → 100).
percent_draughtproofed=_recompute_percent_draughtproofed(
epc, single_indices
),
)
cost = Cost(
total=len(single_indices) * product.unit_cost_per_m2,

View file

@ -50,6 +50,8 @@ def apply_simulations(
)
if simulation.lighting is not None:
_fold_lighting(result, simulation.lighting)
if simulation.percent_draughtproofed is not None:
result.percent_draughtproofed = simulation.percent_draughtproofed
return result
@ -66,11 +68,13 @@ def _fold_lighting(epc: EpcPropertyData, overlay: LightingOverlay) -> None:
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."""
``glazing_type`` and ``frame_factor`` 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.frame_factor is not None:
window.frame_factor = overlay.frame_factor
if overlay.u_value is None and overlay.solar_transmittance is None:
return
details: Optional[WindowTransmissionDetails] = window.window_transmission_details

View file

@ -57,12 +57,16 @@ class WindowOverlay:
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`. `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)
@ -105,3 +109,8 @@ class EpcSimulation:
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

View file

@ -17,8 +17,6 @@ from __future__ import annotations
from dataclasses import replace
from typing import Final
import pytest
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
@ -486,24 +484,13 @@ def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None:
assert _window_spec(applied) == _window_spec(after)
_GLAZING_DRAUGHT_COUPLING_REASON: Final[str] = (
"Blocked on the glazing measure's draught-proofing coupling. The window "
"U/g overlay reproduces the after's 14 windows EXACTLY (all four single-"
"glazed panes — codes 1 and 15 — become the relodged double/secondary "
"spec). The residual ~0.7 SAP is a secondary effect the overlay does not "
"model: replacing the single-glazed (lodged draught_proofed=No) windows "
"with sealed units re-lodges percent_draughtproofed 84->100 (~0.3 SAP) and "
"lowers fabric heat loss by ~+150 kWh space heating (~0.4 SAP) not yet "
"isolated. Flips green once the glazing overlay propagates draught-proofing "
"(and the residual fabric coupling is modelled)."
)
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
def test_double_glazing_overlay_reproduces_the_relodged_after() -> None:
# Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15,
# "single glazing, known data"); the after re-lodges every one as double
# (gt=5, U=1.40, g=0.72).
# (gt=5, U=1.40, g=0.72). The overlay now also models the two secondary
# effects of fitting sealed units: RdSAP 10 §8.1 draught-proofing
# 84 → 100 (`percent_draughtproofed`) and the re-lodged FF=0.70 on the
# panes that lodged FF 1.00 / 0.50 — so the full-SAP pin closes.
before: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_before.pdf"
)
@ -519,7 +506,6 @@ def test_double_glazing_overlay_reproduces_the_relodged_after() -> None:
)
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a planning protection forces secondary glazing; the after
# re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85).

View file

@ -67,8 +67,12 @@ def test_single_glazed_dwelling_yields_a_double_glazing_recommendation() -> None
option = recommendation.options[0]
assert option.measure_type == "double_glazing"
assert dict(option.overlay.windows) == {
0: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72),
2: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72),
0: WindowOverlay(
glazing_type=5, u_value=1.40, solar_transmittance=0.72, frame_factor=0.70
),
2: WindowOverlay(
glazing_type=5, u_value=1.40, solar_transmittance=0.72, frame_factor=0.70
),
}
@ -121,6 +125,10 @@ def test_planning_protection_picks_secondary_glazing_over_double() -> None:
option = recommendation.options[0]
assert option.measure_type == "secondary_glazing"
assert dict(option.overlay.windows) == {
0: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85),
2: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85),
0: WindowOverlay(
glazing_type=11, u_value=2.90, solar_transmittance=0.85, frame_factor=0.70
),
2: WindowOverlay(
glazing_type=11, u_value=2.90, solar_transmittance=0.85, frame_factor=0.70
),
}