mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Merge f869a1f6a7 into f68cea27c9
This commit is contained in:
commit
7782183a4c
5 changed files with 98 additions and 28 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue