mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Bill metal-framed API windows on the Table 24 metal U-column 🟩
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) <noreply@anthropic.com>
This commit is contained in:
parent
6287ee7c8a
commit
1f266d0282
2 changed files with 150 additions and 7 deletions
|
|
@ -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]
|
||||
|
|
|
|||
100
tests/datatypes/epc/domain/test_mapper_metal_window_frame.py
Normal file
100
tests/datatypes/epc/domain/test_mapper_metal_window_frame.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue