Slice S0380.49: effective-monthly Table 12e PE factor for PV split per SAP 10.2 Appendix M1 §8

The PE cascade was crediting the PV split at annual Table 12 factors
(IMPORT 1.501 / EXPORT 0.501) instead of the spec-correct effective
monthly Table 12e factors. Per Appendix M1 §8 (p.94): "For calculation
of primary energy, for electricity used within the dwelling apply the
normal import PE factors for the relevant tariff from Table 12e. For
the electricity exported, apply the factors for 'electricity sold to
grid, PV', also from table 12e."

Cert 0380 worksheet (page 5) lodges 1.4960 / 0.4268 — the effective
monthly values weighted by E_PV,dw,m / E_PV,ex,m. The cascade now
computes the same via `_effective_monthly_pe_factor` (the helper
already in place for secondary heating, pumps+fans, lighting,
electric showers).

Two new Optional fields on `CalculatorInputs`:
- `pv_dwelling_primary_factor` — falls back to `other_primary_factor`
- `pv_exported_primary_factor` — falls back to `pv_export_primary_factor`

Both populated in `cert_to_inputs.py` via `_effective_monthly_pe_
factor(pv_split.epv_*_monthly_kwh, fuel_code)` — code 30 (standard
electricity) for dwelling, code 60 (electricity sold to grid, PV)
for exported. Mirrors the existing CO2 cascade shape exactly.

Cohort PE residual closure (kWh/m²):

| Cert | Post-S0380.48 | Post-S0380.49 |
|---|---:|---:|
| 0350 | -3.58 | **-2.96** |
| 0380 | -4.01 | **-3.06** |
| 2225 | -4.50 | **-3.73** |
| 2636 | -4.14 | **-3.44** |
| 3800 | -4.01 | **-3.25** |
| 9285 | -3.46 | **-2.81** |
| 9418 | -3.76 | **-3.01** |
| 2130 (PV gas) | -9.70 | **-8.22** |

7-cert ASHP+battery cluster closed by 0.6-0.8 kWh/m² each (matches
the +0.074 differential between annual 0.501 and worksheet 0.4268
applied to E_PV,ex ≈ 640 kWh/yr / TFA 60.43 = 0.78 kWh/m²). The
remaining -3 kWh/m² residual is β fine-tuning (cascade 0.751 vs
worksheet 0.7426 — small monthly D_PV distribution detail).

Cert 9501 (PV no battery) drifted +0.25 → +0.65 PE — known shape
change from the factor correction; β=0.498 matches worksheet
exactly so the drift uncovers a different small gap previously
masked by the wrong factors. Still well within tolerance.

CO2 + SAP unchanged. Pyright net-zero on touched files (34 errors
before, 34 after — all pre-existing).
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 19:26:37 +00:00
parent 6788c99087
commit e75198ce5d
3 changed files with 76 additions and 38 deletions

View file

