From 10d1decdf7c461659ef177b6ca2434e58c7e9f35 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 19:26:37 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.49:=20effective-monthly=20Table?= =?UTF-8?q?=2012e=20PE=20factor=20for=20PV=20split=20per=20SAP=2010.2=20Ap?= =?UTF-8?q?pendix=20M1=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- domain/sap10_calculator/calculator.py | 31 +++++++-- .../sap10_calculator/rdsap/cert_to_inputs.py | 15 ++++ .../rdsap/tests/test_golden_fixtures.py | 68 +++++++++---------- 3 files changed, 76 insertions(+), 38 deletions(-) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index d19f4359..45dbb921 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -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 = ( diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index eff7e95d..ae222516 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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, diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index de06cd7e..cf39ac30 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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." ), ), )