Slice S0380.30: extend g_L + g⊥ Table 6b to RdSAP 21 codes 8-15 — closes API path cohort residual cluster

Per the RdSAP 21 schema in [datatypes/epc/domain/epc_codes.csv][1], the
`glazing_type` enum extends to 15 codes; the legacy SAP 10.2 Table 6b
cascade lookups in `internal_gains.py:106` and `solar_gains.py:178`
only knew codes 1-7. Every API-path cert in the cohort lodges
`glazing_type` via the RdSAP 21 numbering, and triple-glazed
lodgements surface as **code 14** ("triple glazing, installed 2022+").

Pre-slice the cascade fell through to the 0.80 / 0.76 double-glazed
defaults for codes 8-15:

  Internal gains g_L (Table 6b):
    code 14 → default 0.80 (DG)        vs spec 0.70 (TG)
    → daylight factor over-bonused → lighting kWh under-counted

  Solar gains g⊥ (Table 6b):
    code 14 → default 0.76 (DG)        vs spec 0.68 (TG)
    → solar gains over-counted

For cert 0350-2968-2650-2796-5255 (semi-detached, 9 triple-glazed
windows lodged as code 14), this drove:
  lighting_kwh_per_yr: cascade 221.79 vs Summary-path 228.44
    (-6.65 kWh/yr — daylight bonus too generous → lighting too low)
  space_heating_kwh_per_yr: cascade 7000.21 vs Summary-path 6996.94
    (+3.28 kWh/yr — extra solar gains lower HP demand)
  net ECF: -0.0022 vs Summary-path → SAP +0.031

Same mechanism on the other 5 cohort-1 ASHP API certs.

Fix: extend both lookup tables with the RdSAP 21 additions per the
schema CSV semantics:

  | code | description (RdSAP 21)          | g_L  | g⊥   |
  |------|----------------------------------|------|------|
  |  8   | triple glazing, known data      | 0.70 | 0.68 |
  |  9   | triple glazing, 2002-2022       | 0.70 | 0.68 |
  | 10   | triple glazing, pre-2002         | 0.70 | 0.68 |
  | 11   | secondary glazing, normal-E      | 0.80 | 0.76 |
  | 12   | secondary glazing, low-E         | 0.80 | 0.76 |
  | 13   | double glazing, 2022+            | 0.80 | 0.76 |
  | 14   | triple glazing, 2022+            | 0.70 | 0.68 |
  | 15   | single glazing, known data       | 0.90 | 0.85 |

Solar gains also adds code 7 (double known data) for
`_G_PERPENDICULAR_BY_GLAZING_TYPE` to align with the existing
`_G_LIGHT_BY_GLAZING_CODE` code-7 entry (which already mapped to
0.80 = double).

Outcome — Cohort-1 ASHP cohort API path:
  cert 0380:  +0.025  →  +1e-6     (close to exact)
  cert 0350:  +0.031  →  +2.2e-5   (close to exact)
  cert 2225:  +0.029  →  -4.8e-5   (close to exact)
  cert 2636:  +0.015  →  -0.015    (sign flip; cantilever-specific
                                    residual surfaces; same |Δ| as Summary)
  cert 3800:  +0.023  →  -2e-5     (close to exact)
  cert 9285:  +0.029  →  -3.4e-5   (close to exact)

5 of 6 API path certs now sit at <1e-4 vs worksheet. Cert 2636
matches its Summary-path residual (-0.015) — the cantilever fixture
has its own non-glazing residual to be diagnosed separately.

Cohort-2 Summary path unchanged (33 exact + 5 ≤0.07) — the cohort-2
certs lodge glazing codes 1-7 (RdSAP 17 numbering still surfaces in
Elmhurst Summary PDF lookups), so codes 8-15 only affect the
RdSAP-21-schema API path.

Golden API fixture pins updated to reflect the tightened cascade-vs-API
alignment (7 certs: 0380, 0350, 2225, 2636, 3800, 9285, 9418). SAP
integer residuals unchanged (all sit at +0).

Pyright net-zero on touched files (22 → 22).

Tests: 710 → **711** pass (+1 new: cert 0350 fixture-shape test for
glazing_type=14 routing to g⊥=0.68 with `total_solar_gains_monthly_w[0]
≈ 67.00 W` (vs pre-slice 74.88 W at the DG default), proving code 14
hits the triple-glazed Table 6b row.) 10 expected fails unchanged.

