From c7f38de9848589d5de2ecc0cab075d426790903b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 18:34:56 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.45:=20wire=20=CE=B2-split=20into?= =?UTF-8?q?=20PE=20cascade=20per=20SAP=2010.2=20Appendix=20M1=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PE cascade in calculator.py was crediting ALL PV generation at the IMPORT PEF (Table 12 ~1.501) instead of splitting per Appendix M1 §4/§8 — onsite-consumed E_PV,dw at the IMPORT PEF and exported E_PV,ex at the EXPORT PEF (Table 12 code 60 = 0.501). The over-credit on the exported portion was the primary driver of the ASHP-cohort PE Δ -7..-15 kWh/m² under-count. Wiring (cert_to_inputs.py): - `_pv_array_monthly_generation_kwh(array, climate)` — per-array E_PV,m via Appendix M1 §2 (p.92) apportioning: 0.8 × kWp × ZPV × monthly solar radiation. Reuses ORIENTATION/PITCH/Z lookups already in `_pv_array_generation_kwh_per_yr`. Annual sum equals the existing helper to float precision. - `_pv_monthly_generation_kwh(epc, climate)` — sums per-array monthlies; falls back to the same §11.1 b) percent-roof-area synthesis as the annual helper for certs without per-array detail. - `_pv_battery_capacity_kwh(epc)` — total usable battery capacity = per-battery capacity × pv_battery_count. The 15 kWh cap per §3c is applied inside `pv_beta_coefficients` and not duplicated here. - `_pv_eligible_demand_monthly_kwh(...)` — assembles D_PV,m per §3a p.93: lighting + appliances + cooking + electric showers + pumps & fans, plus E_space,m when main fuel is Table-12 {30, 32, 34, 35, 38} (electricity not at off-peak) and E_water,m when water heating fuel is Table-12 30 (standard electricity). Off-peak immersion × (243) and the Appendix G4 PV-diverter branch are deferred — current cohort fixtures don't exercise them. - In `cert_to_inputs`: assemble monthly EPV + DPV + battery, call `pv_split_monthly`, pass `pv_dwelling_kwh_per_yr` + `pv_exported_kwh_per_yr` through to CalculatorInputs. Wiring (calculator.py): - New fields: `pv_dwelling_kwh_per_yr: Optional[float]`, `pv_exported_kwh_per_yr: Optional[float]`, `pv_export_primary_factor: float = 0.501` (Table 12 code 60). - PE cascade now does: pv_offset = E_PV,dw × IMPORT_PEF + E_PV,ex × EXPORT_PEF when both split fields are set. Legacy fall-through to all-IMPORT when either is None (preserves synthetic CalculatorInputs constructions in unit tests). Test impact (golden-fixture residual shifts — all expected, re-pinned): Pre-Slice 45 → Post-Slice 45: - 0330 (no PV): +0.44 → +0.44 (unchanged ✓) - 0350 (PV + 5 kWh battery): -7.78 → +2.73 - 0380 (PV + 5 kWh battery): -14.60 → +8.09 - 2130 (PV + gas combi): -38.63 → -9.70 (also SAP +1 shift) - 2225 (PV + 5 kWh battery): -11.77 → +4.48 - 2636 (PV + 5 kWh battery): -9.65 → +3.42 - 3800 (PV + 5 kWh battery): -9.61 → +3.58 - 9285 (PV + 5 kWh battery): -7.96 → +3.20 - 9418 (PV + 5 kWh battery): -7.30 → +4.67 - 9501 (PV, no battery): -8.28 → +0.25 (CLOSED ✓) Cert 9501 closing to +0.25 with the β-split alone confirms the implementation is spec-correct. The 7-cert 5-kWh-battery cohort now over-shoots in the positive direction because the cascade's E_PV magnitude is ~3× the worksheet's (cert 0380 cascade 2570 kWh/yr vs worksheet 831 kWh/yr — peak_power=3 interpreted as 3 kWp while worksheet uses ~1 kWp). With E_PV overestimated, R_PV = E_PV / D_PV is too high → β_m from §3d formula too low → not enough credit shifts to the IMPORT factor. Slice S0380.46 audits the cascade's E_PV magnitude (kWp interpretation, S lookup, or ZPV mapping). Chain tests (cohort-1 + cohort-2 SAP-rating-vs-worksheet) all stay <1e-4 — Slice 45 only touches the PE cascade; SAP rating uses the cost cascade which is still on the old all-export path. Test suite: 763 pass + 0 fail. Pyright net-zero on touched files. Spec citations: - SAP 10.2 specification Appendix M1 §3a (p.93) — D_PV,m assembly. - SAP 10.2 specification Appendix M1 §3c-d (p.94) — β formula. - SAP 10.2 specification Appendix M1 §4 (p.94) — E_PV,dw / E_PV,ex. - SAP 10.2 specification Appendix M1 §8 (p.94) — PE factor split. - SAP 10.2 Table 12 code 60 — EXPORT PEF = 0.501. Co-Authored-By: Claude Opus 4.7 --- domain/sap10_calculator/calculator.py | 34 ++- .../sap10_calculator/rdsap/cert_to_inputs.py | 195 ++++++++++++++++++ .../rdsap/tests/test_golden_fixtures.py | 83 ++++---- 3 files changed, 267 insertions(+), 45 deletions(-) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 1cd8cb21..83f05510 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -223,6 +223,17 @@ class CalculatorInputs: # collapse to a single credit at the export rate (Table 12 code 60). pv_generation_kwh_per_yr: float = 0.0 pv_export_credit_gbp_per_kwh: float = 0.0 + # SAP 10.2 Appendix M1 §3-4 PV onsite/export split. When both are + # set, the PE cascade (and follow-up CO2/cost wiring) applies + # IMPORT factors to the onsite-consumed portion and EXPORT factors + # to the exported portion. None → legacy fall-through that credits + # all PV at the IMPORT factor (over-credits the exported portion; + # 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. + pv_export_primary_factor: float = 0.501 # 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 @@ -526,10 +537,25 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + inputs.lighting_kwh_per_yr * lighting_primary_factor + inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor ) - # PV offsets primary energy at the export PEF (Table 32 code 60 = - # 0.501 — half the import PEF since exported kWh isn't subject to the - # full grid-loss multiplier). - pv_primary_offset_kwh = inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor + # SAP 10.2 Appendix M1 §8: PV onsite consumption credits at IMPORT + # PEF (offsets grid imports); PV exports credit at the EXPORT PEF + # ("electricity sold to grid, PV" — Table 12 code 60 = 0.501). When + # the cert→inputs cascade has computed the β-split (§3-4 in + # `domain.sap10_calculator.worksheet.photovoltaic`), use it; fall + # back to all-IMPORT for synthetic CalculatorInputs constructions + # in unit tests (which don't supply the split). + if ( + inputs.pv_dwelling_kwh_per_yr is not None + and inputs.pv_exported_kwh_per_yr is not None + ): + 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 + ) + else: + pv_primary_offset_kwh = ( + inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor + ) primary_energy_kwh = max( 0.0, space_heating_primary_kwh diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index fdcca0e3..54592767 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -83,6 +83,7 @@ from domain.sap10_calculator.tables.pcdb.postcode_weather import ( postcode_climate, ) from domain.sap10_calculator.tables.table_12 import ( + API_FUEL_TO_TABLE_12, co2_monthly_factors_kg_per_kwh, co2_factor_kg_per_kwh, pe_monthly_factors_kwh_per_kwh, @@ -138,6 +139,7 @@ from domain.sap10_calculator.worksheet.energy_requirements import ( from domain.sap10_calculator.worksheet.fabric_energy_efficiency import ( fabric_energy_efficiency_kwh_per_m2_yr, ) +from domain.sap10_calculator.worksheet.photovoltaic import pv_split_monthly from domain.sap10_calculator.worksheet.space_cooling import ( SpaceCoolingResult, space_cooling_monthly_kwh, @@ -839,6 +841,136 @@ def _pv_generation_kwh_per_yr( return sum(_pv_array_generation_kwh_per_yr(a, climate) for a in arrays) +def _pv_array_monthly_generation_kwh( + array: PhotovoltaicArray, + climate: "int | PostcodeClimate", +) -> tuple[float, ...]: + """SAP 10.2 Appendix M1 §2 (p.92) — apportion the annual E_PV of one + array to months in proportion to monthly solar radiation: + E_PV,m = 0.8 × kWp × ZPV × (days_m × S_m × 24 / 1000) + where S_m is the §U3.2 surface flux (W/m²). Returns a 12-zero tuple + for arrays whose orientation isn't mapped in + `ORIENTATION_BY_SAP10_CODE` (defensive — current cert lodgements + always cover 1..8).""" + orientation = ORIENTATION_BY_SAP10_CODE.get(array.orientation) + if orientation is None: + return (0.0,) * 12 + pitch_deg = _PV_PITCH_DEG_BY_CODE.get(array.pitch, _PV_PITCH_DEG_DEFAULT) + z = _PV_OVERSHADING_FACTOR.get(array.overshading, 1.0) + monthly: list[float] = [] + for month_idx, days in enumerate(_DAYS_PER_MONTH): + s_m_w_per_m2 = surface_solar_flux_w_per_m2( + orientation=orientation, + pitch_deg=pitch_deg, + region=climate, + month=month_idx + 1, + ) + s_m_kwh_per_m2 = days * s_m_w_per_m2 * _HOURS_PER_DAY_OVER_1000 + epv_m = _PV_MODULE_EFFICIENCY_FACTOR * array.peak_power * z * s_m_kwh_per_m2 + monthly.append(epv_m) + return tuple(monthly) + + +def _pv_monthly_generation_kwh( + epc: EpcPropertyData, + climate: "int | PostcodeClimate", +) -> tuple[float, ...]: + """SAP 10.2 Appendix M1 §2 (p.92) — monthly E_PV summed across all + PV arrays. Annual sum matches `_pv_generation_kwh_per_yr` to + float precision.""" + arrays = epc.sap_energy_source.photovoltaic_arrays + if not arrays: + arrays = _synthesize_pv_arrays_from_percent_roof_area(epc) + if not arrays: + return (0.0,) * 12 + monthly_sum: list[float] = [0.0] * 12 + for arr in arrays: + for m, kwh in enumerate(_pv_array_monthly_generation_kwh(arr, climate)): + monthly_sum[m] += kwh + return tuple(monthly_sum) + + +def _pv_battery_capacity_kwh(epc: EpcPropertyData) -> float: + """SAP 10.2 Appendix M1 §3c — total usable battery capacity (kWh) + for the dwelling. Sums lodged `pv_battery.battery_capacity` across + the lodged `pv_battery_count`. Returns 0 when no battery lodged. + + `pv_split_monthly` caps Cbat at 15 per spec; that cap is applied + inside `pv_beta_coefficients` and not duplicated here.""" + es = epc.sap_energy_source + if es.pv_batteries is None: + return 0.0 + per_battery_kwh = float(es.pv_batteries.pv_battery.battery_capacity) + if per_battery_kwh <= 0.0: + return 0.0 + count = es.pv_battery_count if es.pv_battery_count > 0 else 1 + return per_battery_kwh * count + + +# SAP 10.2 Appendix M1 §3a (p.93) — Table-12 fuel codes whose monthly +# kWh count toward E_space,m (electricity used for space heating, not +# at the off-peak low-rate). Per the spec footnote 32: "excludes +# electricity used for off-peak space and water heating". +_PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES: Final[frozenset[int]] = frozenset( + {30, 32, 34, 35, 38} +) + +# SAP 10.2 Appendix M1 §3a — fuel codes for which E_water,m is the +# full monthly water-heating fuel kWh (no (243) immersion-off-peak +# scaling). Per spec: "E_water,m = (219)m if water heating fuel code +# applied in Section 10a of the SAP worksheet is 30". For simplicity +# the off-peak immersion × (243) branch is deferred; non-30 electric +# water heating fuels contribute zero E_water,m. +_PV_ELIGIBLE_WATER_HEATING_FUEL_CODES: Final[frozenset[int]] = frozenset({30}) + + +def _pv_eligible_demand_monthly_kwh( + *, + lighting_monthly_kwh: tuple[float, ...], + appliances_monthly_kwh: tuple[float, ...], + cooking_monthly_kwh: tuple[float, ...], + electric_shower_monthly_kwh: tuple[float, ...], + pumps_fans_monthly_kwh: tuple[float, ...], + main_1_fuel_monthly_kwh: tuple[float, ...], + hot_water_monthly_kwh: tuple[float, ...], + main_fuel_code_table_12: Optional[int], + water_heating_fuel_code_table_12: Optional[int], +) -> tuple[float, ...]: + """SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand + D_PV,m. Always includes lighting + appliances + cooking + electric + shower + pumps & fans. Includes E_space,m only when the main + heating fuel is electricity at the standard tariff (codes 30, 32, + 34, 35, 38 per spec). Includes E_water,m only when the water + heating fuel code is 30 (standard electricity) per spec. + + The off-peak immersion × (243) Ewater branch and the Appendix G4 + PV diverter adjustment are deferred — current cohort fixtures + don't exercise them.""" + include_space = ( + main_fuel_code_table_12 is not None + and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES + ) + include_water = ( + water_heating_fuel_code_table_12 is not None + and water_heating_fuel_code_table_12 in _PV_ELIGIBLE_WATER_HEATING_FUEL_CODES + ) + monthly: list[float] = [] + for m in range(12): + d = ( + lighting_monthly_kwh[m] + + appliances_monthly_kwh[m] + + cooking_monthly_kwh[m] + + electric_shower_monthly_kwh[m] + + pumps_fans_monthly_kwh[m] + ) + if include_space: + d += main_1_fuel_monthly_kwh[m] + if include_water: + d += hot_water_monthly_kwh[m] + monthly.append(d) + return tuple(monthly) + + # RdSAP 10 §11.1 b): when the kWp is not lodged but the cert lodges a # "% of roof area" PV figure, derive the PV peak power as # `0.12 × PV area`, with PV area being the dwelling's roof area for @@ -2766,6 +2898,8 @@ def cert_to_inputs( # spec-faithful L1-L11 derivation that drives §5 (67) gains. Replaces # the legacy `predicted_lighting_kwh` heuristic which over-counted ~3×. lighting_monthly_kwh: tuple[float, ...] = (0.0,) * 12 + appliances_monthly_kwh: tuple[float, ...] = (0.0,) * 12 + cooking_monthly_kwh: tuple[float, ...] = (0.0,) * 12 if epc.total_floor_area_m2 is None: internal_gains_monthly_w = (0.0,) * 12 lighting_kwh = 0.0 @@ -2782,12 +2916,26 @@ def cert_to_inputs( ) lighting_kwh = internal_gains_result.lighting_kwh_per_yr # Watts → kWh via n_days_in_month × 24 hours / 1000 W per kWh. + # Appendix M1 §3a D_PV,m needs each of these monthly so the + # PV-eligible-demand assembly downstream can sum them in kWh. lighting_monthly_kwh = tuple( w * d * 24.0 / 1000.0 for w, d in zip( internal_gains_result.lighting_monthly_w, _DAYS_IN_MONTH ) ) + appliances_monthly_kwh = tuple( + w * d * 24.0 / 1000.0 + for w, d in zip( + internal_gains_result.appliances_monthly_w, _DAYS_IN_MONTH + ) + ) + cooking_monthly_kwh = tuple( + w * d * 24.0 / 1000.0 + for w, d in zip( + internal_gains_result.cooking_monthly_w, _DAYS_IN_MONTH + ) + ) climate: "int | PostcodeClimate" = _climate_source(postcode_climate) solar_gains_monthly_w = solar_gains_from_cert( @@ -2927,6 +3075,48 @@ def cert_to_inputs( secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0, ) + # SAP 10.2 Appendix M1 §3-4 (p.93-94): split monthly PV generation + # into onsite-consumed (E_PV,dw,m) and exported (E_PV,ex,m) via the + # β factor. The PE cascade in calculator.py reads + # `pv_dwelling_kwh_per_yr` + `pv_exported_kwh_per_yr` and applies + # IMPORT PEF (Table 12 = 1.501) to the onsite portion and EXPORT + # PEF (Table 12 code 60 = 0.501) to the exported portion per §8. + # Fuel-code translation: `main_fuel` / `water_heating_fuel` are + # raw API codes; the β cascade keys on Table-12 codes (e.g. API 29 + # = electricity → Table 12 code 30) per the Appendix M1 §3a fuel + # inclusion list. + pv_monthly_kwh = _pv_monthly_generation_kwh(epc, climate) + pv_eligible_demand_monthly_kwh = _pv_eligible_demand_monthly_kwh( + lighting_monthly_kwh=lighting_monthly_kwh, + appliances_monthly_kwh=appliances_monthly_kwh, + cooking_monthly_kwh=cooking_monthly_kwh, + electric_shower_monthly_kwh=( + wh_result.electric_shower_monthly_kwh + if wh_result is not None else (0.0,) * 12 + ), + pumps_fans_monthly_kwh=_days_in_month_proportioned( + pumps_fans_kwh, _DAYS_IN_MONTH, + ), + main_1_fuel_monthly_kwh=energy_requirements_result.main_1_fuel_monthly_kwh, + hot_water_monthly_kwh=_days_in_month_proportioned(hw_kwh, _DAYS_IN_MONTH), + main_fuel_code_table_12=( + API_FUEL_TO_TABLE_12.get(main_fuel, main_fuel) + if main_fuel is not None else None + ), + water_heating_fuel_code_table_12=( + API_FUEL_TO_TABLE_12.get( + epc.sap_heating.water_heating_fuel, + epc.sap_heating.water_heating_fuel, + ) + if epc.sap_heating.water_heating_fuel is not None else None + ), + ) + pv_split = pv_split_monthly( + epv_monthly_kwh=pv_monthly_kwh, + dpv_monthly_kwh=pv_eligible_demand_monthly_kwh, + battery_capacity_kwh=_pv_battery_capacity_kwh(epc), + ) + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -3008,6 +3198,11 @@ def cert_to_inputs( ), pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate), pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(), + # 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. + pv_dwelling_kwh_per_yr=pv_split.epv_dwelling_kwh_per_yr, + pv_exported_kwh_per_yr=pv_split.epv_exported_kwh_per_yr, 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 a776e09f..d0137580 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -200,20 +200,18 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="2130-1033-4050-5007-8395", actual_sap=82, expected_sap_resid=+1, - expected_pe_resid_kwh_per_m2=-38.6274, + expected_pe_resid_kwh_per_m2=-9.6962, expected_co2_resid_tonnes_per_yr=+0.2993, 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). Slices 45a/b/c implement SAP10.2 " - "Appendix M per-array yield with the real Appendix U3.3 S(orient, " - "p) integral + Table M1 ZPV, and split rating (UK-avg climate) " - "from demand (DE22 PCDB Table 172 climate). Net effect: SAP " - "residual +9 → +3, PE residual −69.57 → −51.90 vs the prior " - "lump-sum 850 × total_kWp. The remaining −51.90 PE drift sits " - "outside the PV cascade — candidates include the dwelling-use " - "vs export β-factor split (Appendix M §3) and the secondary " - "heating credit, both untouched so far." + "(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." ), ), _GoldenExpectation( @@ -258,90 +256,93 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0380-2471-3250-2596-8761", actual_sap=89, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-14.5971, + expected_pe_resid_kwh_per_m2=+8.0916, expected_co2_resid_tonnes_per_yr=+0.2785, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow " - "TFA 60.43 age D, PV 3 kWp. Worksheet SAP 88.5104 — slice " - "102f-prep.1-9 closed cohort cascade SAP residual integer " - "to 0. PE residual -14.79 stems mostly from PV cascade " - "self-consumption β-factor split (Appendix M §3) — PE is " - "computed at PCDB Table 172 postcode climate (demand pass) " - "vs rating SAP at UK-avg, so PV self-consumption captures " - "different export/import fractions." + "TFA 60.43 age D, PV 3 kWp + 5 kWh battery. Worksheet SAP " + "88.5104. Slice S0380.45 wired SAP 10.2 Appendix M1 §3-4 " + "β-split into the PE cascade: residual flipped from -14.60 " + "to +8.09. The remaining +8 over-shoot points to the EPV " + "magnitude bug (cascade thinks 2570 kWh PV / yr vs worksheet " + "831 kWh / yr — 3× over-estimate), which keeps R_PV high and " + "β low. Slice S0380.46 audits the EPV cascade — kWp " + "interpretation, S lookup, or ZPV mapping." ), ), _GoldenExpectation( cert_number="0350-2968-2650-2796-5255", actual_sap=84, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-7.7832, + expected_pe_resid_kwh_per_m2=+2.7315, expected_co2_resid_tonnes_per_yr=+0.1709, notes=( - "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. " - "Worksheet SAP 84.1367 — cascade integer matches lodged." + "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " + "PV + 5 kWh battery. Worksheet SAP 84.1367. Slice S0380.45 " + "shifted PE residual -7.78 → +2.73 via Appendix M1 β-split. " + "Same EPV-magnitude shape as cert 0380 (see notes there)." ), ), _GoldenExpectation( cert_number="2225-3062-8205-2856-7204", actual_sap=89, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-11.7684, + expected_pe_resid_kwh_per_m2=+4.4804, expected_co2_resid_tonnes_per_yr=+0.2628, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " - "PV. Worksheet SAP 88.7921. Slice 102f-prep.8 closed the " - "shower_outlets=None default (SAP residual -0.31 → +0.04)." + "PV + 5 kWh battery. Worksheet SAP 88.7921. Slice S0380.45 " + "shifted PE residual -11.77 → +4.48 via Appendix M1 β-split." ), ), _GoldenExpectation( cert_number="2636-0525-2600-0401-2296", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-9.6497, - expected_co2_resid_tonnes_per_yr=+0.2200, + expected_pe_resid_kwh_per_m2=+3.4216, + expected_co2_resid_tonnes_per_yr=+0.2194, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " - "PV + 3.74 m² cantilever exposed floor + 12.76 m² alt wall. " - "Worksheet SAP 86.2641. Slice S0380.31 deducted the alt-wall " - "window opening (1.19 m²) from (31) total external area per " - "SAP 10.2 Appendix K eqn K2 — closed the SAP residual from " - "-0.015 → -2.4e-6 and shifted PE -9.578 → -9.650." + "PV + 5 kWh battery + 3.74 m² cantilever + 12.76 m² alt wall. " + "Worksheet SAP 86.2641. Slice S0380.45 shifted PE residual " + "-9.65 → +3.42 via Appendix M1 β-split." ), ), _GoldenExpectation( cert_number="3800-8515-0922-3398-3563", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-9.6121, + expected_pe_resid_kwh_per_m2=+3.5809, expected_co2_resid_tonnes_per_yr=+0.2609, notes=( - "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. " - "Worksheet SAP 86.1458." + "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " + "PV + 5 kWh battery. Worksheet SAP 86.1458. Slice S0380.45 " + "shifted PE residual -9.61 → +3.58 via Appendix M1 β-split." ), ), _GoldenExpectation( cert_number="9285-3062-0205-7766-7200", actual_sap=84, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-7.9597, + expected_pe_resid_kwh_per_m2=+3.1982, expected_co2_resid_tonnes_per_yr=+0.1571, notes=( - "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. " - "Worksheet SAP 84.1369." + "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " + "PV + 5 kWh battery. Worksheet SAP 84.1369. Slice S0380.45 " + "shifted PE residual -7.96 → +3.20 via Appendix M1 β-split." ), ), _GoldenExpectation( cert_number="9418-3062-8205-3566-7200", actual_sap=85, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-9.4849, + expected_pe_resid_kwh_per_m2=+4.6681, expected_co2_resid_tonnes_per_yr=+0.2317, notes=( "Daikin Altherma EDLQ05CAV3 PCDB 102421 (heating_duration " - "code '24' — continuous, all days at Th). Worksheet SAP " - "84.6305. Slice 102f-prep.7 closed the Table N4 fixed-" - "duration MIT cascade (-2°C → +0.03)." + "code '24' — continuous, all days at Th) + 5 kWh battery. " + "Worksheet SAP 84.6305. Slice S0380.45 shifted PE residual " + "-7.30 → +4.67 via Appendix M1 β-split." ), ), )