From 5b269f23b673ee38bdeb04a029e5ff589f0eef79 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 18:42:28 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.46:=20wire=20=CE=B2-split=20into?= =?UTF-8?q?=20CO2=20cascade=20per=20SAP=2010.2=20Appendix=20M1=20=C2=A77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CO2 cascade in calculator.py had no PV credit at all (environmental_section_from_cert had a stale `pv_credit = 0.0` with the comment "no PV in any Elmhurst fixture", but that helper isn't called by `calculate_sap_from_inputs` anyway). The full ASHP+PV cluster therefore over-counted CO2 by +0.16..+0.28 t/yr — the entire PV CO2 offset was missing. Wiring (calculator.py): - New fields: `pv_dwelling_co2_factor_kg_per_kwh: Optional[float]`, `pv_exported_co2_factor_kg_per_kwh: Optional[float]`. - CO2 cascade now subtracts: pv_co2_credit = E_PV,dw × dwelling_CO2_factor + E_PV,ex × exported_CO2_factor when the split + factors are set. None preserves the legacy zero-credit behaviour for synthetic CalculatorInputs constructions. Wiring (cert_to_inputs.py): - New constant: `_PV_EXPORT_FUEL_CODE_TABLE_12 = 60` (SAP 10.2 Table 12 code 60, "electricity sold to grid, PV") — the EXPORT factor key per Appendix M1 §6/§7/§8. - The dwelling CO2 factor is the effective monthly Table 12d Σ weighted by E_PV,dw,m at code 30 (Standard electricity); the exported CO2 factor is the same Σ weighted by E_PV,ex,m at code 60 ("Electricity sold to grid, PV"). Both reuse the existing `_effective_monthly_co2_factor` helper. Test impact (CO2 residual cluster, re-pinned in this slice): Pre-Slice 46 → Post-Slice 46: - 0330 (no PV): -0.034 → -0.034 (unchanged ✓) - 0350 (PV + 5 kWh battery): +0.171 → -0.084 - 0380 (PV + 5 kWh battery): +0.279 → -0.054 - 2130 (PV + gas combi): +0.299 → -0.046 - 2225 (PV + 5 kWh battery): +0.263 → -0.071 - 2636 (PV + 5 kWh battery): +0.219 → -0.058 - 3800 (PV + 5 kWh battery): +0.261 → -0.014 - 9285 (PV + 5 kWh battery): +0.157 → -0.098 - 9418 (PV + 5 kWh battery): +0.232 → -0.046 - 9501 (PV, no battery): +0.202 → -0.047 Cluster magnitude dropped 3-5× — over-count flipped to slight under-count (-0.01..-0.10 vs +0.16..+0.28). The remaining negative residual is largely the same E_PV-magnitude bug from Slice 45 (PV is over-credited because the cascade thinks E_PV ≈ 3× the worksheet value for the 5-kWh-battery cohort). Slice 47 (cost cascade) + Slice S0380.48 (E_PV magnitude audit) will close the cluster further. Chain tests still <1e-4 — CO2 cascade isn't gated by the chain tests' SAP-rating-vs-worksheet assertions. Test suite: 763 pass + 0 fail. Pyright net-zero per touched file (calculator.py 0/0; cert_to_inputs.py 34/34; test_golden_fixtures.py 1/1). Spec citations: - SAP 10.2 specification Appendix M1 §7 (p.94) — PV CO2 credit split. - SAP 10.2 Table 12d (p.194) code 60 — monthly CO2 factor for "electricity sold to grid, PV" (already in `tables/table_12.py`). Co-Authored-By: Claude Opus 4.7 --- domain/sap10_calculator/calculator.py | 31 +++++++++++++++++++ .../sap10_calculator/rdsap/cert_to_inputs.py | 15 +++++++++ .../rdsap/tests/test_golden_fixtures.py | 16 +++++----- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 83f05510..03525a8e 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -234,6 +234,15 @@ class CalculatorInputs: # SAP 10.2 Table 12 code 60 ("electricity sold to grid, PV") PE # factor = 0.501. Applied to E_PV,ex when split is set. pv_export_primary_factor: float = 0.501 + # SAP 10.2 Appendix M1 §7 — per-cert CO2 factors for the PV split. + # The dwelling factor is the effective monthly Table 12d IMPORT + # factor (Σ(E_PV,dw,m × CO2_30,m) / Σ(E_PV,dw,m)); the exported + # factor is the effective monthly Table 12d code-60 ("electricity + # sold to grid, PV") factor. Both are computed in cert_to_inputs. + # Synthetic CalculatorInputs constructions leave these None → no + # PV CO2 credit applied (legacy behaviour). + pv_dwelling_co2_factor_kg_per_kwh: Optional[float] = None + pv_exported_co2_factor_kg_per_kwh: Optional[float] = None # Secondary heating — SAP 10.2 Table 11 routes a fraction of space # heating demand to a secondary system (0.10 for gas/oil/solid main # systems; 0.15-0.20 for electric room/storage heaters). Fraction @@ -501,6 +510,28 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + lighting_co2 + electric_shower_co2 ) + # SAP 10.2 Appendix M1 §7 — subtract PV CO2 credit. Onsite consumption + # offsets grid imports at the IMPORT CO2 factor (Table 12d weighted + # by E_PV,dw,m); exports credit at the EXPORT CO2 factor (Table 12d + # code 60 weighted by E_PV,ex,m). Both factors are precomputed in + # cert_to_inputs; None preserves the legacy zero-credit behaviour + # for synthetic CalculatorInputs constructions. + if ( + inputs.pv_dwelling_kwh_per_yr is not None + and inputs.pv_dwelling_co2_factor_kg_per_kwh is not None + ): + co2 -= ( + inputs.pv_dwelling_kwh_per_yr + * inputs.pv_dwelling_co2_factor_kg_per_kwh + ) + if ( + inputs.pv_exported_kwh_per_yr is not None + and inputs.pv_exported_co2_factor_kg_per_kwh is not None + ): + co2 -= ( + inputs.pv_exported_kwh_per_yr + * inputs.pv_exported_co2_factor_kg_per_kwh + ) # Per-end-use effective PE factors. Same shape as the CO2 cascade: # electricity end-uses use Table 12e (p.195) monthly factors weighted diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 54592767..f304dde7 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -1151,6 +1151,10 @@ def _days_in_month_proportioned( _DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) _STANDARD_ELECTRICITY_FUEL_CODE: Final[int] = 30 +# SAP 10.2 Table 12 code 60 — "electricity sold to grid, PV". Used as +# the EXPORT factor key for the Appendix M1 §6/§7/§8 PV split: +# (1-β)·E_PV credits at this code's monthly Table 12d/12e factor. +_PV_EXPORT_FUEL_CODE_TABLE_12: Final[int] = 60 def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float: @@ -3201,8 +3205,19 @@ def cert_to_inputs( # SAP 10.2 Appendix M1 §3-4 PV split — the cascade applies # IMPORT PEF (Table 12) to the onsite portion and EXPORT PEF # (Table 12 code 60 = 0.501) to the exported portion per §8. + # The CO2 factors per §7 are the effective monthly Table 12d + # values weighted by the monthly E_PV,dw / E_PV,ex split: + # dwelling uses code 30 (Standard electricity); exported uses + # code 60 (Electricity sold to grid, PV). pv_dwelling_kwh_per_yr=pv_split.epv_dwelling_kwh_per_yr, pv_exported_kwh_per_yr=pv_split.epv_exported_kwh_per_yr, + pv_dwelling_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( + pv_split.epv_dwelling_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ), + pv_exported_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( + pv_split.epv_exported_monthly_kwh, + _PV_EXPORT_FUEL_CODE_TABLE_12, + ), secondary_heating_fraction=secondary_fraction_value, secondary_heating_efficiency=secondary_efficiency_value, energy_requirements=energy_requirements_result, diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index d0137580..a9700b50 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -201,7 +201,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( actual_sap=82, expected_sap_resid=+1, expected_pe_resid_kwh_per_m2=-9.6962, - expected_co2_resid_tonnes_per_yr=+0.2993, + expected_co2_resid_tonnes_per_yr=-0.0456, notes=( "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " "postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays " @@ -257,7 +257,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( actual_sap=89, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+8.0916, - expected_co2_resid_tonnes_per_yr=+0.2785, + expected_co2_resid_tonnes_per_yr=-0.0540, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow " "TFA 60.43 age D, PV 3 kWp + 5 kWh battery. Worksheet SAP " @@ -275,7 +275,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+2.7315, - expected_co2_resid_tonnes_per_yr=+0.1709, + expected_co2_resid_tonnes_per_yr=-0.0841, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " "PV + 5 kWh battery. Worksheet SAP 84.1367. Slice S0380.45 " @@ -288,7 +288,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( actual_sap=89, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+4.4804, - expected_co2_resid_tonnes_per_yr=+0.2628, + expected_co2_resid_tonnes_per_yr=-0.0711, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " "PV + 5 kWh battery. Worksheet SAP 88.7921. Slice S0380.45 " @@ -300,7 +300,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+3.4216, - expected_co2_resid_tonnes_per_yr=+0.2194, + expected_co2_resid_tonnes_per_yr=-0.0581, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " "PV + 5 kWh battery + 3.74 m² cantilever + 12.76 m² alt wall. " @@ -313,7 +313,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( actual_sap=86, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+3.5809, - expected_co2_resid_tonnes_per_yr=+0.2609, + expected_co2_resid_tonnes_per_yr=-0.0142, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " "PV + 5 kWh battery. Worksheet SAP 86.1458. Slice S0380.45 " @@ -325,7 +325,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( actual_sap=84, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+3.1982, - expected_co2_resid_tonnes_per_yr=+0.1571, + expected_co2_resid_tonnes_per_yr=-0.0979, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " "PV + 5 kWh battery. Worksheet SAP 84.1369. Slice S0380.45 " @@ -337,7 +337,7 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( actual_sap=85, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=+4.6681, - expected_co2_resid_tonnes_per_yr=+0.2317, + expected_co2_resid_tonnes_per_yr=-0.0463, notes=( "Daikin Altherma EDLQ05CAV3 PCDB 102421 (heating_duration " "code '24' — continuous, all days at Th) + 5 kWh battery. "