fix(uvalues): thread glazing gap into pre-2002 window U fallback (RdSAP 10 Table 24, PDF p.50)

`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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-20 14:14:31 +00:00
parent 600684f5df
commit d7a60efcdf
3 changed files with 75 additions and 6 deletions

View file

@ -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

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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.