diff --git a/domain/modelling/generators/glazing_recommendation.py b/domain/modelling/generators/glazing_recommendation.py index ac6c371c..b6ec54d0 100644 --- a/domain/modelling/generators/glazing_recommendation.py +++ b/domain/modelling/generators/glazing_recommendation.py @@ -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, diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index 8bd5dfb0..bde9794d 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -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 diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 94e43c44..2db98cd2 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -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 diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 6bba36ef..3aa20c29 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -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). diff --git a/tests/domain/modelling/test_glazing_recommendation.py b/tests/domain/modelling/test_glazing_recommendation.py index 993052f0..52063b5e 100644 --- a/tests/domain/modelling/test_glazing_recommendation.py +++ b/tests/domain/modelling/test_glazing_recommendation.py @@ -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 + ), }