[1]: datatypes/epc/domain/epc_codes.csv (RdSAP-Schema-21.0.1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 12:01:34 +00:00
parent e27b923bca
commit faf116bd70
4 changed files with 83 additions and 14 deletions

View file

@ -258,8 +258,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0380-2471-3250-2596-8761",
actual_sap=89,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-14.6848,
expected_co2_resid_tonnes_per_yr=+0.2780,
expected_pe_resid_kwh_per_m2=-14.5971,
expected_co2_resid_tonnes_per_yr=+0.2785,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow "
"TFA 60.43 age D, PV 3 kWp. Worksheet SAP 88.5104 — slice "
@ -275,8 +275,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0350-2968-2650-2796-5255",
actual_sap=84,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-7.8741,
expected_co2_resid_tonnes_per_yr=+0.1701,
expected_pe_resid_kwh_per_m2=-7.7832,
expected_co2_resid_tonnes_per_yr=+0.1709,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. "
"Worksheet SAP 84.1367 — cascade integer matches lodged."
@ -286,8 +286,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2225-3062-8205-2856-7204",
actual_sap=89,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-11.8557,
expected_co2_resid_tonnes_per_yr=+0.2621,
expected_pe_resid_kwh_per_m2=-11.7684,
expected_co2_resid_tonnes_per_yr=+0.2628,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV. Worksheet SAP 88.7921. Slice 102f-prep.8 closed the "
@ -298,8 +298,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2636-0525-2600-0401-2296",
actual_sap=86,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-9.6692,
expected_co2_resid_tonnes_per_yr=+0.2193,
expected_pe_resid_kwh_per_m2=-9.5780,
expected_co2_resid_tonnes_per_yr=+0.2200,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV + 3.74 m² cantilever exposed floor + 12.76 m² alt wall. "
@ -313,8 +313,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="3800-8515-0922-3398-3563",
actual_sap=86,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-9.6838,
expected_co2_resid_tonnes_per_yr=+0.2603,
expected_pe_resid_kwh_per_m2=-9.6121,
expected_co2_resid_tonnes_per_yr=+0.2609,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. "
"Worksheet SAP 86.1458."
@ -324,8 +324,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="9285-3062-0205-7766-7200",
actual_sap=84,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-8.0466,
expected_co2_resid_tonnes_per_yr=+0.1564,
expected_pe_resid_kwh_per_m2=-7.9597,
expected_co2_resid_tonnes_per_yr=+0.1571,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. "
"Worksheet SAP 84.1369."
@ -335,8 +335,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="9418-3062-8205-3566-7200",
actual_sap=85,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-9.5795,
expected_co2_resid_tonnes_per_yr=+0.2311,
expected_pe_resid_kwh_per_m2=-9.4849,
expected_co2_resid_tonnes_per_yr=+0.2317,
notes=(
"Daikin Altherma EDLQ05CAV3 PCDB 102421 (heating_duration "
"code '24' — continuous, all days at Th). Worksheet SAP "

View file

@ -103,6 +103,14 @@ _LIGHTING_L8C_EFFICACY_LM_PER_W: Final[float] = 21.3
# glazed = 0.90; double-glazed variants = 0.80; triple-glazed = 0.70.
# Mirrors the SAP code mapping in cert_to_inputs._g_perpendicular but
# returns the light column, not solar.
#
# Codes 1-7 follow the legacy SAP 10.2 Table 6b ordering (also matched
# by the RdSAP 17 schema for the modal cohort lodgement values 2/3/6).
# Codes 8-15 are RdSAP-21 schema additions (per
# datatypes/epc/domain/epc_codes.csv) — every API-path cert lodges its
# glazing-type integer via the RdSAP 21 enum, and triple-glazed certs
# in the cohort surface as code 14 (triple 2022+) which previously fell
# through to the 0.80 default and over-bonused the daylight factor.
_G_LIGHT_BY_GLAZING_CODE: Final[dict[int, float]] = {
1: 0.90, # single glazed
2: 0.80, # double glazed (air filled, pre-2002)
@ -111,6 +119,15 @@ _G_LIGHT_BY_GLAZING_CODE: Final[dict[int, float]] = {
5: 0.80, # double glazed (low-E argon)
6: 0.70, # triple glazed
7: 0.80, # secondary glazing
# RdSAP 21 schema extensions
8: 0.70, # triple glazing, known data
9: 0.70, # triple glazing, installed 2002-2022
10: 0.70, # triple glazing, installed pre-2002
11: 0.80, # secondary glazing, normal emissivity
12: 0.80, # secondary glazing, low emissivity
13: 0.80, # double glazing, installed 2022+
14: 0.70, # triple glazing, installed 2022+
15: 0.90, # single glazing, known data
}
_G_LIGHT_DEFAULT: Final[float] = 0.80 # treat unknowns as DG (modal)

View file

@ -175,6 +175,11 @@ def window_solar_gain_w(
# SAP 10.2 Table 6b — total solar energy transmittance g⊥ at normal incidence
# by SAP10 glazing_type code (p178). Default 0.76 (modal double-glazed UK
# stock) when the cert's glazing_type is missing or unrecognised.
#
# Codes 1-6 follow the SAP 10.2 Table 6b ordering. Codes 7-15 are RdSAP-21
# schema additions (per datatypes/epc/domain/epc_codes.csv); triple-glazed
# cohort certs lodge code 14 (triple 2022+) which previously fell through
# to the 0.76 DG default and over-counted solar gains.
_G_PERPENDICULAR_BY_GLAZING_TYPE: Final[dict[int, float]] = {
1: 0.85, # single glazed
2: 0.76, # double glazed (air or argon filled) — 2002-2022
@ -182,6 +187,17 @@ _G_PERPENDICULAR_BY_GLAZING_TYPE: Final[dict[int, float]] = {
4: 0.63, # double glazed low-E soft-coat
5: 0.76, # window with secondary glazing
6: 0.68, # triple glazed (air or argon filled)
# RdSAP 21 schema extensions — triple-glazed variants all share the
# same Table 6b g⊥; secondary glazing mirrors the existing code 5 value.
7: 0.76, # double glazing, known data
8: 0.68, # triple glazing, known data
9: 0.68, # triple glazing, installed 2002-2022
10: 0.68, # triple glazing, installed pre-2002
11: 0.76, # secondary glazing, normal emissivity
12: 0.76, # secondary glazing, low emissivity
13: 0.76, # double glazing, installed 2022+
14: 0.68, # triple glazing, installed 2022+
15: 0.85, # single glazing, known data
}
_G_PERPENDICULAR_DEFAULT: Final[float] = 0.76

View file

@ -99,6 +99,42 @@ def test_solar_gains_from_cert_prefers_manufacturer_g_perpendicular_over_table_6
assert result.total_solar_gains_monthly_w[0] == pytest.approx(74.8788, abs=5e-3)
def test_solar_gains_recognises_rdsap_21_triple_glazed_code_14_as_triple() -> None:
"""RdSAP 21 schema (per datatypes/epc/domain/epc_codes.csv) adds
glazing_type codes 8-15 to the legacy SAP 10.2 Table 6b codes 1-7.
Triple-glazed lodgements surface as code 14 ("triple glazing,
installed 2022+") on every cohort-1 API path triple-glazed cert
(0380/0350/2225/2636/3800/9285/9418). Pre-slice S0380.30 the cascade
fell through to the 0.76 double-glazed default for codes 8-15,
over-counting solar gains and pushing API path SAP residuals to
+0.014..+0.031. Code 14 must route to the same g=0.68 as code 6
("triple glazed") with no `window_transmission_details` override."""
# Arrange — identical geometry as the existing
# `prefers_manufacturer_g_perpendicular_over_table_6b` test but with
# `glazing_type=14` and `window_transmission_details=None` so the
# cascade falls back to the Table 6b lookup.
window = make_window(
orientation=4, width=5.52, height=1.0, glazing_type=14,
)
window.window_transmission_details = None
epc = make_minimal_sap10_epc(
total_floor_area_m2=53.0,
sap_windows=[window],
)
# Act
result = solar_gains_from_cert(
epc=epc, region=0, overshading=OvershadingCategory.AVERAGE,
)
# Assert — code 14 lookup returns g⊥=0.68 (matching code 6 triple).
# Same window at g⊥=0.76 (DG default) would give 74.8788 W Jan;
# at g⊥=0.68 (TG) the gain scales by 0.68/0.76 = 0.8947 →
# 74.8788 × 0.8947 ≈ 67.00 W Jan. Pre-slice the cascade returned
# 74.8788 (DG default); post-slice 67.00.
assert abs(result.total_solar_gains_monthly_w[0] - 67.00) < 0.5
def test_z_solar_for_overshading_returns_table_6d_first_column() -> None:
# Arrange — SAP 10.2 Table 6d "Winter solar access factor (for calculation
# of solar gains for heating)" — first numeric column on p178. Used for