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." ), ), )