diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 2a05962b..90705e70 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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 " diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index 90071b1c..084e1797 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -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) diff --git a/domain/sap10_calculator/worksheet/solar_gains.py b/domain/sap10_calculator/worksheet/solar_gains.py index 8e94e9c5..d5f747c4 100644 --- a/domain/sap10_calculator/worksheet/solar_gains.py +++ b/domain/sap10_calculator/worksheet/solar_gains.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/tests/test_solar_gains.py b/domain/sap10_calculator/worksheet/tests/test_solar_gains.py index 2298f554..0374ce2c 100644 --- a/domain/sap10_calculator/worksheet/tests/test_solar_gains.py +++ b/domain/sap10_calculator/worksheet/tests/test_solar_gains.py @@ -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