Slice S0380.46: wire β-split into CO2 cascade per SAP 10.2 Appendix M1 §7

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 18:42:28 +00:00
parent 49de18e83a
commit 5b269f23b6
3 changed files with 54 additions and 8 deletions

View file

@ -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

View file

@ -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,

View file

@ -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. "