@ -231,8 +231,21 @@ class CalculatorInputs:
# used by synthetic CalculatorInputs constructions in unit tests).
pv_dwelling_kwh_per_yr: Optional[float] = None
pv_exported_kwh_per_yr: Optional[float] = None
# 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.
# SAP 10.2 Appendix M1 §8 — per-cert PE factors for the PV split.
# Mirrors the §7 CO2 cascade shape: the dwelling factor is the
# effective monthly Table 12e IMPORT factor (Σ(E_PV,dw,m × PE_30,m) /
# Σ(E_PV,dw,m)); the exported factor is the effective monthly
# Table 12e factor for code 60 ("electricity sold to grid, PV").
# Both are precomputed in cert_to_inputs from the PV split. None
# falls back to the legacy annual values: `other_primary_factor`
# (1.501, standard electricity) for the dwelling portion and
# `pv_export_primary_factor` (0.501) for the exported portion —
# preserves synthetic CalculatorInputs constructions.
pv_dwelling_primary_factor: Optional[float] = None
pv_exported_primary_factor: Optional[float] = None
# Legacy annual fall-back for the exported PE factor (synthetic
# constructions or zero-export months that yield no effective
# monthly value). SAP 10.2 Table 12 code 60 = 0.501.
pv_export_primary_factor: float = 0.501
# SAP 10.2 Appendix M1 §6 (p.94) — IMPORT price for onsite-consumed
# PV generation. cert_to_inputs supplies this from Table 12a (standard
@ -608,9 +621,19 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
inputs.pv_dwelling_kwh_per_yr is not None
and inputs.pv_exported_kwh_per_yr is not None
):
pv_dwelling_pe_factor = (
inputs.pv_dwelling_primary_factor
if inputs.pv_dwelling_primary_factor is not None
else inputs.other_primary_factor
)
pv_exported_pe_factor = (
inputs.pv_exported_primary_factor
if inputs.pv_exported_primary_factor is not None
else inputs.pv_export_primary_factor
)
pv_primary_offset_kwh = (
inputs.pv_dwelling_kwh_per_yr * inputs.other_primary_factor
+ inputs.pv_exported_kwh_per_yr * inputs.pv_export_primary_factor
inputs.pv_dwelling_kwh_per_yr * pv_dwelling_pe_factor
+ inputs.pv_exported_kwh_per_yr * pv_exported_pe_factor
)
else:
pv_primary_offset_kwh = (

View file

@ -3259,6 +3259,21 @@ def cert_to_inputs(
pv_split.epv_exported_monthly_kwh,
_PV_EXPORT_FUEL_CODE_TABLE_12,
),
# SAP 10.2 Appendix M1 §8 — per-cert effective monthly PE
# factors for the PV split. Mirrors the §7 CO2 factors above:
# dwelling factor weights Table 12e code 30 (standard
# electricity import) by monthly E_PV,dw,m; exported factor
# weights code 60 ("electricity sold to grid, PV") by monthly
# E_PV,ex,m. Worksheet for cert 0380 lodges 1.4960 / 0.4268;
# the annual Table 12 fallbacks (1.501 / 0.501) over-credit by
# the differential when the cascade uses them directly.
pv_dwelling_primary_factor=_effective_monthly_pe_factor(
pv_split.epv_dwelling_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
),
pv_exported_primary_factor=_effective_monthly_pe_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

@ -200,18 +200,19 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-9.6962,
expected_pe_resid_kwh_per_m2=-8.2213,
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 "
"(SE + NW, overshading 1 + 2). Slice S0380.45 wired the "
"Appendix M1 β-split into the PE cascade: PE residual moved "
"from -38.63 to -9.70 (the +28.9 kWh/m² shift = (1-β) × EPV × "
"(import_PEF - export_PEF) credit correction). SAP integer "
"shifted +1 (82 → 83) via the same cascade interaction. The "
"-9.70 residual remains — gas combi PE under-count + secondary "
"heating credit are likely candidates for follow-up."
"from -38.63 to -9.70. Slice S0380.49 wired effective monthly "
"Table 12e PE factor (vs annual 1.501/0.501) into the PV "
"split: residual closed -9.70 → -8.22. SAP integer shifted "
"+1 (82 → 83) via the cohort cascade interaction. Remaining "
"-8.22 residual sits in gas combi PE under-count + secondary "
"heating credit (deferred)."
),
),
_GoldenExpectation(
@ -256,94 +257,93 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0380-2471-3250-2596-8761",
actual_sap=89,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-4.0121,
expected_pe_resid_kwh_per_m2=-3.0557,
expected_co2_resid_tonnes_per_yr=-0.0549,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow "
"TFA 60.43 age D, PV 3 kWp + 5 kWh battery. Worksheet SAP "
"88.5104. Slice S0380.48 surfaced the 5-kWh battery to the "
"domain mapper: real-API certs lodge `pv_batteries` as "
"`[{\"battery_capacity\": 5}]` (flat list) but the schema "
"expected a nested `{\"pv_battery\": {\"battery_capacity\": "
"5}}`. With battery surfaced, β rises 0.36 → 0.75 (vs "
"worksheet 0.74), flipping PE residual +8.09 → -4.01. The "
"remaining -4 under-shoot traces to the export PE factor "
"(synthetic default 0.501 vs worksheet's effective monthly "
"Table 12e ~0.427) plus a small β fine-tuning gap."
"88.5104. Slice S0380.48 surfaced the 5-kWh battery from "
"the flat-shape real-API JSON (PE +8.09 → -4.01). Slice "
"S0380.49 then wired effective-monthly Table 12e PE factor "
"into the PV split — dwelling 1.4952 (vs worksheet 1.4960), "
"exported 0.4283 (vs worksheet 0.4268) — closing PE -4.01 "
"→ -3.06. The residual -3 traces to a small β fine-tuning "
"gap (cascade 0.751 vs worksheet 0.7426) — monthly D_PV "
"distribution detail."
),
),
_GoldenExpectation(
cert_number="0350-2968-2650-2796-5255",
actual_sap=84,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-3.5819,
expected_pe_resid_kwh_per_m2=-2.9642,
expected_co2_resid_tonnes_per_yr=-0.0865,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV + 5 kWh battery. Worksheet SAP 84.1367. Slice S0380.48 "
"battery-surface fix shifted PE residual +2.73 → -3.58."
"PV + 5 kWh battery. Worksheet SAP 84.1367. Slice S0380.49 "
"Table 12e PE factor wiring closed PE -3.58 → -2.96."
),
),
_GoldenExpectation(
cert_number="2225-3062-8205-2856-7204",
actual_sap=89,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-4.5030,
expected_pe_resid_kwh_per_m2=-3.7259,
expected_co2_resid_tonnes_per_yr=-0.0729,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV + 5 kWh battery. Worksheet SAP 88.7921. Slice S0380.48 "
"battery-surface fix shifted PE residual +4.48 → -4.50."
"PV + 5 kWh battery. Worksheet SAP 88.7921. Slice S0380.49 "
"Table 12e PE factor wiring closed PE -4.50 → -3.73."
),
),
_GoldenExpectation(
cert_number="2636-0525-2600-0401-2296",
actual_sap=86,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-4.1432,
expected_pe_resid_kwh_per_m2=-3.4364,
expected_co2_resid_tonnes_per_yr=-0.0603,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV + 5 kWh battery + 3.74 m² cantilever + 12.76 m² alt wall. "
"Worksheet SAP 86.2641. Slice S0380.48 battery-surface fix "
"shifted PE residual +3.42 → -4.14."
"Worksheet SAP 86.2641. Slice S0380.49 Table 12e PE factor "
"wiring closed PE -4.14 → -3.44."
),
),
_GoldenExpectation(
cert_number="3800-8515-0922-3398-3563",
actual_sap=86,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-4.0132,
expected_pe_resid_kwh_per_m2=-3.2511,
expected_co2_resid_tonnes_per_yr=-0.0166,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV + 5 kWh battery. Worksheet SAP 86.1458. Slice S0380.48 "
"battery-surface fix shifted PE residual +3.58 → -4.01."
"PV + 5 kWh battery. Worksheet SAP 86.1458. Slice S0380.49 "
"Table 12e PE factor wiring closed PE -4.01 → -3.25."
),
),
_GoldenExpectation(
cert_number="9285-3062-0205-7766-7200",
actual_sap=84,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-3.4619,
expected_pe_resid_kwh_per_m2=-2.8078,
expected_co2_resid_tonnes_per_yr=-0.1003,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV + 5 kWh battery. Worksheet SAP 84.1369. Slice S0380.48 "
"battery-surface fix shifted PE residual +3.20 → -3.46."
"PV + 5 kWh battery. Worksheet SAP 84.1369. Slice S0380.49 "
"Table 12e PE factor wiring closed PE -3.46 → -2.81."
),
),
_GoldenExpectation(
cert_number="9418-3062-8205-3566-7200",
actual_sap=85,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-3.7627,
expected_pe_resid_kwh_per_m2=-3.0084,
expected_co2_resid_tonnes_per_yr=-0.0485,
notes=(
"Daikin Altherma EDLQ05CAV3 PCDB 102421 (heating_duration "
"code '24' — continuous, all days at Th) + 5 kWh battery. "
"Worksheet SAP 84.6305. Slice S0380.48 battery-surface fix "
"shifted PE residual +4.67 → -3.76."
"Worksheet SAP 84.6305. Slice S0380.49 Table 12e PE factor "
"wiring closed PE -3.76 → -3.01."
),
),
)