From 1f266d02825ec7570c7a4572f82b76061f68c47f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 22 Jul 2026 12:54:47 +0000 Subject: [PATCH] =?UTF-8?q?Bill=20metal-framed=20API=20windows=20on=20the?= =?UTF-8?q?=20Table=2024=20metal=20U-column=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the gov-EPC API path a window's metal frame was unrepresentable: both transmission tables were frameless (U_pvc, g, 0.70), so a cert lodging a metal frame (pvc_frame:"false", RdSAP input 6-5) was billed at the PVC/wooden U and frame factor 0.70 — over-rating metal-framed dwellings. Add the RdSAP 10 Table 24 metal U-column (PDF p.50-51; mirrors u_window's metal values) and a pvc_frame dimension to _api_glazing_transmission: an explicit "false" routes the window to the metal U + SAP 10.2 Table 6c frame factor 0.8; "true"/None/other keep the PVC column + 0.70, so no PVC cert regresses. Secondary glazing keeps its frame-independent U (only FF changes). Wired into _api_sap_window and _api_sap_roof_window (both full-schema, lodge pvc_frame); the reduced-field path has no pvc_frame and is unchanged. Spec: RdSAP 10 Table 24 metal column, SAP 10.2 Table 6c, RdSAP input 6-5 (p.80). Corpus: within-0.5 80.6% -> 81.0%, MAE 0.578 -> 0.571. Cert 21067296 68.60 -> 66.96 (toward lodged 67). Closes #1667. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 57 ++++++++-- .../domain/test_mapper_metal_window_frame.py | 100 ++++++++++++++++++ 2 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 tests/datatypes/epc/domain/test_mapper_metal_window_frame.py diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index ffa27812d..37eeb353e 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5342,24 +5342,67 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[ } +# RdSAP 10 Table 24 METAL-frame U-column (PDF p.50-51) — #1667. Only the U +# differs from the PVC/wooden column above (the glazing g is frame-independent); +# the frame factor becomes 0.8 (SAP 10.2 Table 6c) instead of 0.70. Secondary +# glazing (types 4/11/12) has no separate metal U — its combined U is +# frame-independent — so it is absent here and keeps the PVC-column U. Values +# mirror `u_window`'s metal column (`rdsap_uvalues.py`): single 5.7; DG pre-2002 +# 6/12/16 = 3.7/3.4/3.3; triple pre-2002 6/12/16 = 2.9/2.6/2.5; DG/triple 2002+ +# = 2.2; 2022+ = 1.6. +_API_GLAZING_TYPE_TO_METAL_U: Dict[int, float] = { + 1: 3.4, 3: 3.4, 7: 3.4, # DG pre-2002 / unknown-date, 12mm default + 2: 2.2, 9: 2.2, # DG (2) / triple (9) 2002+ (pre-2022) + 13: 1.6, 14: 1.6, # DG / DG-or-triple 2022+ + 5: 5.7, 15: 5.7, # single glazing + 6: 2.6, 8: 2.6, 10: 2.6, # triple pre-2002 / unknown / known, 12mm default +} +_API_GLAZING_TYPE_GAP_TO_METAL_U: Dict[tuple[int, object], float] = { + (1, 6): 3.7, (1, 12): 3.4, (1, "16+"): 3.3, + (3, 6): 3.7, (3, 12): 3.4, (3, "16+"): 3.3, + (7, 6): 3.7, (7, 12): 3.4, (7, "16+"): 3.3, + (6, 6): 2.9, (6, 12): 2.6, (6, "16+"): 2.5, + (8, 6): 2.9, (8, 12): 2.6, (8, "16+"): 2.5, + (10, 6): 2.9, (10, 12): 2.6, (10, "16+"): 2.5, +} + +# SAP 10.2 Table 6c window frame factor: 0.8 for metal, 0.70 for PVC/wooden. +_METAL_FRAME_FACTOR: Final[float] = 0.8 + + def _api_glazing_transmission( glazing_type: Optional[int], glazing_gap: object, + pvc_frame: object = None, ) -> Optional[tuple[float, float, float]]: """Resolve (U, g, frame_factor) for an API window from RdSAP 10 Table 24. Per-gap override takes precedence over the type-only default. Returns None only when `glazing_type` is absent (None → cascade default). A glazing_type PRESENT but unmapped raises `UnmappedApiCode` rather than silently routing the window to the u_window all-None default U=2.5 — the forcing function - that surfaced single-glazing (code 5 → 4.8) instead of letting it hide.""" + that surfaced single-glazing (code 5 → 4.8) instead of letting it hide. + + `pvc_frame` is the lodged RdSAP input 6-5 boolean (spec p.80): "true" = + wooden/PVC (grouped in Table 24), "false" = metal. Only an explicit "false" + routes the window to the Table 24 metal U-column + FF 0.8 (#1667); "true", + None or any other value keeps the PVC/wooden column + FF 0.70, so no + PVC-lodged cert regresses.""" if glazing_type is None: return None gap_key = (glazing_type, glazing_gap) if gap_key in _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: - return _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key] - if glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION: - return _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type] - raise UnmappedApiCode("glazing_type", glazing_type) + base = _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key] + elif glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION: + base = _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type] + else: + raise UnmappedApiCode("glazing_type", glazing_type) + if pvc_frame != "false": + return base + u_pvc, g_perp, _pvc_ff = base + metal_u = _API_GLAZING_TYPE_GAP_TO_METAL_U.get( + gap_key, _API_GLAZING_TYPE_TO_METAL_U.get(glazing_type, u_pvc) + ) + return (metal_u, g_perp, _METAL_FRAME_FACTOR) # GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade @@ -5478,7 +5521,7 @@ def _api_sap_roof_window(w: Any) -> SapRoofWindow: Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to the inclined-position value the worksheet bills on (27a). Mirror of the site-notes `_map_elmhurst_roof_window`.""" - transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame) vertical_u = transmission[0] if transmission is not None else 2.0 g_perp = transmission[1] if transmission is not None else 0.76 frame_factor = w.frame_factor @@ -5548,7 +5591,7 @@ def _api_sap_window(w: Any) -> SapWindow: to the SAP 10.2 Table 6b cascade enum so the cascade's daylight g_L lookup picks the spec-correct value (e.g. RdSAP 21 code 1 = DG pre-2002, cascade g_L=0.80, not single-glazed 0.90).""" - transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame) frame_factor: Optional[float] = w.frame_factor if frame_factor is None and transmission is not None: frame_factor = transmission[2] diff --git a/tests/datatypes/epc/domain/test_mapper_metal_window_frame.py b/tests/datatypes/epc/domain/test_mapper_metal_window_frame.py new file mode 100644 index 000000000..6681a4e7b --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_metal_window_frame.py @@ -0,0 +1,100 @@ +"""#1667 — on the gov-EPC API path a window's metal frame was unrepresentable: +the transmission lookup had no frame dimension, so every window was billed at +the PVC/wooden U-column and frame factor 0.70 even where the cert lodges a metal +frame (`pvc_frame:"false"`). RdSAP 10 Table 24 gives metal a higher U (+0.2 to ++0.9 W/m^2K) and SAP 10.2 Table 6c a frame factor of 0.8, so we over-rated +metal-framed dwellings. The mapper must route a metal-lodged window to the +Table 24 metal column + FF 0.8, leaving PVC-lodged windows unchanged. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any, cast + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import Sap10Calculator + +_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + + +def _corpus_cert(uprn: str) -> dict[str, Any]: + for line in _CORPUS.read_text().splitlines(): + if uprn in line: + cert: dict[str, Any] = json.loads(line) + return cert + raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus") + + +def _forced_pvc_frame(payload: dict[str, Any], value: str) -> dict[str, Any]: + """Deep-copy `payload`, setting every lodged `pvc_frame` to `value` — used to + build a PVC-framed twin of a metal-framed cert.""" + twin = deepcopy(payload) + + def walk(node: object) -> None: + if isinstance(node, dict): + node_dict = cast("dict[str, Any]", node) + for key, child in node_dict.items(): + if key == "pvc_frame" and child is not None: + node_dict[key] = value + else: + walk(child) + elif isinstance(node, list): + for child in cast("list[Any]", node): + walk(child) + + walk(twin) + return twin + + +def _window_u_and_ff(payload: dict[str, Any]) -> tuple[set[float], set[float]]: + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + u_values = { + w.window_transmission_details.u_value + for w in epc.sap_windows + if w.window_transmission_details is not None + } + frame_factors = {w.frame_factor for w in epc.sap_windows if w.frame_factor is not None} + return u_values, frame_factors + + +def test_metal_frame_window_bills_table_24_metal_u_and_frame_factor_0p8() -> None: + # Arrange — cert 21067296 lodges six pvc_frame:"false" (metal) double-glazed + # (type 3, 12 mm) windows; HEAD billed them on the PVC column (U 2.8, FF 0.70). + payload = _corpus_cert("21067296") + + # Act + u_values, frame_factors = _window_u_and_ff(payload) + + # Assert — Table 24 metal DG-12mm column (3.4) and SAP 10.2 Table 6c FF 0.8. + assert u_values == {3.4} + assert frame_factors == {0.8} + + +def test_pvc_frame_window_stays_on_the_pvc_column() -> None: + # Arrange — the same cert forced to pvc_frame:"true"; the fix must not + # regress PVC/wooden-framed windows (the discriminator is the boolean). + payload = _forced_pvc_frame(_corpus_cert("21067296"), "true") + + # Act + u_values, frame_factors = _window_u_and_ff(payload) + + # Assert — PVC DG-12mm column (2.8) and FF 0.70, exactly as before. + assert u_values == {2.8} + assert frame_factors == {0.7} + + +def test_metal_frame_fix_moves_cert_toward_lodged() -> None: + # Arrange — 21067296 scored 68.60 on HEAD (PVC assumption), lodged 67. + payload = _corpus_cert("21067296") + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + + # Act + sap = Sap10Calculator().calculate(epc).sap_score_continuous + + # Assert — the metal U-penalty pulls it to ~66.96, i.e. toward lodged 67. + assert abs(sap - 66.96) <= 0.05