From beb0db952212894ceacba2e1e8e4cbb32f1d40ec Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 18:45:00 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20handover=20after=20S0380.44..S0380.46?= =?UTF-8?q?=20=E2=80=94=20PV=20=CE=B2-split=203/6=20wiring=20slices=20ship?= =?UTF-8?q?ped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents progress on the SAP 10.2 Appendix M1 β-factor split for the PE / CO2 / cost cascades + golden-fixture residual closure. Shipped: - Slice 1 (S0380.44): pure β-factor calculator module + 13 unit tests - Slice 2 (S0380.45): wire β into PE cascade - Slice 3 (S0380.46): wire β into CO2 cascade Cert 9501 (PV no battery): PE Δ -8.28 → +0.25, CO2 Δ +0.20 → -0.05 — clean spec validation. The 7-cert ASHP+battery cohort overshoots PE by +2.7..+8.1 because the cascade's E_PV is ~3× the worksheet's value (cert 0380 cascade 2570 kWh vs worksheet 831 kWh). E_PV magnitude audit deferred to Slice 5. Open: - Slice 4 (S0380.47, next): wire β into cost cascade - Slice 5 (S0380.48): E_PV magnitude audit - Slice 6 (S0380.49): re-pin fixtures + verify chain tests <1e-4 Co-Authored-By: Claude Opus 4.7 --- .../docs/HANDOVER_PV_BETA_SPLIT.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 domain/sap10_calculator/docs/HANDOVER_PV_BETA_SPLIT.md diff --git a/domain/sap10_calculator/docs/HANDOVER_PV_BETA_SPLIT.md b/domain/sap10_calculator/docs/HANDOVER_PV_BETA_SPLIT.md new file mode 100644 index 00000000..6cfc690f --- /dev/null +++ b/domain/sap10_calculator/docs/HANDOVER_PV_BETA_SPLIT.md @@ -0,0 +1,217 @@ +# Handover — PV β-factor split (3/6 wiring slices shipped) + +Branch `feature/per-cert-mapper-validation`. This session shipped +**3 slices** (S0380.44 → S0380.46) that implemented and wired SAP 10.2 +Appendix M1 §3-4 β-factor for the PE and CO2 cascades. Three more +slices remain before the ASHP cluster fully closes. + +**HEAD at handover start:** `5b269f23` (Slice S0380.46). +**Test suite:** 763 pass + 0 fail. + +## Slices shipped this session + +| 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 — `cert_to_inputs` builds monthly E_PV + D_PV + battery, calls `pv_split_monthly`, passes `pv_dwelling_kwh_per_yr` + `pv_exported_kwh_per_yr` to `CalculatorInputs`; calculator credits at IMPORT + EXPORT PEF | Appendix M1 §3a (p.93), §8 (p.94) | +| **S0380.46** | `5b269f23` | Wired β-split into CO2 cascade — added `pv_dwelling_co2_factor_kg_per_kwh` + `pv_exported_co2_factor_kg_per_kwh` (effective monthly Table 12d Σ); calculator subtracts the credit | Appendix M1 §7 (p.94), Table 12d code 60 | + +## Residual progress (ASHP cluster + cert 2130 + cert 9501) + +### PE residual (kWh/m²) + +| Cert | Pre-S0380.44 | Post-S0380.45 (PE wired) | Notes | +|---|---:|---:|---| +| 0330 (no PV) | +0.44 | +0.44 | unchanged ✓ | +| 0350 (PV+5kWh) | −7.78 | +2.73 | overshoots — EPV bug | +| 0380 (PV+5kWh) | −14.60 | +8.09 | overshoots — EPV bug | +| 2130 (PV gas) | −38.63 | −9.70 | partial close; +SAP-int 1 | +| 2225 (PV+5kWh) | −11.77 | +4.48 | overshoots — EPV bug | +| 2636 (PV+5kWh) | −9.65 | +3.42 | overshoots — EPV bug | +| 3800 (PV+5kWh) | −9.61 | +3.58 | overshoots — EPV bug | +| 9285 (PV+5kWh) | −7.96 | +3.20 | overshoots — EPV bug | +| 9418 (PV+5kWh) | −7.30 | +4.67 | overshoots — EPV bug | +| **9501 (PV no battery)** | **−8.28** | **+0.25** | **CLOSED ✓** — validates spec implementation | + +### CO2 residual (t/yr) + +| Cert | Pre-S0380.46 | Post-S0380.46 (CO2 wired) | Notes | +|---|---:|---:|---| +| 0330 | −0.034 | −0.034 | no PV, unchanged ✓ | +| 0350 | +0.171 | −0.084 | over → under flip | +| 0380 | +0.279 | −0.054 | over → under flip | +| 2130 | +0.299 | −0.046 | over → under flip | +| 2225 | +0.263 | −0.071 | over → under flip | +| 2636 | +0.219 | −0.058 | over → under flip | +| 3800 | +0.261 | −0.014 | over → under flip | +| 9285 | +0.157 | −0.098 | over → under flip | +| 9418 | +0.232 | −0.046 | over → under flip | +| 9501 | +0.202 | −0.047 | over → under flip | + +Cluster magnitude dropped ~3-5× on CO2; PE for the 5-kWh-battery +cohort overshoots because β is too low (R_PV = E_PV / D_PV too high, +which traces to the **E_PV magnitude bug** — Slice 5). + +## ★ Why cert 9501 closes but the 5-kWh-battery cohort overshoots + +Cert 9501 has PV but **no battery**. Its PE Δ closed from −8.28 to ++0.25 — clean validation that the β implementation is spec-correct. + +The 7-cert ASHP+battery cohort (0350/0380/2225/2636/3800/9285/9418) +shares the same Mitsubishi PUZ-WM50VHA + 3 kWp PV + 5 kWh battery +pattern. After Slice 45 they overshoot by +2.7..+8.1 PE. + +**Root cause** (already identified, deferred to Slice 5): the cascade +computes E_PV ≈ 3× the worksheet's value. For cert 0380: +- Cascade E_PV = 2570 kWh/yr (via `0.8 × kWp=3 × S × ZPV`) +- Worksheet E_PV = 831 kWh/yr (looks like 1 kWp × 0.8 × S × Z) + +Either `peak_power=3` in the API JSON is in units that aren't kWp +(maybe 0.1 kWp units?), or the worksheet was generated with different +data than the API lodgement. Audit needs to compare: +1. `peak_power` value across cohort certs (always 3 or 3.28? what does + the Summary PDF Section 19 lodge for these same certs?) +2. S value used by cascade `_pv_annual_s_kwh_per_m2` vs worksheet +3. ZPV mapping for overshading=1 + +With E_PV correctly = ~830, R_PV would drop ~3×, β rises from ~0.47 to +~0.66, and the cluster lands at ~0 residual. + +## Open slices + +| Slice | Status | What | Risk | +|---|---|---|---| +| **S0380.47** (Slice 4) | **NEXT** | Wire β into cost cascade — split E_PV,dw at IMPORT price + E_PV,ex at EXPORT price per §6 | Medium: shifts SAP rating for every PV cert; chain tests need re-pinning (small Δ) | +| **S0380.48** (Slice 5) | Pending | Audit E_PV magnitude bug for 5-kWh-battery cohort — kWp interpretation, S lookup, or ZPV mapping | Medium: will surface several certs' residuals to ~0 once fixed | +| **S0380.49** (Slice 6) | Pending | Re-pin all golden fixtures + verify cohort-1 + cohort-2 chain tests still <1e-4; tighten `_PE_ABS_TOLERANCE` / `_CO2_ABS_TOLERANCE` if cluster lands cleanly | Low: cleanup | + +## Slice 4 plan (cost cascade) + +[fuel_cost.py:182](../worksheet/fuel_cost.py) currently does: +```python +pv_credit = -pv_generation_kwh_per_yr * pv_export_credit_gbp_per_kwh +``` +treating ALL PV as exported at the EXPORT price (13.19 p/kWh = Table 12a +"electricity sold to grid, PV"). Per Appendix M1 §6, onsite-consumed +PV should bill at the IMPORT price (standard tariff ~18 p/kWh, or +weighted high/low Table 12a if off-peak meter). + +### Implementation outline + +1. Add to `CalculatorInputs` (calculator.py): + ```python + pv_dwelling_import_price_gbp_per_kwh: Optional[float] = None + ``` + (the EXPORT price field `pv_export_credit_gbp_per_kwh` already exists + and stays as the EXPORT side). + +2. In `cert_to_inputs.py`, compute the dwelling IMPORT price using the + same off-peak meter logic as `_space_heating_fuel_cost_gbp_per_kwh` + (Table 12a high/low rate weighted if meter is off-peak; standard + tariff otherwise). Pass through to `CalculatorInputs`. + +3. In [fuel_cost.py:182](../worksheet/fuel_cost.py) replace the single-rate + credit with the β-split: + ```python + pv_credit = -( + pv_dwelling_kwh_per_yr * dwelling_import_price + + pv_exported_kwh_per_yr * pv_export_credit_gbp_per_kwh + ) + ``` + Fall back to the legacy single-rate path when split fields are None. + +### Expected fallout + +- SAP rating shifts up slightly for every PV cert (cost cascade now + credits onsite consumption higher). Magnitude depends on β. +- Cohort-1 + cohort-2 chain-test 1e-4 pins all need re-pinning to the + new SAP score. The shift should be small (~0.02-0.05 SAP per cert, + per the cost-spread analysis in the prior handover) so the new pins + will still be tight against the worksheet. +- The 5-kWh-battery cohort cost residual will partially shift in + the right direction; the EPV-magnitude bug from Slice 5 will keep + some over-shoot until then. + +## Slice 5 plan (E_PV magnitude audit) + +Concrete diagnostics for the next agent: + +1. **Probe cert 0380's API JSON for the actual `peak_power` field unit.** + The JSON has `peak_power: 3`. SAP spec says "kWp" — but if the + worksheet works with ~1 kWp, either: + - The cert lodges `peak_power` in deca-watts (=0.01 kWp), or + - There's a `peak_power_unit` field we're missing, or + - The worksheet was generated with hand-corrected data + + Check the Elmhurst Summary PDF Section 19 for the same cert and + compare what's lodged there vs the API. + +2. **Probe `_pv_annual_s_kwh_per_m2` for cert 0380's array.** + Array is (orientation=5=South, pitch=3=45°, overshading=1=None). + Compute the cascade's S value and compare against the SAP Appendix + U3.3 table for South / 45° / UK average. Expected ~1100 kWh/m²/yr. + If cascade gives that and worksheet works with much less, the + issue is on the worksheet side (different climate region). + +3. **Probe `_PV_OVERSHADING_FACTOR[1]` = 1.0.** Compare against the + Table M1 spec value for "None or very little" overshading. + +4. **Try setting cert 0380's `peak_power = 1.0` and check if residuals close.** + If yes → it's a kWp interpretation bug. Surface it via the schema + or the mapper. + +## 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** for chain tests ([[feedback-worksheet-not-api-reference]]) +- **Cross-mapper parity via cascade** ([[feedback-cross-mapper-parity-via-cascade]]) +- **Spec-floor skepticism** ([[feedback-spec-floor-skepticism]]) +- **Bigger slices OK for uniform-cohort work** ([[feedback-bigger-slices-for-uniform-work]]) +- **Golden residuals → ~0** ([[feedback-golden-residuals-near-zero]]) +- **AAA test convention** with literal `# Arrange / # Act / # Assert` ([[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** + +## Lesson learned: β-split shape is universal across PE / CO2 / Cost + +All three cascades had the same bug shape: credit ALL PV at one rate +(IMPORT for PE; missing entirely for CO2; EXPORT for cost). The +spec-correct fix is uniformly: split E_PV by β; onsite at IMPORT +factor, exported at EXPORT factor. The cleanest API: + +- `CalculatorInputs.pv_dwelling_kwh_per_yr` + `pv_exported_kwh_per_yr` + are the cross-cascade-shared β-split state. +- Each cascade adds its own pair of factor fields: + - PE: `other_primary_factor` (IMPORT) + `pv_export_primary_factor` (EXPORT) + - CO2: `pv_dwelling_co2_factor_kg_per_kwh` + `pv_exported_co2_factor_kg_per_kwh` + - Cost: `pv_dwelling_import_price_gbp_per_kwh` + `pv_export_credit_gbp_per_kwh` +- `None` on any factor field falls back to the legacy single-rate + behaviour (preserves synthetic CalculatorInputs constructions in + unit tests). + +The EXPORT factor is keyed on **Table 12 code 60** ("electricity sold +to grid, PV") at all three cascades — already present in +[`table_12.py`](../tables/table_12.py).