docs: handover for next agent — Appendix L lighting → §11a/§12a/§13a sweep

Rewrites HANDOVER_NEXT.md after the §10a + §4 HW work. Two tickets:

1. **Appendix L lighting predictor swap** (immediate) — replace the
   legacy `domain.ml.demand.predicted_lighting_kwh` heuristic with
   the spec-faithful Appendix L L1-L12 cascade already living in
   `worksheet/internal_gains._lighting_gains_monthly_w`. Single
   slice; closes 000474 cost residual from +9.2% toward ~0%.
2. **§11a SAP rating + §12a CO2 + §13a Primary Energy sweep** —
   per-end-use cascade on top of the §10a `FuelCostResult`. Mirrors
   §10a's pattern (kwargs orchestrator + Result dataclass + cert_to_
   inputs precompute + calculator delegation). ~5 slices.

Carries §A current-state residuals table (000474 + 000490 post-§4
HW), §B/§C tickets with slice plans, §D codebase pointers, §G
deferred-list cross-reference to ADR-0010 amendment + SPEC_COVERAGE
remaining-work sections.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-21 23:03:52 +00:00
parent c9eb231a9c
commit 95086f957e

View file

@ -1,14 +1,17 @@
# Handover — §10a Fuel costs (xlsx ~614740)
# Handover — Appendix L lighting closure → §11a / §12a / §13a sweep
**For the agent picking up the next chunk of work.** Read this BEFORE invoking `/grill-me`. Read all of it. Caveman mode is the house style — terse, technical, no filler.
Owner: `khalim@domna.homes`. Branch: `ara-backend-design-prd`.
This handover covers **the §10a Fuel costs worksheet block** — xlsx rows ~614740, spec lines 80448084. Line refs (240)..(255). Mirrors the §9a slice you've just shipped: refactor existing `calculator.py` inline cost arithmetic into a worksheet-shape orchestrator with named line-ref outputs.
Two tickets in priority order:
1. **Immediate — Appendix L lighting predictor swap.** Closes the residual +9.2% cost gap on 000474 that §4 HW exposed. ~1 slice, ~50 LoC.
2. **Section sweep — §11a / §12a / §13a "Individual heating systems incl micro-CHP".** Worksheet line refs (256)..(272). Builds the rating + CO2 + primary-energy per-end-use cascade on top of the §10a fuel-cost orchestrator. ~3 slices.
Hard rules (unchanged):
- **Caveman mode** house style.
- **Tolerance**: don't loosen test tolerances to make them pass. If the orchestrator can't hit the locked tolerance, pause and ask the user.
- **Tolerance**: don't loosen test tolerances to make them pass. If a refactor can't hit the locked tolerance, pause and ask the user.
- **Spec PDFs**: don't scan more than ~50 lines without checking with the user.
- **Commit per slice**, one slice = one commit, AAA test convention (`# Arrange / # Act / # Assert`), `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>` trailer.
@ -19,207 +22,286 @@ Hard rules (unchanged):
Last commits on this branch:
```
15d6b781 pcdb followup: e2e mapper-chain regression test
7d4f3d78 pcdb followup: 000474 fixture lodges main_heating_index_number=16839; e2e ceiling 7 → 2
1b43c95c pcdb followup: 000490 fixture lodges main_heating_index_number=10328 (Vaillant Ecotec Pro)
e63516cb docs: SPEC_COVERAGE PCDB integration row + slice progress + gap-list update
a104dd55 pcdb slice 3: cert_to_inputs precedence cascade — Table 105 overrides Table 4a/4b
23678228 pcdb slice 2: runtime gas_oil_boiler_record lookup via Table 105 NDJSON
fe04cd3a pcdb slice 1: pcdb10.dat ETL → 8 per-table NDJSON files + parser + 8 tests
53c393bf docs: SPEC_COVERAGE §9a row + slice progress table + PCDB gap-list update
380b6781 §9a slice 2: CalculatorInputs.energy_requirements + cert_to_inputs wiring (atomic)
2b5fc6a5 §9a slice 1: space_heating_fuel_monthly_kwh orchestrator + EnergyRequirementsResult
05d9dc73 docs: SPEC_COVERAGE §8f row + slice progress table
43cc16bc §8f slice 1: fabric_energy_efficiency_kwh_per_m2_yr + 6-fixture conformance
c9f15a2e docs: SPEC_COVERAGE §8c row + slice progress table
f3797066 §8c slice 3: CalculatorInputs + MonthlyEntry + SapResult + cert_to_inputs wiring (atomic)
3b9fa936 §8c slice 2: 6-fixture ALL_FIXTURES conformance with shared template constants
cf28eec4 §8c slice 1: space_cooling_monthly_kwh orchestrator + utilisation_factor_loss leaf
c9eb231a §4 HW slice 3: docs — SPEC_COVERAGE row + Remaining work + golden note
02fc9e4d §4 HW slice 2: Equation D1 monthly water-eff cascade
760e25de §4 HW slice 1: PCDB Table 3b combi-loss override
ae8c9461 docs: §10a slice 3 — ADR-0010 amendment + SPEC_COVERAGE row
adfa7f60 §10a slice 2: cert_to_inputs._fuel_cost + calculator delegation
0f255165 §10a slice 1: table_32 + table_12a + fuel_cost orchestrator
6d6767ce docs: handover for §10a Fuel costs + SPEC_COVERAGE PCDB-followup updates
```
347 tests passing. 0 new pyright errors on touched modules. Sections §1, §3, §5, §6, §7, §8, §8c, §8f, §9a (single-main), §13 Full. PCDB Table 105 Gas/Oil Boilers wired into the efficiency cascade end-to-end (mapper → domain → calculator), 4 PCDB-listed corpus golden certs flowing through PCDB precedence.
486 tests passing on the domain package. Sections fully implemented: §1, §3, §4 (combi-gas + PCDB Table 3b + Eq D1), §5, §6, §7, §8, §8c, §8f, §9a (single-main), §10a (RdSAP10 Table 32 + Table 12a + note (a) standing), §13 SAP-rating equation. PCDB Tables 105 (gas/oil boilers) + combi-loss fields wired end-to-end.
**000490 + 000474 post-PCDB residuals (live, after this branch):**
**000474 + 000490 post-§4 HW residuals:**
| metric | 000490 actual | 000490 PDF | 000474 actual | 000474 PDF |
| metric | 000474 actual | 000474 PDF | 000490 actual | 000490 PDF |
|---|---|---|---|---|
| SAP score | 63 | 57 (+6) | 63 | 62 (+1) |
| main_heating_fuel kWh | 13001.3 | 13003.85 (-0.02%) | 12304.8 | ~11968 (+2.8%) |
| total_fuel_cost £ | 706.23 | 807.54 (-12.5%) | 651.85 | 655.69 (-0.6%) |
| e2e SAP ceiling | 6 | — | 2 | — |
| SAP score | 59 | 62 (Δ-3) | 60 | 57 (Δ+3) |
| main_heating_fuel kWh | 12304.8 | ~11968 (+2.8%) | 13001.3 | 13003.85 (≤0.1%) |
| **HW fuel kWh** | **2291.6** ✓ | **2291.78 (≤0.1%)** | **2846.8** ✓ | **2850.57 (≤0.1%)** |
| lighting kWh | 528.1 | ~169 (back-derived) | ? (similar overshoot) | ? |
| total_fuel_cost £ | 715.83 | 655.69 (+9.2%) | 769.70 | 807.54 (4.7%) |
| e2e SAP ceiling | 3 (was 4 post-§10a) | — | 3 (was 2 post-§10a) | — |
000474 is near-closure. 000490 widened on cost (not kWh) because the cert pre-dates the 14-Mar-2025 SAP10.2 amendment — pre-amendment gas unit prices were ~13% higher than what `domain.sap.tables.table_12.SAP_10_2_SPEC_PRICES` carries. ADR-0010 §3 Validation Cohort discipline says this is *known* spec-version drift, not a calculator regression. The fuel-kWh closure is the spec-faithful direction.
Two residuals remain:
- **000474 lighting overshoots ~3x** (528 vs ~169 back-derived). This is the dominant remaining cost residual — drives the +£60 over PDF.
- **000490 cost residual 4.7%** is spec-version drift per ADR-0010 §3 Validation Cohort (cert pre-dates 14-Mar-2025 amendment; PDF cost embeds older cert-assessor prices). HW kWh closure is the spec-faithful direction.
---
## §B — Ticket: §10a Fuel costs
## §B — Ticket 1: Appendix L lighting predictor swap
### B.1 Mission
Implement §10a (xlsx rows ~614740) — produce per-end-use cost lines (240)..(255) via a new `worksheet/fuel_cost.py` orchestrator. Refactor `calculator.py`'s inline cost arithmetic to delegate to the orchestrator; expose worksheet-line-shape `FuelCostResult` on `CalculatorInputs`. Six Elmhurst fixtures already cover this implicitly via `total_fuel_cost_gbp` — just need to surface the per-line refs.
Replace the legacy `domain.ml.demand.predicted_lighting_kwh` (heuristic `9.3 × TFA × (1 reduction)`) with the **spec-faithful Appendix L L1-L12 annual lighting kWh** that already exists in `domain.sap.worksheet.internal_gains`. Single slice. Closes 000474 cost residual from +9.2% toward ~0%.
### B.2 SAP10.2 spec anchors (lines 80448084)
### B.2 Why
| Line ref | Description |
|---|---|
| (240a)-(240e) | Space heating main 1 cost. Off-peak: (240a) Table 12a high-rate fraction, (240b) low-rate = 1-(240a), (240c) high-rate cost = (211)×(240a)×price×0.01, (240d) low-rate cost, (240e) collapses to other-fuel single-rate cost. |
| (241a)-(241e) | Space heating main 2 cost — zero-branch in scope A (no two-main fixtures). |
| (242a)-(242e) | Secondary heating cost. |
| (243)-(247) | Water heating cost. (243) high-rate fraction (Table 12 — note: not Table 12a; HW uses a different row). (244) = 1-(243). (245)/(246) high/low cost. (247) other-fuel single-rate. |
| (247a) | Instant electric shower (64a) × price. Zero in all 6 fixtures. |
| (248) | Space cooling cost (221) × price. Zero in all 6 fixtures (§8c f_C=0). |
| (249) | Pumps/fans/keep-hot cost (231) × price. Spec note: if off-peak, list each (230a)..(230g) separately by Table 12a. |
| (250) | Lighting cost (232) × price. |
| (251) | Additional standing charges (Table 12 note (a) — currently NOT applied for rating purposes for standard electricity tariff). |
| (252) | Energy saving/generation Appendix M/N items (PV credit, micro-CHP). Currently `pv_credit` is computed inline at calculator.py:282-283. |
| (253)/(254) | Appendix Q items (saved/used). Zero in our corpus. |
| (255) | Total = sum of all (240).. through (254). Currently `total_cost = max(0.0, main_heating_cost + ... pv_credit)` at calculator.py:296. |
The Appendix L cascade is already implemented correctly for the **§5 internal-gains side** (used to compute (67) lighting gains as monthly watts). But the **cost side** (`inputs.lighting_kwh_per_yr`) reads the heuristic `predicted_lighting_kwh`. They diverge by ~3x on 000474.
For 000474: TFA=71.55, N=1.8896, 8 low-energy bulbs (LED/CFL undistinguished).
- Heuristic: `9.3 × 71.55 × (1 0.45) ≈ 366` kWh/yr (with rough LED+CFL share assumption) → actual 528 lodged.
- Spec Appendix L cascade with daylight factor + λ_b + λ_req + topup: ~169 kWh/yr.
### B.3 Current state in code
Calculator does the cost arithmetic inline in `calculate_sap_from_inputs` (calculator.py:295-305):
[packages/domain/src/domain/ml/demand.py:222-243](packages/domain/src/domain/ml/demand.py#L222-L243) — `predicted_lighting_kwh` heuristic. Used by cert_to_inputs to populate `CalculatorInputs.lighting_kwh_per_yr`.
[packages/domain/src/domain/sap/worksheet/internal_gains.py:208-265](packages/domain/src/domain/sap/worksheet/internal_gains.py#L208-L265) — `_lighting_gains_monthly_w` (private) builds `e_l_annual_kwh` (line 253) per Appendix L L1-L12, but **doesn't expose it**. The annual kWh figure is computed then converted to monthly W gains.
Search for `e_l_annual_kwh` to confirm — it's a local variable inside the gains function.
### B.4 Likely shape
```python
main_heating_cost = main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
secondary_heating_cost = secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh
hot_water_cost = inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
pumps_fans_cost = inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
total_cost = max(0.0, main_heating_cost + secondary_heating_cost + hot_water_cost + pumps_fans_cost + lighting_cost - pv_credit)
# packages/domain/src/domain/sap/worksheet/internal_gains.py
def annual_lighting_kwh(
*,
total_floor_area_m2: float,
n_occupants: float,
fixed_lighting_capacity_lm: float,
fixed_lighting_efficacy_lm_per_w: float,
daylight_factor: float,
) -> float:
"""SAP 10.2 Appendix L L1-L12 — annual lighting kWh/yr (cost side).
Mirrors the kWh internal to `_lighting_gains_monthly_w` but surfaces
it for `inputs.lighting_kwh_per_yr` to consume."""
# extract the e_l_annual_kwh derivation; either factor it out into
# this fn and have the gains fn call it, OR have both fns share
# a small `_lighting_annual_kwh_components` helper.
...
```
Missing relative to spec:
1. **Table 12a high-rate fractions** for off-peak tariffs (electric storage / heat pumps) — currently `space_heating_fuel_cost_gbp_per_kwh` is a single scalar; for off-peak certs the spec splits into high-rate × fraction + low-rate × (1-fraction). The cert_to_inputs `_off_peak_main_heating_cost(...)` path collapses both into the effective per-kWh cost.
2. **(251) Standing charges** — Table 12 note (a) excludes standing charges from the rating ECF in the standard tariff case but includes them when a non-standard tariff is selected. Currently not applied at all.
3. **(252) Per-row Appendix M/N** — currently single `pv_credit` scalar. Spec wants a row per (233a)-(235d) (PV / wind / hydro / micro-CHP).
4. **Worksheet-line surfacing** — none of (240a)..(254) are exposed individually. Only the (255) total bubbles up via `total_fuel_cost_gbp`.
### B.4 Likely shape (mirror §9a pattern — composite slot on CalculatorInputs)
Then `cert_to_inputs` swaps the call:
```python
# packages/domain/src/domain/sap/worksheet/fuel_cost.py
# Before:
lighting_kwh = predicted_lighting_kwh(
total_floor_area_m2=epc.total_floor_area_m2,
cfl_count=epc.cfl_fixed_lighting_bulbs_count,
led_count=epc.led_fixed_lighting_bulbs_count,
incandescent_count=epc.incandescent_fixed_lighting_bulbs_count,
)
# After: reuse the §5 cascade inputs (already computed for internal_gains_from_cert).
lighting_kwh = annual_lighting_kwh(
total_floor_area_m2=epc.total_floor_area_m2,
n_occupants=..., # Appendix J Table 1b (already computed for §4)
fixed_lighting_capacity_lm=..., # cert-derived, mirror §5 path
fixed_lighting_efficacy_lm_per_w=..., # cert-derived, mirror §5 path
daylight_factor=..., # L2a/L2b — already computed for §5
)
```
The cert→Appendix-L-inputs derivation already lives in `internal_gains_from_cert` (called from cert_to_inputs at line ~1146). Reuse it.
### B.5 Slice plan
```
S1 — annual_lighting_kwh free function in worksheet/internal_gains.py
(factor out e_l_annual_kwh from _lighting_gains_monthly_w; both
callers share the derivation). Synthetic unit test pins 000474
PDF value (~169 kWh/yr) given the cert's bulb counts.
S2 — cert_to_inputs swaps lighting_kwh derivation from predicted_lighting_
kwh to annual_lighting_kwh. Two ALL_FIXTURES e2e closures: 000474
cost residual closes +9.2% → ~0% (ceiling 3 → 1 or below);
000490 ceiling re-checks.
S3 — docs: SPEC_COVERAGE Appendix L row + ADR-0010 amendment if needed.
```
### B.6 Tests
- Synthetic: `annual_lighting_kwh(TFA=71.55, N=1.8896, C_L_fixed=..., ε_fixed=..., D=...) == 169 ± 5` against a hand-computed Appendix L example.
- ALL_FIXTURES: 6 fixtures already pin lighting kWh tuples via §5 — those don't change. The cost-side closure shows in `inputs.lighting_kwh_per_yr` matching the §5 internal `e_l_annual_kwh` exactly.
- e2e: 000474 SAP score ceiling tightens 3 → 1 (or below).
### B.7 Don't list
- Don't delete `predicted_lighting_kwh` immediately — leave it in `domain.ml.demand` with a deprecation comment in case external callers rely on it. Future slice rips it.
- Don't change the §5 lighting GAINS path. The kWh figure feeds both gains and cost — just expose it.
---
## §C — Ticket 2: §11a / §12a / §13a "Individual heating systems incl micro-CHP"
### C.1 Mission
Build the per-end-use rating + CO2 + primary-energy cascade on top of the §10a `FuelCostResult`. SAP 10.2 worksheet line refs:
- **§11a SAP rating** — (256), (257), (258). Spec lines ~8085-8110.
- **§12a CO2 emissions** — (259)..(265). Spec lines ~8110-8150. Per-end-use CO2 mirroring §10a's (240)..(250) cost structure.
- **§13a Primary Energy** — (266)..(272). Same per-end-use mirror but with primary-energy factors.
The calculator currently aggregates these inline (calculator.py:441-505 has `co2 = main_heating_co2 + secondary_heating_co2 + ...` and `primary_energy_kwh = max(0, space_heating_primary_kwh + ... pv_primary_offset_kwh)`). The cascade is correct at aggregate level but does NOT produce the per-end-use line refs in worksheet shape.
Worksheet-shape fidelity rule (memory `feedback_worksheet_shape_fidelity`): mirror SAP10.2 sections completely with per-line dataclasses, even when no consumer needs the per-line detail.
### C.2 SAP10.2 spec anchors
User needs to point at PDF pages — likely around pages 32-38 for §11a/§12a/§13a (after §10 fuel costs at pages 29-32). The xlsx at the repo root maps line refs to cells.
### C.3 Likely shape
Mirror §10a's pattern:
```python
# packages/domain/src/domain/sap/worksheet/sap_rating.py
@dataclass(frozen=True)
class FuelCostResult:
"""SAP 10.2 §10a worksheet line refs (240)..(255)."""
# Main system 1 (off-peak split)
main_1_high_rate_fraction: float # (240a)
main_1_low_rate_fraction: float # (240b)
main_1_high_rate_cost_gbp: float # (240c)
main_1_low_rate_cost_gbp: float # (240d)
main_1_other_fuel_cost_gbp: float # (240e)
main_1_total_cost_gbp: float # (240) — sum
# Main system 2 — zero-branch placeholders
main_2_high_rate_fraction: float # (241a)
main_2_low_rate_fraction: float # (241b)
main_2_high_rate_cost_gbp: float # (241c)
main_2_low_rate_cost_gbp: float # (241d)
main_2_other_fuel_cost_gbp: float # (241e)
main_2_total_cost_gbp: float # (241)
# Secondary
secondary_high_rate_fraction: float # (242a)
... # (242b)..(242e)
secondary_total_cost_gbp: float # (242)
# Water heating
water_high_rate_fraction: float # (243)
water_low_rate_fraction: float # (244)
water_high_rate_cost_gbp: float # (245)
water_low_rate_cost_gbp: float # (246)
water_other_fuel_cost_gbp: float # (247)
water_total_cost_gbp: float # sum
# Other end-use rows
instant_shower_cost_gbp: float # (247a)
space_cooling_cost_gbp: float # (248)
pumps_fans_cost_gbp: float # (249)
lighting_cost_gbp: float # (250)
standing_charges_gbp: float # (251) — zero in standard tariff
pv_credit_gbp: float # (252) — keep negative
appendix_q_saved_gbp: float # (253) — zero in corpus
appendix_q_used_gbp: float # (254) — zero in corpus
total_cost_gbp: float # (255) — Σ minus PV
class SapRatingResult:
"""SAP 10.2 §11a line refs (256)..(258)."""
ecf_gbp_per_m2_per_yr: float # (256)
sap_rating_continuous: float # (257) un-rounded
sap_rating_integer: int # (258) rounded
def sap_rating_from_fuel_cost(...) -> SapRatingResult:
...
# packages/domain/src/domain/sap/worksheet/co2_emissions.py
@dataclass(frozen=True)
class Co2EmissionsResult:
"""SAP 10.2 §12a line refs (259)..(265). Per-end-use CO2 in kg/yr."""
main_1_co2_kg_per_yr: float # (259)
main_2_co2_kg_per_yr: float # (260)
secondary_co2_kg_per_yr: float # (261)
hot_water_co2_kg_per_yr: float # (262)
pumps_fans_co2_kg_per_yr: float # (263)
lighting_co2_kg_per_yr: float # (264)
pv_credit_co2_kg_per_yr: float # (265) — negative
total_co2_kg_per_yr: float # sum, clamped >=0
# packages/domain/src/domain/sap/worksheet/primary_energy.py
@dataclass(frozen=True)
class PrimaryEnergyResult:
"""SAP 10.2 §13a line refs (266)..(272). Per-end-use kWh primary/yr."""
# mirror Co2EmissionsResult shape with primary energy factors
...
```
Slice plan suggestion (mirror §9a 3-slice cadence):
Per-end-use CO2 factors source from **Table 12 / Table 32** (same values per RdSAP10 §19.2). Per-end-use PEF source from **Table 12 / Table 32** primary energy factor column.
### C.4 Calculator coupling
Mirror §10a path (i) — cert_to_inputs precompute. `CalculatorInputs` gains:
- `sap_rating: SapRatingResult`
- `co2_emissions: Co2EmissionsResult`
- `primary_energy: PrimaryEnergyResult`
`calculate_sap_from_inputs` reads them; deletes the inline CO2 + primary-energy blocks. The `SapResult` fields stay (sap_score, co2_kg_per_yr, primary_energy_kwh_per_yr, etc.) — populated from the composite slots.
### C.5 Slice plan
```
Slice 1 — FuelCostResult + fuel_cost_from_energy_requirements orchestrator (no off-peak split:
assume single rate; all 28 fields populated). Synthetic test pins (255) total matches the
calculator's current inline math.
Slice 2 — CalculatorInputs.fuel_cost composite slot + cert_to_inputs wiring + calculator delegation
(calculator.py:295-305 becomes `total_cost = inputs.fuel_cost.total_cost_gbp`). Round-trip
test asserts SapResult.total_fuel_cost_gbp unchanged from pre-refactor.
Slice 3 — Table 12a high-rate fraction lookup + off-peak split for main heating / HW / pumps/fans.
Touches the 4 cert paths that lodge off-peak meter_type. Spec (251) standing charges
deferred (Table 12 note (a) — needs separate slice gated on the standard-tariff rule).
Slice 4 — docs: SPEC_COVERAGE §10a row + slice progress table.
Slice 1 — worksheet/co2_emissions.py: Co2EmissionsResult + orchestrator + Table 12/32 CO2 factor lookups (or reuse table_12.co2_factor_kg_per_kwh). Synthetic unit tests + 6-fixture conformance via SapResult.co2_kg_per_yr parity.
Slice 2 — worksheet/primary_energy.py: PrimaryEnergyResult + orchestrator. Same shape.
Slice 3 — worksheet/sap_rating.py: SapRatingResult (refactor of worksheet/rating.py: existing `energy_cost_factor` + `sap_rating` + `sap_rating_integer` move into a single orchestrator that takes a FuelCostResult and returns the result dataclass).
Slice 4 — CalculatorInputs composite slots + cert_to_inputs wiring + calculator delegation (drop inline CO2/PE blocks). Re-verify 6-fixture e2e.
Slice 5 — docs: SPEC_COVERAGE rows for §11a / §12a / §13a, ADR-0010 amendment if cost target needs cross-ref.
```
### B.5 What to defer (name as remaining-work in slice 4)
### C.6 Tests
- **(251) Additional standing charges** — Table 12 note (a) rule. Currently zero. Needs separate slice covering when standing charges enter the rating ECF.
- **(252) Per-Appendix-M/N row split** — keep `pv_credit_gbp` as a single scalar in scope A; expand to per-line (233a)..(235d) when wind / hydro / micro-CHP fixtures land.
- **(253)/(254) Appendix Q** — zero placeholders; populate when a Q-item cert lands.
- **(247a) Instant electric shower** — line (64a) currently always 0 from `_hot_water_fuel_kwh_per_yr`. Wire when electric-shower cert lodges.
- **Off-peak per-row (230a)..(230g) split** — spec line 8076 says "if off-peak tariff, list each of (230a) to (230g) separately and apply fuel price according to Table 12a". Out of scope; requires the §9a (230a)-(230h) Table 4f pumps/fans breakdown first.
- Synthetic: per-orchestrator unit tests (kWh × factor arithmetic per end-use, PV credit sign, total clamps).
- 6-fixture ALL_FIXTURES: re-pin `SapResult.co2_kg_per_yr` + `primary_energy_kwh_per_yr` per fixture if PDF lodges them.
- e2e ceiling on 000474 SAP score: should stay at 1 (or wherever §B closes it).
### B.6 Calculator coupling
### C.7 Don't list
Mirror §9a path (i) — cert_to_inputs precompute. `CalculatorInputs.fuel_cost: FuelCostResult` composite slot. `calculate_sap_from_inputs` collapses to:
```python
fuel_cost = inputs.fuel_cost
total_cost = fuel_cost.total_cost_gbp
# (existing co2 + primary energy paths consume `main_fuel_kwh` etc. as before — they're orthogonal)
```
Existing scalar CalculatorInputs fields (`space_heating_fuel_cost_gbp_per_kwh`, etc.) stay as inputs to the orchestrator. Calculator stops reading them — symmetric with how §9a left `main_heating_efficiency` in place but stopped using it after `energy_requirements` landed.
### B.7 Tests
- **Synthetic** unit tests on the orchestrator: single-rate cost = kWh × £/kWh × 1.0; off-peak split honours Table 12a fractions; PV credit subtracts at the spec-correct precedence; zero-branch lines stay zero.
- **6-fixture ALL_FIXTURES** at orchestrator level — call with fixture LINE_211 / LINE_215 / LINE_219 / lighting / pumps_fans pins + SAP_10_2_SPEC_PRICES, assert (255) total matches PDF cost. **Will partially fail for 000490** (spec-version drift). Recommend pinning per-fixture LINE_255_TOTAL_FUEL_COST_GBP at the **current state** (after PCDB) so future improvements show as tightening; document the 000490 spec-version delta in the docstring as already done for the e2e SAP score test.
- **Cert-round-trip** in `test_cert_to_inputs.py`: build a typical cert, run cert_to_inputs → calculator, assert `inputs.fuel_cost.total_cost_gbp == result.total_fuel_cost_gbp` to float equality (pre-refactor parity).
### B.8 Don't list
- Do **not** touch the existing fuel-cost-per-kWh wiring in `cert_to_inputs._space_heating_fuel_cost_gbp_per_kwh / _hot_water_fuel_cost_gbp_per_kwh / _other_fuel_cost_gbp_per_kwh`. These are the orchestrator's *inputs*; keep them.
- Do **not** modify SAP_10_2_SPEC_PRICES — the Table 12 prices are spec-current. The 000490 cost gap is spec-version drift, not a price-table bug.
- Do **not** loosen e2e tolerance ceilings on 000490 / 000474 unless the refactor breaks parity. The current ceilings (6 / 2) reflect post-PCDB state and should hold across a pure refactor.
- Don't touch §13 `worksheet/rating.py` Equation 7-9 logic — the formulas are spec-faithful and already pinned. The refactor moves them under a `sap_rating_from_fuel_cost` orchestrator.
- Don't introduce a new CO2 factor table — use the existing `table_12.co2_factor_kg_per_kwh`. RdSAP10 §19.2 says CO2 is unchanged from SAP10.2 Table 12.
- Don't widen e2e ceilings to absorb refactor regressions. If anything regresses, pause + ask.
---
## §C — Codebase pointers
## §D — Codebase pointers
- §9a orchestrator template: [packages/domain/src/domain/sap/worksheet/energy_requirements.py](../../packages/domain/src/domain/sap/worksheet/energy_requirements.py) — 16-field dataclass + free function. Mirror this shape for `FuelCostResult`.
- Calculator inline cost code (target of refactor): [packages/domain/src/domain/sap/calculator.py:280-305](../../packages/domain/src/domain/sap/calculator.py#L280-L305).
- cert_to_inputs cost-per-kWh resolution: [packages/domain/src/domain/sap/rdsap/cert_to_inputs.py](../../packages/domain/src/domain/sap/rdsap/cert_to_inputs.py) — search for `_space_heating_fuel_cost_gbp_per_kwh` etc.
- Table 12 lookups: [packages/domain/src/domain/sap/tables/table_12.py](../../packages/domain/src/domain/sap/tables/table_12.py).
- Existing PCDB integration (recent precedent for new ETL/lookup pattern): [packages/domain/src/domain/sap/tables/pcdb/](../../packages/domain/src/domain/sap/tables/pcdb/).
- §9a tests: [packages/domain/src/domain/sap/worksheet/tests/test_energy_requirements.py](../../packages/domain/src/domain/sap/worksheet/tests/test_energy_requirements.py).
- e2e SAP score tests: [packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py](../../packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py).
- SPEC_COVERAGE: [docs/sap-spec/SPEC_COVERAGE.md](SPEC_COVERAGE.md).
- ADR-0009 / ADR-0010: [docs/adr/0009-deterministic-sap-calculator.md](../adr/0009-deterministic-sap-calculator.md), [docs/adr/0010-sap10-calculator-spec-target-and-validation.md](../adr/0010-sap10-calculator-spec-target-and-validation.md).
### Appendix L lighting (ticket 1)
- Legacy heuristic: [packages/domain/src/domain/ml/demand.py:222](packages/domain/src/domain/ml/demand.py#L222) `predicted_lighting_kwh`
- Spec cascade (gains side): [packages/domain/src/domain/sap/worksheet/internal_gains.py:204](packages/domain/src/domain/sap/worksheet/internal_gains.py#L204) `_lighting_gains_monthly_w` — extract `e_l_annual_kwh`
- cert_to_inputs callsite: [packages/domain/src/domain/sap/rdsap/cert_to_inputs.py](packages/domain/src/domain/sap/rdsap/cert_to_inputs.py) — search for `predicted_lighting_kwh`
### §11a/§12a/§13a sweep (ticket 2)
- §10a orchestrator (template): [packages/domain/src/domain/sap/worksheet/fuel_cost.py](packages/domain/src/domain/sap/worksheet/fuel_cost.py) — mirror the kwargs-orchestrator + Result dataclass shape.
- Existing inline CO2 + primary-energy: [packages/domain/src/domain/sap/calculator.py:467-505](packages/domain/src/domain/sap/calculator.py#L467-L505).
- SAP rating equations: [packages/domain/src/domain/sap/worksheet/rating.py](packages/domain/src/domain/sap/worksheet/rating.py) — `energy_cost_factor`, `sap_rating`, `sap_rating_integer`.
- Table 12 factors: [packages/domain/src/domain/sap/tables/table_12.py](packages/domain/src/domain/sap/tables/table_12.py) — CO2 + PEF columns already typed.
### Spec / docs
- SAP10.2 PDF: `docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf`. §11/§12/§13 are around pages 32-38 (ask the user for precise spec-page anchors before scanning).
- RdSAP10 PDF: `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf`. §19.2 confirms CO2 + PEF match SAP10.2 Table 12.
- ADR-0010 (cost target): [docs/adr/0010-sap10-calculator-spec-target-and-validation.md](docs/adr/0010-sap10-calculator-spec-target-and-validation.md). Carries the §10a amendment + handover-time deferred list.
- SPEC_COVERAGE: [docs/sap-spec/SPEC_COVERAGE.md](docs/sap-spec/SPEC_COVERAGE.md).
### Fixtures
- 000474 + 000490 are the two fixtures with PDF e2e expectations (`test_e2e_elmhurst_sap_score.py`). 4 others (000477, 000480, 000487, 000516) only pin worksheet-section LINE_* values.
- Lighting fixture inputs: each `_elmhurst_worksheet_*.py` has CFL/LED/incandescent counts on the cert build_epc().
---
## §D — Skills
## §E — Skills
The dev container ships `/grill-me`, `/tdd`, `/caveman`. Default flow:
```
/grill-me → walk down design tree, recommend scope cuts
/tdd implement §10a Fuel costs → one test → one impl → repeat
/grill-me → walk down design tree, recommend scope cuts
/tdd implement Appendix L lighting swap → one test → one impl → repeat
```
User invokes `/grill-me` first when ready. Audit B.5 / B.7 / B.8 + surface unknowns first; wait for `/grill-me`.
Same chain for §11a/§12a/§13a. Consider `/grill-with-docs` if domain language drifts.
---
## §E — Definitely do NOT
## §F — Definitely do NOT
- Do **not** touch the §10a-adjacent §11a (256-258) rating chain — already Full.
- Do **not** scan more than ~50 lines of spec PDF without asking the user for the specific table/page.
- Do **not** modify existing fixture `build_epc()` to change boiler / PCDB lodgement — only 000490 + 000474 carry PCDB pointers per real PDF lodgement. Other 4 fixtures don't lodge PCDB and stay on Table 4a defaults until the user provides a PCDB code.
- Do **not** loosen test tolerances to make tests pass — ask the user.
- Do **not** invoke `/ultrareview` yourself.
- Do **not** widen the e2e SAP-score ceiling on 000474 (currently 2). It's tight on purpose post-PCDB.
- Do **not** loosen the 000474 e2e ceiling 3 → 4 etc. to mask the lighting overshoot. The whole point of the Appendix L slice is to close that ceiling further.
- Do **not** scan more than ~50 lines of spec PDF without asking the user for the specific page/table range.
- Do **not** touch `inputs.fuel_cost` (the §10a precompute) or `inputs.energy_requirements` (the §9a precompute). They're load-bearing and tested.
- Do **not** invoke `/ultrareview` yourself — it's user-triggered.
- Do **not** delete the legacy scalar cost fields (`space_heating_fuel_cost_gbp_per_kwh` etc.) from `CalculatorInputs` in this work — they're a synthetic-test backwards-compat shim per the §10a slice-2c fallback. Rip out only in a dedicated cleanup ticket after the synthetic test corpus migrates.
---
## §G — Known follow-ups (named on §10a + §4 HW deferred lists)
Reference: ADR-0010 amendment "Deferred work" + SPEC_COVERAGE §4 HW / §10a "Remaining work" sections.
**Worksheet:**
- Table 12a `Table12aSystem` cert→row mapping for off-peak electric mains (currently zero-sentinel fallback).
- Table 13 immersion / HP-DHW-only WH fractions.
- Off-peak per-row (230a)-(230g) Table 12a split for pumps/fans (spec line 8076).
- (247a) Instant electric shower kWh routing.
- (252) per-row Appendix M/N split (PV / wind / hydro / micro-CHP).
- (253)/(254) Appendix Q routes.
- §4 cylinder + solar + WWHRS + PV diverter + FGHRS branches.
- §4 PCDB Table 3b storage / FGHRS rows + Table 3c two-profile + Electric CPSU Appendix F.
**Infra / cleanup:**
- Drop legacy scalar fuel-cost fields from `CalculatorInputs` once synthetic test corpus migrates to `fuel_cost=...`.
- §11 FEE compliance conditions (different ventilation / HW / lighting / gains column) — relevant for new-build compliance, not the rating sweep.
- Heat-pump Appendix N cascade via PCDB Table 362 (replaces SCOP 2.30 Table 4a fallback for `main_category=4`).
- Table D1/D2/D3 condensing-boiler control-class corrections (Ecodesign).
---
End of handover. Read in full before `/grill-me`.