# Handover — PV β-factor split (6/6 COMPLETE) Branch `feature/per-cert-mapper-validation`. This phase shipped **6 slices** (S0380.44 → S0380.49) that implemented and wired the full SAP 10.2 Appendix M1 β-factor across PE, CO2, and Cost cascades, surfaced the real-API battery capacity, and wired effective-monthly Table 12e PE factors for the PV split. **HEAD at handover end:** `e75198ce` (Slice S0380.49). **Test suite:** 763 pass + 0 fail. ## Slices shipped this phase | Slice | Commit | What | Spec | |---|---|---|---| | **S0380.44** | `5344bc89` | New module `worksheet/photovoltaic.py` with `pv_split_monthly`, `pv_beta_coefficients`, `PhotovoltaicSplit` + 13 unit tests | Appendix M1 §3c-d (p.94), §4 (p.94) | | **S0380.45** | `49de18e8` | Wired β-split into PE cascade | Appendix M1 §3a (p.93), §8 (p.94) | | **S0380.46** | `5b269f23` | Wired β-split into CO2 cascade | Appendix M1 §7 (p.94), Table 12d code 60 | | **S0380.47** | `42ed38f7` | Wired β-split into cost cascade (zero cohort impact — Table 32 collapses code 30 = code 60 = 13.19 p/kWh) | Appendix M1 §6 (p.94), Table 32 code 30/60 | | **S0380.48** | `bf99b1c7` | E_PV "magnitude bug" audit revealed the real bug: schema gap on `pv_batteries[].battery_capacity` flat shape. Schema fix + mapper fall-back surfaced the 5-kWh batteries. Cohort PE +2.7..+8.1 → -3.5..-4.5 | Appendix M1 §3c (p.94) | | **S0380.49** | `e75198ce` | Wired effective-monthly Table 12e PE factors (`pv_dwelling_primary_factor` + `pv_exported_primary_factor`) for the PV split. Cluster closed -3.5..-4.5 → -2.8..-3.7 | Appendix M1 §8 (p.94), Table 12e code 30/60 | ## Residual progress — full PE cohort trajectory | Cert | Pre-S0380.44 | Post-S0380.45 | Post-S0380.48 | Post-S0380.49 | |---|---:|---:|---:|---:| | 0330 (no PV) | +0.44 | +0.44 | +0.44 | +0.44 | | 0350 (PV+5kWh) | −7.78 | +2.73 | −3.58 | **−2.96** | | 0380 (PV+5kWh) | −14.60 | +8.09 | −4.01 | **−3.06** | | 2130 (PV gas) | −38.63 | −9.70 | −9.70 | **−8.22** | | 2225 (PV+5kWh) | −11.77 | +4.48 | −4.50 | **−3.73** | | 2636 (PV+5kWh) | −9.65 | +3.42 | −4.14 | **−3.44** | | 3800 (PV+5kWh) | −9.61 | +3.58 | −4.01 | **−3.25** | | 9285 (PV+5kWh) | −7.96 | +3.20 | −3.46 | **−2.81** | | 9418 (PV+5kWh) | −7.30 | +4.67 | −3.76 | **−3.01** | | **9501 (PV no battery)** | **−8.28** | **+0.25** | **+0.25** | **+0.65** | CO2 residuals all <0.11 t/yr; SAP scores all exact (except 2130 at +1 — pre-existing, a gas-combi/secondary-heating gap unrelated to PV). 9501 drifted slightly because its β=0.498 already matched worksheet exactly, so the factor correction surfaced a small previously-hidden gap. Cluster shows clean closure trajectory. ## Remaining work (open front) The 7-cert ASHP+battery cluster now sits at -2.8..-3.7 kWh/m² PE. The differential breakdown: 1. **β fine-tuning** (~1-2 kWh/m²): cascade β = 0.751-0.812 vs worksheet β = 0.7426 for cert 0380. This is a monthly D_PV distribution detail. The `_pv_eligible_demand_monthly_kwh` helper sums lighting/appliances/cooking/electric-shower/pumps-fans/main-1 /hot-water tuples; their relative monthly weighting affects β. 2. **Heat pump electricity demand** (~1 kWh/m²): the ASHP cohort has main-heating-fuel = electricity. The cascade's D_PV inclusion list may not perfectly match the worksheet's footnote 32 restrictions ("excludes electricity used for off-peak space and water heating"). Verify against worksheet line refs for cert 0380. 3. **Possible small E_PV or pv_split monthly distribution gaps** (~0.5 kWh/m²): per-month E_PV in the cascade vs worksheet may differ marginally if `_pv_array_monthly_generation_kwh` uses slightly different solar-flux interpolation than the worksheet. Each of these is a candidate for a follow-up slice but not part of the β-split phase scope. ## Architecture: cross-cascade β-split shape (final) All three cascades use the **uniform shape**: | Cascade | Dwelling factor (IMPORT) | Exported factor (EXPORT) | |---|---|---| | **PE** | `pv_dwelling_primary_factor` → fall back to `other_primary_factor` (1.501) | `pv_exported_primary_factor` → fall back to `pv_export_primary_factor` (0.501) | | **CO2** | `pv_dwelling_co2_factor_kg_per_kwh` → fall back to no credit | `pv_exported_co2_factor_kg_per_kwh` → fall back to no credit | | **Cost** | `pv_dwelling_import_price_gbp_per_kwh` → Table 32 code 30 (13.19 p/kWh) | `pv_export_credit_gbp_per_kwh` → Table 32 code 60 (13.19 p/kWh) | Shared cross-cascade state: - `pv_dwelling_kwh_per_yr` + `pv_exported_kwh_per_yr` on `CalculatorInputs` carry the β-split per-year totals. - All factor fields are `Optional[float] = None` (defaults preserve the legacy synthetic-construction behaviour in unit tests). - `cert_to_inputs` populates them via `_effective_monthly_co2_factor` / `_effective_monthly_pe_factor` over `pv_split.epv_*_monthly_kwh`, keyed on Table 12 code 30 for dwelling and code 60 for exported. ## Test baseline at HEAD ```bash PYTHONPATH=/workspaces/model python -m pytest \ backend/documents_parser/tests/test_summary_pdf_mapper_chain.py \ backend/documents_parser/tests/test_elmhurst_extractor.py \ backend/documents_parser/tests/test_elmhurst_end_to_end.py \ domain/sap10_calculator/worksheet/tests/test_e2e_elmhurst_sap_score.py \ domain/sap10_calculator/worksheet/tests/test_water_heating.py \ domain/sap10_calculator/worksheet/tests/test_mean_internal_temperature.py \ domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py \ domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py \ domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py \ domain/sap10_ml/tests/test_rdsap_uvalues.py \ datatypes/epc/schema/tests/test_schema_loading.py \ domain/sap10_calculator/worksheet/tests/test_photovoltaic.py \ --no-cov -q ``` Expected: **763 pass + 0 fail**. ## Conventions preserved - **1e-4 across the board** ([[feedback-one-e-minus-4-across-the-board]]) - **Worksheet, not API, is the target** ([[feedback-worksheet-not-api-reference]]) - **Cross-mapper parity via cascade** ([[feedback-cross-mapper-parity-via-cascade]]) - **Spec-floor skepticism** ([[feedback-spec-floor-skepticism]]) AND **verify handover claims** ([[feedback-verify-handover-claims]]) — the original "E_PV magnitude bug" hypothesis was wrong (worksheet E_PV matched cascade); the real bug was a schema-gap on `pv_batteries[].battery_capacity`. Verified by reading the worksheet PDF directly. - **Bigger slices OK for uniform-cohort work** ([[feedback-bigger-slices-for-uniform-work]]) - **Golden residuals → ~0** ([[feedback-golden-residuals-near-zero]]) — cluster closed by ~50% magnitude across the phase - **AAA test convention** ([[feedback-aaa-test-convention]]) - **`abs(diff) <= tol`** not `pytest.approx` ([[feedback-abs-diff-over-pytest-approx]]) - **Spec citation in commit messages** ([[feedback-spec-citation-in-commits]]) - **One slice = one commit; stage by name** ([[feedback-commit-per-slice]]) - **Pyright net-zero per touched file** ([[feedback-zero-error-strict]])