From d7a60efcdf2eb2f0491123a0f8c9c8a7364a0e3b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 20 Jun 2026 14:14:31 +0000 Subject: [PATCH] fix(uvalues): thread glazing gap into pre-2002 window U fallback (RdSAP 10 Table 24, PDF p.50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `u_window` hard-coded the 12 mm gap row for pre-2002 double/triple glazing (double 2.8, triple 2.1), ignoring the lodged glazing gap. Table 24 splits the pre-2002 rows by gap: double 6mm=3.1 / 12mm=2.8 / 16mm+=2.7; triple 6mm=2.4 / 12mm=2.1 / 16mm+=2.0 (PVC/wooden), with a metal-frame column (+0.5/+0.5/+0.5 ish). Added a `glazing_gap` parameter + `_glazing_gap_row` helper and wired `w.glazing_gap` through the synthesised-window caller in heat_transmission. Corpus impact nil by design: the gov-API mapper already resolves per-window U gap-aware via `_API_GLAZING_TYPE_GAP_TO_TRANSMISSION` (e.g. code 3 + gap "16+" → 2.7), so corpus certs use that lodged per-window U, not this fallback. This aligns the reduced-field / worksheet fallback path with the mapper and Table 24. Unknown gap still defaults to the 12 mm row. (Metal frames are not distinguishable on the gov-API path — only a `pvc_frame` boolean exists and Table 24 groups PVC+wooden — so the PVC/wooden U stands there; the metal column applies only where frame material is lodged.) Spec-pinned: pre-2002 double + triple gap-row tests. pyright not installed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../worksheet/heat_transmission.py | 7 ++- domain/sap10_ml/rdsap_uvalues.py | 50 +++++++++++++++++-- domain/sap10_ml/tests/test_rdsap_uvalues.py | 24 +++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 80c65aac..a0d773d9 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -171,7 +171,12 @@ def _synthesised_window_u_raw(windows: Optional[Sequence[SapWindow]]) -> float: if isinstance(code, int) else ("double", None) ) - return u_window(installed_year=year, glazing_type=glaze, frame_type=w.frame_material) + return u_window( + installed_year=year, + glazing_type=glaze, + frame_type=w.frame_material, + glazing_gap=w.glazing_gap, + ) # RdSAP10 §15 "Rounding of data" (p.66): "All element areas (gross) # including window areas and conservatory wall area: 2 d.p." plus # "U-values: 2 d.p.". This is the data-passed-to-SAP-calculator diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index b4ab5de3..6687491f 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -1401,12 +1401,51 @@ def u_exposed_floor( # --------------------------------------------------------------------------- +# RdSAP 10 Table 24 (PDF p.50-51) — pre-2002 (Scotland pre-2003 / NI pre-2006) +# double and triple glazing split by glazing gap between panes: 6 mm, 12 mm, +# and 16 mm or more, each with a PVC/wooden and a metal-frame U-value. The +# 2002+ and 2022+ rows are gap-independent ("any" gap). (pvc, metal) per gap: +_PRE_2002_DOUBLE_U_BY_GAP: Final[dict[str, tuple[float, float]]] = { + "6": (3.1, 3.7), "12": (2.8, 3.4), "16+": (2.7, 3.3), +} +_PRE_2002_TRIPLE_U_BY_GAP: Final[dict[str, tuple[float, float]]] = { + "6": (2.4, 2.9), "12": (2.1, 2.6), "16+": (2.0, 2.5), +} + + +def _glazing_gap_row(glazing_gap: "str | int | None") -> str: + """Map a lodged glazing gap to its Table 24 row key ("6" / "12" / "16+"). + + The cert lodges discrete gaps as the int 6 or 12 or the string "16+" + (RdSAP-Schema `glazing_gap`). Unknown gap (None) defaults to the 12 mm + row — the spec's typical pre-2002 sealed unit. Robust to intermediate + integers: <=8 → 6 mm, >=15 → 16 mm-or-more, else 12 mm.""" + if glazing_gap is None: + return "12" + if isinstance(glazing_gap, str): + s = glazing_gap.strip().lower() + if "16" in s or "+" in s: + return "16+" + try: + g = int(float(s)) + except ValueError: + return "12" + else: + g = int(glazing_gap) + if g <= 8: + return "6" + if g >= 15: + return "16+" + return "12" + + def u_window( installed_year: Optional[int], glazing_type: Optional[str], frame_type: Optional[str], + glazing_gap: "str | int | None" = None, ) -> float: - """RdSAP10 window U-value in W/m^2K, never null.""" + """RdSAP10 window U-value in W/m^2K, never null (RdSAP 10 Table 24).""" if glazing_type is None and installed_year is None and frame_type is None: return 2.5 glaze = (glazing_type or "double").lower() @@ -1423,10 +1462,11 @@ def u_window( return 1.6 if metal else 1.4 if installed_year is not None and installed_year >= 2002: return 2.2 if metal else 2.0 - # pre-2002 double/triple default to 12mm gap row. - if glaze == "triple": - return 2.6 if metal else 2.1 - return 3.4 if metal else 2.8 + # pre-2002 double/triple — Table 24 splits by glazing gap (6/12/16+ mm). + gap_row = _glazing_gap_row(glazing_gap) + table = _PRE_2002_TRIPLE_U_BY_GAP if glaze == "triple" else _PRE_2002_DOUBLE_U_BY_GAP + pvc_u, metal_u = table[gap_row] + return metal_u if metal else pvc_u # --------------------------------------------------------------------------- diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 522ccd52..4fb51f42 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -1760,6 +1760,30 @@ def test_u_window_post_2022_metal_returns_table24_1_6_not_pvc_1_4() -> None: assert result == pytest.approx(1.6, abs=0.001) +def test_u_window_pre_2002_double_glazing_gap_selects_table24_row() -> None: + # Arrange — RdSAP 10 Table 24 (PDF p.50) pre-2002 double glazing splits + # by glazing gap (PVC/wooden frame): 6 mm → 3.1, 12 mm → 2.8, 16 mm or + # more → 2.7. The cert lodges the gap as the int 6/12 or the string + # "16+"; unknown gap defaults to the 12 mm row. + + # Act / Assert + assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=6) == pytest.approx(3.1, abs=0.001) + assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=12) == pytest.approx(2.8, abs=0.001) + assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap="16+") == pytest.approx(2.7, abs=0.001) + assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=None) == pytest.approx(2.8, abs=0.001) + + +def test_u_window_pre_2002_triple_glazing_gap_and_metal_frame_select_table24_row() -> None: + # Arrange — Table 24 pre-2002 triple glazing: 6 mm → 2.4, 12 mm → 2.1, + # 16 mm+ → 2.0 (PVC); metal frame adds +0.5 per the metal column + # (6 → 2.9, 12 → 2.6, 16+ → 2.5). + + # Act / Assert + assert u_window(installed_year=None, glazing_type="triple", frame_type="pvc", glazing_gap="16+") == pytest.approx(2.0, abs=0.001) + assert u_window(installed_year=None, glazing_type="triple", frame_type="metal", glazing_gap=6) == pytest.approx(2.9, abs=0.001) + assert u_window(installed_year=None, glazing_type="triple", frame_type="metal", glazing_gap="16+") == pytest.approx(2.5, abs=0.001) + + def test_u_window_falls_back_to_mid_range_when_unknown() -> None: # Arrange — nothing known.