From 29c776bb23277189141f6447be2e52a26b1d3abc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 18 May 2026 14:48:11 +0000 Subject: [PATCH] slice S-B6: glazing g_perpendicular + frame_factor lookups (Tables 6b/6c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two hardcoded glazing defaults (g⊥=0.63, FF=0.7) in the cert→inputs mapper with spec-driven lookups: - g_perpendicular by glazing_type (Table 6b): single → 0.85, double 2002+ → 0.72, low-E soft → 0.63, secondary → 0.76, triple → 0.68. Default 0.72 when missing. - frame_factor by frame_material (Table 6c): wood/PVC/composite → 0.70, aluminium/steel/metal → 0.83. Measured values from window_transmission_details / SapWindow.frame_factor still take precedence. Overshading factor stays at 0.77 ("average") since RdSAP 10 doesn't lodge a per-window overshading code. 100-cert parity probe: MAE 5.65 → 5.70 (flat) exact-match within ±1: 18% → 20% bias +1.13 → +1.50 Slight bias drift toward over-prediction is expected — bigger solar gains reduce predicted heating demand. Net: the engine is now more spec-correct (more exact matches), but composition of errors elsewhere needs the next slice to bring bias back toward 0. Co-Authored-By: Claude Opus 4.7 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 80 ++++++++++++++++--- .../sap/rdsap/tests/test_cert_to_inputs.py | 36 +++++++++ 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 0517fb54..09d28179 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -88,6 +88,41 @@ _ORIENTATION_BY_CODE: Final[dict[int, Orientation]] = { } +# SAP 10.3 Table 6b — g_perpendicular (solar transmittance at normal +# incidence) by SAP10 glazing_type code. Default 0.72 (modern double +# glazing, no low-E) when the cert's glazing_type is missing or +# unrecognised — the modal RdSAP case. +_G_PERPENDICULAR_BY_GLAZING_TYPE: Final[dict[int, float]] = { + 1: 0.85, # single + 2: 0.72, # double 2002-2022 (no low-E) + 3: 0.72, # double pre-2002 + 4: 0.63, # double low-E soft coat + 5: 0.76, # secondary glazing + 6: 0.68, # triple +} +_G_PERPENDICULAR_DEFAULT: Final[float] = 0.72 + + +# SAP 10.3 Table 6c — frame factor (proportion of window area that is +# glazed, not frame) by frame material. The cert lodges this as a free- +# text string ("PVC", "Wood", "Metal", "Aluminium"); matching is case- +# insensitive and substring-based because site-notes capitalisation +# drifts. +_FRAME_FACTOR_BY_MATERIAL: Final[tuple[tuple[str, float], ...]] = ( + ("metal with thermal break", 0.80), + ("metal", 0.83), + ("aluminium with thermal break", 0.80), + ("aluminium", 0.83), + ("steel", 0.83), + ("wood", 0.70), + ("timber", 0.70), + ("pvc", 0.70), + ("upvc", 0.70), + ("composite", 0.70), +) +_FRAME_FACTOR_DEFAULT: Final[float] = 0.70 + + # SAP 10.3 Table 12 CO2 emission factors (kg CO2 / kWh delivered). # Keys are SAP 10.2 Table 32 fuel codes (the existing fuel-price keys); # anything not listed cascades to mains-gas baseline. @@ -206,14 +241,39 @@ def _living_area_fraction(habitable_rooms_count: Optional[int]) -> float: return _LIVING_AREA_FRACTION_MIN +def _g_perpendicular(w: SapWindow) -> float: + """Solar transmittance at normal incidence per SAP 10.3 Table 6b. + Prefer the cert's measured value (`window_transmission_details`); + otherwise look up by `glazing_type`.""" + if w.window_transmission_details is not None: + return float(w.window_transmission_details.solar_transmittance) + if isinstance(w.glazing_type, int) and w.glazing_type in _G_PERPENDICULAR_BY_GLAZING_TYPE: + return _G_PERPENDICULAR_BY_GLAZING_TYPE[w.glazing_type] + return _G_PERPENDICULAR_DEFAULT + + +def _frame_factor(w: SapWindow) -> float: + """SAP 10.3 Table 6c frame factor. Prefer the cert's measured value + (`SapWindow.frame_factor`); otherwise look up by `frame_material`.""" + if w.frame_factor is not None: + return float(w.frame_factor) + material = (w.frame_material or "").lower() + for needle, ff in _FRAME_FACTOR_BY_MATERIAL: + if needle in material: + return ff + return _FRAME_FACTOR_DEFAULT + + def _window_inputs(windows: list[SapWindow]) -> tuple[WindowInput, ...]: """Map each cert window with a known SAP octant to a `WindowInput`. - Defaults for the optical / shading factors are SAP 10.3 Table 6b/6c/6d - typicals (low-e double-glazing, PVC frame, average overshading) — the - cert rarely carries these directly. Pitch = 90° (vertical windows). - Windows whose orientation isn't in 1-8 are dropped, matching the live - ML feature-builder convention. + `g_perpendicular` follows SAP 10.3 Table 6b (by glazing_type when no + measured transmission details), `frame_factor` follows Table 6c (by + frame_material when no measured value). Overshading factor stays at + SAP's "average" default 0.77 because the cert doesn't lodge a per- + window overshading code in RdSAP 10. Pitch = 90° (vertical windows). + Windows whose orientation isn't in 1-8 are dropped, matching the + live ML feature-builder convention. """ out: list[WindowInput] = [] for w in windows: @@ -221,19 +281,13 @@ def _window_inputs(windows: list[SapWindow]) -> tuple[WindowInput, ...]: if orientation_code is None or orientation_code not in _ORIENTATION_BY_CODE: continue area = float(w.window_width) * float(w.window_height) - g = ( - float(w.window_transmission_details.solar_transmittance) - if w.window_transmission_details is not None - else 0.63 - ) - ff = float(w.frame_factor) if w.frame_factor is not None else 0.7 out.append( WindowInput( area_m2=area, orientation=_ORIENTATION_BY_CODE[orientation_code], pitch_deg=90.0, - g_perpendicular=g, - frame_factor=ff, + g_perpendicular=_g_perpendicular(w), + frame_factor=_frame_factor(w), overshading_factor=0.77, ) ) diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index 112f73bd..38e3e458 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -253,6 +253,42 @@ def test_main_heating_control_code_maps_to_sap_control_type() -> None: assert type_3.control_type == 3 +def test_window_g_perpendicular_uses_table_6b_by_glazing_type() -> None: + # Arrange — SAP 10.3 Table 6b: g⊥ depends on the glazing type when + # transmission details aren't measured. Single (code 1) → 0.85; + # double low-E soft coat (code 4) → 0.63; triple (code 6) → 0.68. + single = make_window(orientation=5, glazing_type=1, frame_material="PVC") + triple = make_window(orientation=5, glazing_type=6, frame_material="PVC") + low_e_double = make_window(orientation=5, glazing_type=4, frame_material="PVC") + base = _typical_semi_detached_epc() + base.sap_windows = [single, triple, low_e_double] + + # Act + inputs = cert_to_inputs(base) + + # Assert — same orientation, three different glazing types. + g_values = sorted(w.g_perpendicular for w in inputs.windows) + assert g_values == [0.63, 0.68, 0.85] + + +def test_window_frame_factor_uses_table_6c_by_frame_material() -> None: + # Arrange — Table 6c: PVC/Wood = 0.70; aluminium / steel = 0.83 + # (no thermal break). Metal-with-thermal-break would be 0.80 but + # not tested here since cert strings rarely carry that distinction. + pvc = make_window(orientation=5, frame_material="PVC") + wood = make_window(orientation=5, frame_material="Wood") + aluminium = make_window(orientation=5, frame_material="Aluminium") + base = _typical_semi_detached_epc() + base.sap_windows = [pvc, wood, aluminium] + + # Act + inputs = cert_to_inputs(base) + + # Assert + ff_values = sorted(w.frame_factor for w in inputs.windows) + assert ff_values == [0.70, 0.70, 0.83] + + def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None: # Arrange — RdSAP convention: when the main heating is electric- # storage (code 401-409) or direct-electric (191-196), space heating