Commit graph

441 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
35d2648ae6 Slice S0380.56: cascade WHC 914 routing extended to fuel cost / CO2 / PE
Slice S0380.55 routed water-heating EFFICIENCY to Main 2 for WHC
914. This slice extends the routing to water-heating FUEL — the
cascade's CO2 factor, PE factor, and Table 32 fuel-cost lookups
were still pinned to Main 1's fuel code via the legacy
`epc.sap_heating.water_heating_fuel or main_fuel` pattern.

For cert 000565 (WHC 914 + HP Main 1 + gas combi Main 2):
- `epc.sap_heating.water_heating_fuel` is None (Elmhurst §15 doesn't
  lodge a separate water-heating fuel type)
- `_main_fuel_code(Main 1)` is None (HP, no fuel_type lodged in §14.0
  — Elmhurst convention for heat pump certs)
- Old pattern: water_fuel = None → `co2_factor_kg_per_kwh(None) = 0`
  → HW CO2 silently 0 (off by ~833 kg/yr vs gas combi truth)

New helper `_water_heating_fuel_code(epc)` mirrors `_water_heating_
main(epc)`: prefers the explicitly-lodged `water_heating_fuel`,
otherwise falls back to `_main_fuel_code` of whichever main system
services DHW per WHC. Wired into 5 cascade sites (CO2 / PE / cost
× hot-water + per-end-use CO2 + per-end-use PE factors).

Cert 000565 cascade impact:
- hot_water_co2 (kg/yr): factor=0 → 0.21 (gas) — now correctly
  attributes ~833 kg HW CO2 to gas combustion
- hot_water_primary_factor: 0 → gas Table 12e value
- hot_water_high_rate_gbp_per_kwh: previously fell through to Main 1
  fuel-code which is also None → gas tariff sentinel; now derives
  explicitly from Main 2's mains-gas fuel (Table 32 code 26)
- co2_kg_per_yr pin: +287 → +734 (got "worse" because HW gas CO2 is
  now correctly counted; remaining surplus is from an INDEPENDENT
  Main 1 fuel-inference bug — `_main_fuel_code` returns None for HP
  Main 1 because Elmhurst §14.0 leaves `Fuel Type` empty for heat
  pumps, so the cost/CO2 cascade defaults Main 1 to the gas tariff)

The Main-1 HP fuel-inference bug is the next slice. For single-main
non-HP certs the helper resolves identically to the prior pattern
(water_heating_fuel explicit, or Main 1 fuel) — no behavioural
change for the existing fixture corpus.

Pyright net-zero (34 / 34).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
b8ea20988f Slice S0380.55: cascade WHC 914 → Main 2 water-heating efficiency routing
Closes the second half of the cert 000565 Main 2 work. After Slice
S0380.54 lodged Main 2 on the EpcPropertyData, the water-heating
cascade still derived efficiency from Main 1 (the heat pump) instead
of Main 2 (the gas combi that actually services DHW).

Per the Elmhurst RdSAP convention, `Water Heating SapCode 914` =
"from second main system" — DHW is generated by Main 2, not Main 1.
The §4 / Appendix D2.1 summer-efficiency lookup must therefore key
off Main 2's PCDB Table 105 record (cert 000565: PCDB 15100 Vaillant
Ecotec plus 415, summer η = 88%) rather than Main 1's HP COP.

Implementation:
- New `_water_heating_main(epc)` helper — returns Main 2 when WHC
  is in `_WATER_FROM_SECOND_MAIN_CODES = {914}` AND a second main is
  lodged; otherwise returns Main 1 (matches prior behaviour for
  single-main certs + WHC 901/902 "from main system")
- The water-eff branch at the §4 cascade now reads `water_pcdb_main
  = gas_oil_boiler_record(water_main.main_heating_index_number)`
  + `_water_efficiency_with_category_inherit(water_main.sap_main_
  heating_code, water_main.main_heating_category, _main_fuel_code(
  water_main))` — same logic as before but parametrised by the
  water-heating main rather than hard-coded to Main 1

Cert 000565 cascade impact on hot_water_kwh_per_yr pin:
- Before: actual 1,844.66 kWh/yr (= HW heat / HP COP 1.70 — wrong)
  Δ −1,910.36 vs U985-0001-000565.pdf expected 3,755.03
  After Slice S0380.54 (Main 2 lodged but cascade still using Main 1):
  actual 3,919.91 kWh/yr, Δ +164.88 (regression from the no-cascade
  baseline because Main 2 PCDB was lodged but water_eff still came
  from Main 1's HP-vs-default fallthrough)
- After this slice: actual 3,969.53 kWh/yr (= HW heat / 0.88)
  Δ +214.50 — 89% reduction vs the original Main-1 WHC 914 routing,
  remaining gap is fine-grained (FGHRS / solar HW / Table 3a no-keep-
  hot territory — separate slice)

For single-main certs (the 14 existing Summary fixtures + 8 ASHP
cohort certs): `_water_heating_main` returns Main 1, identical to
the prior `main` reference. Cohort regression check: 472 pass + 10
expected 000565 fails — no broader regression.

Spec source: SAP 10.2 §4 water-heating cascade + Appendix D2.1 (D1
equation) summer-efficiency override; Elmhurst RdSAP water-heating
code 914 ("from second main system").

Pyright net-zero on cert_to_inputs.py (34 errors before, 34 after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
c52e750bb2 Slice S0380.52: cert 000565 Elmhurst-only mapper-driven cascade pin + glazing-label coverage
User pivot at end of prior session: don't hand-build EpcPropertyData
fixtures — route Summary PDFs through `EpcPropertyDataMapper.from_
elmhurst_site_notes` so the pin grid exercises extractor + mapper +
calculator, and each new Elmhurst doc grows mapper coverage instead
of bespoke fixture code.

New fixture cert 000565 is a stress-test cert (5 building parts, age
mix A→J, conservatory with heaters, curtain wall, basement walls,
mixed party-wall constructions) that surfaces many uncommon cascade
paths absent from the cohort-2 + ASHP corpus.

Mapper coverage extended for 3 Elmhurst §11 glazing labels surfaced
on this cert (per RdSAP-Schema-21.0.1, `datatypes/epc/domain/
epc_codes.csv` glazed_type rows):

  "Triple between 2002 and 2021": 9  (RdSAP-21 schema row 9 — triple
       glazing, installed 2002-2022 in EAW; `_G_PERPENDICULAR_BY_
       GLAZING_TYPE[9] = 0.68`, `_G_LIGHT_BY_GLAZING_CODE[9] = 0.70`)
  "Single glazing": 1                (alias of bare "Single"; cascade
       g_L = 0.90, g⊥ = 0.85 per SAP 10.2 Table 6b)
  "Double glazing, known data": 3    (Elmhurst lodgement of RdSAP-21
       schema row 7 "double, known data"; manufacturer U-value and
       g-value lodged via WindowTransmissionDetails override the
       cascade's defaults — grouped under code 3 with other unknown-
       date DG variants for cascade-equivalence on g_L/g⊥)

Per [[feedback-e2e-validation-philosophy]] + [[feedback-zero-error-
strict]]: pin tolerances are abs=1e-4 against U985-0001-000565.pdf
Block 1 line refs (pinned: SAP int + SAP continuous + ECF + total
fuel cost + CO2 + space heating + main 1 fuel + secondary fuel +
hot water + lighting + pumps/fans).

Outcome: 1/11 pin green (`secondary_heating_fuel_kwh_per_yr = 0`);
10 pins are now named calculator-gap residuals to fix in subsequent
slices:

  main_heating_fuel_kwh_per_yr  +27,665.01 kWh/yr  (heat-pump SAP code
      224 + gas combi via WHC 914 "from second main"; cascade probably
      runs ASHP for DHW instead of routing through gas combi)
  hot_water_kwh_per_yr             +164.88 kWh/yr  (FGHRS / solar HW /
      Table 3a no-keep-hot for the gas combi DHW path)
  lighting_kwh_per_yr              -236.19 kWh/yr  (RdSAP §12-1 bulb-
      count cascade; 27 total / 7 low-energy / 20 incandescent lodged)
  pumps_fans_kwh_per_yr            -122.52 kWh/yr  (cascade defaults
      to 130; expected 252.52 = MEV PCDF 500755 + flue + solar pump)

Cohort regression check: 472 pass + 10 expected 000565 failures.
Pyright net-zero (32 errors before, 32 after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
dae17a6d39 docs: extend handover with Elmhurst-only path + 000565 extended test case
User clarified end-of-session: mapper is a thin enum-and-shape
translation; when residuals remain after closing mapper coverage
gaps, the gap is in the **calculator cascade**. This unlocks an
Elmhurst-only fixture path that doesn't need API JSON at all.

The fixture shape mirrors the 6 historical Elmhurst U985 fixtures
(000474, 000477, 000480, 000487, 000490, 000516) at
`domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_NNNNNN.py`
+ `test_e2e_elmhurst_sap_score.py`:

  build_epc() → cert_to_inputs → calculate_sap_from_inputs
  ↳ every SapResult field pinned at abs=1e-4 against U985 line refs

Any failing pin is definitionally a calculator bug. The user generates
certs in Elmhurst SAP and exports Summary + worksheet ZIPs — no gov.uk
EPB lodgement required.

Extended test case (000565) ready at `sap worksheets/extended test case/`:
- Summary_000565.pdf (input)
- U985-0001-000565.pdf (worksheet ground-truth)

Cert 000565 is a wacky stress-test that exercises 3-4 zero-coverage
cascade paths in one cert: Main + 4 extensions, age mix A through J,
RR on every part with mixed ages, conservatory with fixed heaters,
curtain-wall Ext2 post-2023, mixed wall types (solid brick + stone +
curtain wall), mixed party walls (CU + CF + Unable to determine).

After this cert lands, the user has agreed to generate single-feature
certs (oil only, LPG only, solid fuel only, electric direct only,
multi-main-heating, basement) to surface single-cause calculator gaps.

Handover doc now has implementation outline (mirror
_elmhurst_worksheet_000474.py shape) and a coverage-paths table
showing which targets each fuel-type/config exercises.
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
33293b0942 docs: handover after S0380.47..S0380.51 — golden coverage state
Captures the per-cert validation state at HEAD b7fbbcca:

- 5 slices shipped this session: cost cascade β-split (.47), schema
  gap closure for real-API battery_capacity (.48), Table 12e
  effective-monthly PE factor for PV (.49), §4 seasonal HW for PV β
  cascade (.50), UnmappedApiCode strict-raise pattern on API mapper
  (.51).
- 769 pass + 0 fail across the full baseline; pyright net-zero on
  every touched file.

Crucial finding for the next agent: cohort-2 (38 certs) is chain-
tested at 1e-4 SAP vs worksheet but NOT in test_golden_fixtures.py
— PE/CO2 cascades have NO regression guard. Probed at HEAD:
14/38 cohort-2 certs have non-trivial PE residuals invisible to any
current test, including cert 2102 at +20.4 PE / -0.79 CO2 (single
worst undetected residual in the cohort).

Agreed next slice: add all 38 cohort-2 certs to
test_golden_fixtures.py with current PE/CO2 pinned. Surfaces cert
2102 as the next closure target (worksheet exists under
`sap worksheets/`) and creates PE/CO2 regression guards across the
worksheet-backed cohort.

Open threads ranked by tractability:
- Cert 2102 +20.4 PE — worksheet exists, well-scoped
- PV (233a)+(233b) monthly mystery — documented memory entry; ~0.5
  kWh/m² across ASHP cohort
- _api_glazing_transmission strict-raise extension — mechanical
- 8 open-front golden certs (oil + RR) at high residuals — blocked
  on worksheets

Fuel-type diversity guidance: heating system breakdown across all
60+ fixtures shows 34 gas, 20 ASHP, 2 oil (both open-front no
worksheets), 0 solid fuel, 0 LPG, 0 electric direct. Closure on
oil + solid fuel + LPG + electric blocked on worksheet availability
— the gov.uk EPB downloads UI returns API JSON only; dr87 worksheets
come from the assessor's tool (typically Elmhurst SAP) export ZIP.

Handover doc at docs/HANDOVER_GOLDEN_COVERAGE.md.
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
7496ad024b Slice S0380.50: §4 seasonal monthly HW fuel for PV β cascade
The PV β-factor cascade was prorating the annual hot-water fuel kWh
uniformly by days when feeding D_PV,m per Appendix M1 footnote 32.
The worksheet uses §4 (219)m = (62)m / efficiency monthly — which is
seasonal (peaks in Jan when cold-mains-inlet drives energy content,
troughs in Jul/Aug). For cert 0380: worksheet Jul (219) = 68.30 kWh
vs cascade days-prorated 74.60 kWh — over-counted summer D_PV by
~6 kWh/month.

Per Appendix M1 footnote 32: "D_PV,m = ... + E_water,m" where
"E_water,m = (219)_m if water heating fuel code applied in Section
10a of the SAP worksheet is 30". (219)_m is the §4 fuel kWh per
month, not annual / 12.

Fix: scale `wh_result.output_monthly_kwh` to sum to the annual fuel
`hw_kwh` (equivalent to dividing each month by the annual-average
efficiency — exact for single-COP HP water heaters; close enough
for PCDB combi winter/summer-split efficiencies because the annual
total already accounts for the seasonal-efficiency mix). None fall-
back to the legacy days-proration when wh_result is absent
(TFA-missing certs).

Cohort PE residual closure (kWh/m²):

| Cert | Post-S0380.49 | Post-S0380.50 |
|---|---:|---:|
| 0350 | -2.96 | **-2.90** |
| 0380 | -3.06 | **-2.96** |
| 2225 | -3.73 | **-3.54** |
| 2636 | -3.44 | **-3.28** |
| 3800 | -3.25 | **-3.16** |
| 9285 | -2.81 | **-2.74** |
| 9418 | -3.01 | **-2.89** |

Modest but real cohort closure (~0.1 kWh/m² each). The remaining
~3 kWh/m² traces to a small cascade β over-count (0.751 vs worksheet
0.739) — likely Appendix L monthly-weighting details for appliances/
cooking/electric-shower in D_PV; deferred to a follow-up slice.

Cert 9501 (PV no battery) unchanged at +0.65 PE.
CO2 cohort: <0.11 t/yr (within tolerance, re-pinned in same slice).
SAP scores all exact. 763 pass + 0 fail. Pyright net-zero.
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
fb1e7895fa docs: PV β-split phase COMPLETE handover (6/6 slices)
Finalises the handover doc after S0380.49 ships the effective-monthly
Table 12e PE factor for the PV split. Full cohort residual trajectory
table across all four milestones (pre-44 / post-45 / post-48 /
post-49), final cross-cascade architecture diagram, and the punch-list
of open work (β fine-tuning, HP electricity demand, monthly E_PV
distribution) — none in the β-split phase scope, each a candidate
follow-up slice.

Cluster PE residual closed by ~50% magnitude over the phase:
-7..-14 → -2.8..-3.7 kWh/m². CO2 all <0.11 t/yr; SAP all exact.
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
10d1decdf7 Slice S0380.49: effective-monthly Table 12e PE factor for PV split per SAP 10.2 Appendix M1 §8
The PE cascade was crediting the PV split at annual Table 12 factors
(IMPORT 1.501 / EXPORT 0.501) instead of the spec-correct effective
monthly Table 12e factors. Per Appendix M1 §8 (p.94): "For calculation
of primary energy, for electricity used within the dwelling apply the
normal import PE factors for the relevant tariff from Table 12e. For
the electricity exported, apply the factors for 'electricity sold to
grid, PV', also from table 12e."

Cert 0380 worksheet (page 5) lodges 1.4960 / 0.4268 — the effective
monthly values weighted by E_PV,dw,m / E_PV,ex,m. The cascade now
computes the same via `_effective_monthly_pe_factor` (the helper
already in place for secondary heating, pumps+fans, lighting,
electric showers).

Two new Optional fields on `CalculatorInputs`:
- `pv_dwelling_primary_factor` — falls back to `other_primary_factor`
- `pv_exported_primary_factor` — falls back to `pv_export_primary_factor`

Both populated in `cert_to_inputs.py` via `_effective_monthly_pe_
factor(pv_split.epv_*_monthly_kwh, fuel_code)` — code 30 (standard
electricity) for dwelling, code 60 (electricity sold to grid, PV)
for exported. Mirrors the existing CO2 cascade shape exactly.

Cohort PE residual closure (kWh/m²):

| Cert | Post-S0380.48 | Post-S0380.49 |
|---|---:|---:|
| 0350 | -3.58 | **-2.96** |
| 0380 | -4.01 | **-3.06** |
| 2225 | -4.50 | **-3.73** |
| 2636 | -4.14 | **-3.44** |
| 3800 | -4.01 | **-3.25** |
| 9285 | -3.46 | **-2.81** |
| 9418 | -3.76 | **-3.01** |
| 2130 (PV gas) | -9.70 | **-8.22** |

7-cert ASHP+battery cluster closed by 0.6-0.8 kWh/m² each (matches
the +0.074 differential between annual 0.501 and worksheet 0.4268
applied to E_PV,ex ≈ 640 kWh/yr / TFA 60.43 = 0.78 kWh/m²). The
remaining -3 kWh/m² residual is β fine-tuning (cascade 0.751 vs
worksheet 0.7426 — small monthly D_PV distribution detail).

Cert 9501 (PV no battery) drifted +0.25 → +0.65 PE — known shape
change from the factor correction; β=0.498 matches worksheet
exactly so the drift uncovers a different small gap previously
masked by the wrong factors. Still well within tolerance.

CO2 + SAP unchanged. Pyright net-zero on touched files (34 errors
before, 34 after — all pre-existing).
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
a578f0a4ca docs: refresh HANDOVER_PV_BETA_SPLIT after S0380.44..S0380.48 (5/6 shipped)
Updates the PV β-split handover doc after the three new slices land:
- S0380.47 cost cascade wiring (zero cohort impact via Table 32 collapse)
- S0380.48 real-API battery_capacity schema gap (cohort PE +2.7..+8.1
  → -3.5..-4.5)
- Restates the open slice (S0380.49) as wiring effective-monthly
  Table 12e PE factor into the PV cascade — the remaining ~4 kWh/m²
  PE delta is structural (currently uses annual factors instead of
  monthly-weighted).

Key narrative correction: the prior handover's "E_PV magnitude bug"
hypothesis ("cascade thinks 2570 kWh/yr vs worksheet 831") was wrong.
Reading the cert 0380 worksheet PDF directly (dr87-0001-000899.pdf
page 3 line 233) shows -2563.3692 kWh/yr — matching our cascade
exactly. The real bug was the schema dropping flat-shape
battery_capacity, fixed in S0380.48. Lesson captured in the doc:
verify handover-cited numerics against the source PDF before
implementing the prescribed fix (same discipline as spec-floor
skepticism applied to handover claims).

Includes the full PE residual cohort table across all three milestones
(pre-44 / post-45 / post-48) and the Slice 6 implementation outline.
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
2805e13d4d Slice S0380.48: surface real-API pv_batteries[].battery_capacity (5 kWh)
The 7-cert ASHP+battery PE cluster was overshooting by +2.7..+8.1 kWh/m²
after the PE β-split landed in S0380.45. The handover hypothesised an
E_PV magnitude bug ("cascade thinks 2570 kWh/yr vs worksheet 831"). The
worksheet PDF for cert 0380 (dr87-0001-000899.pdf line 233) was
verified to show **-2563.3692** kWh/yr — matching our cascade. The
real bug was different: the **5-kWh battery wasn't reaching the
cascade**, so β-coefficients used the no-battery branch (C1=1.61,
β≈0.36) instead of the 5-kWh branch (C1=1.12, β≈0.75).

Per SAP 10.2 Appendix M1 §3c-d (p.94): "C_bat is the usable capacity
of the battery in kWh, limited to a maximum value of 15 kWh. C_bat=0
if no battery present." Cert 0380 lodges `pv_battery_count: 1` and
`pv_batteries: [{"battery_capacity": 5}]` — but the schema's
`PvBatteries` dataclass had only `pv_battery: Optional[PvBattery]`,
matching the older synthetic fixture shape (nested
`{"pv_battery": {"battery_capacity": 5}}`). The real-API payload's
flat `battery_capacity: 5` was silently dropped during `from_dict`.

Two surgical changes:
- `datatypes/epc/schema/rdsap_schema_21_0_1.py`: add
  `battery_capacity: Optional[float] = None` as a sibling to
  `pv_battery` on `PvBatteries`. Synthetic-shape certs continue to
  populate the nested form; real-API certs now populate the flat form.
- `datatypes/epc/domain/mapper.py:_first_pv_battery`: prefer nested
  when present, fall back to the flat lifted field. Domain still
  exposes a single uniform `PvBatteries(pv_battery=PvBattery(...))`
  shape downstream.

Cohort impact (PE residual kWh/m² vs worksheet):

| Cert | Pre-S0380.48 | Post-S0380.48 |
|---|---:|---:|
| 0350 | +2.73 | -3.58 |
| 0380 | +8.09 | -4.01 |
| 2225 | +4.48 | -4.50 |
| 2636 | +3.42 | -4.14 |
| 3800 | +3.58 | -4.01 |
| 9285 | +3.20 | -3.46 |
| 9418 | +4.67 | -3.76 |

Cluster magnitude dropped from +2.7..+8.1 to -3.5..-4.5 — the cascade
now over-credits PV by ~4 kWh/m² (vs previously under-crediting by
~5 kWh/m²). The residual flipped sign because cascade β=0.75-0.81
slightly exceeds worksheet β=0.74 (read from page-3 line 233a/233b
ratio 1903.39/2563.37 = 0.7426). The remaining ~4 kWh/m² under-shoot
traces to two structural factors deferred until a fresh closure
slice ships:

1. The synthetic-default `pv_export_primary_factor = 0.501` is the
   annual Table 12 code-60 value. The worksheet uses the effective
   monthly Table 12e factor weighted by E_PV,ex,m (cert 0380: 0.4268
   = -0.074 differential). The cascade's `_effective_monthly_pe_
   factor` already computes the same weighting for PV — but the
   calculator's PV PE credit reads `inputs.other_primary_factor`
   (=1.501) and `inputs.pv_export_primary_factor` (=0.501) directly,
   bypassing the per-end-use effective-monthly cascade.
2. Cascade β slightly higher than worksheet (0.751 vs 0.7426 on
   cert 0380) — likely a monthly-distribution detail in D_PV.

SAP scores remain exact across the cohort (residual +0 every cert).
CO2 residuals all <0.11 t/yr (well within the 0.001-tolerance pin
range after re-pin). 9501 (PV no battery) preserved at +0.255 PE /
-0.047 CO2 — no regression. Re-pins all 7 golden fixtures in the
same slice per [[feedback-commit-per-slice]].

Pyright net-zero on touched files (32 errors before, 32 after).
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
887a384477 Slice S0380.47: wire β-split into cost cascade per SAP 10.2 Appendix M1 §6
SAP 10.2 Appendix M1 §6 (p.94): "When calculating the fuel cost
benefits ... apply the normal import electricity price to PV energy
used within the dwelling and the 'electricity sold to grid, PV' price
from Table 12 to the energy exported."

Adds the third leg of the β-factor split (PE was S0380.45, CO2 was
S0380.46). Now uniform across all three cascades:
  PE   → IMPORT PEF × E_dw + EXPORT PEF × E_ex
  CO2  → IMPORT CO2 × E_dw + EXPORT CO2 × E_ex
  Cost → IMPORT £   × E_dw + EXPORT £   × E_ex

Mechanism:
- `worksheet/fuel_cost.py`: optional `pv_dwelling_kwh_per_yr` +
  `pv_exported_kwh_per_yr` + `pv_dwelling_import_price_gbp_per_kwh`
  keyword args; when all three are set, split the credit; otherwise
  fall back to legacy single-rate-EXPORT (preserves synthetic test
  constructions).
- `rdsap/cert_to_inputs.py`: new `_pv_dwelling_import_price_gbp_per_kwh`
  helper that pulls Table 32 code 30 (standard electricity = 13.19
  p/kWh) for standard tariff; off-peak branch uses
  `prices.e7_low_rate_p_per_kwh` as the natural extension point when
  the first off-peak PV cert lands (currently short-circuited by the
  `Tariff != STANDARD` guard at line 2710).
- `calculator.py`: new `pv_dwelling_import_price_gbp_per_kwh` field on
  `CalculatorInputs` with synthetic-fallback split logic mirroring the
  precomputed-fuel_cost path. Maintains the cross-cascade architecture
  documented in the prior handover.

Cohort impact: **none**. Per ADR-0010 RdSAP10 amendment, Table 32
collapses code 30 (standard electricity import) and code 60
(electricity sold to grid, PV) to the SAME 13.19 p/kWh rate. So the
β-split's E_dw × 13.19 + E_ex × 13.19 == E_total × 13.19, matching the
legacy single-rate credit at 1e-4 — 763 pass + 0 fail across the
full chain test suite (Elmhurst U985, cohort-1 ASHP, cohort-2 38-cert
sweep, 15-cert golden fixtures). The β-split shape is now in place
for the off-peak case (where weighted Table 12a high/low rates would
diverge) and any future amendment that splits import/export prices.

Pyright net-zero on touched files (34 errors before, 34 after — all
pre-existing).
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
0eafea9c80 docs: refresh NEXT_AGENT_PROMPT for PV β-split slices 4-6
Replaces stale legacy content (cert-mapper-validation workflow, dated
to a 9-triple staging slice) with the current handoff: branch state,
3 shipped slices (S0380.44 → S0380.46), and concrete directives for
the 3 remaining slices (cost cascade wiring, E_PV magnitude audit,
final fixture re-pin).

Companion to docs/HANDOVER_PV_BETA_SPLIT.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
29039786d5 docs: handover after S0380.44..S0380.46 — PV β-split 3/6 wiring slices shipped
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 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
fe99e518be Slice S0380.46: wire β-split into CO2 cascade per SAP 10.2 Appendix M1 §7
The CO2 cascade in calculator.py had no PV credit at all
(environmental_section_from_cert had a stale `pv_credit = 0.0` with
the comment "no PV in any Elmhurst fixture", but that helper isn't
called by `calculate_sap_from_inputs` anyway). The full ASHP+PV
cluster therefore over-counted CO2 by +0.16..+0.28 t/yr — the entire
PV CO2 offset was missing.

Wiring (calculator.py):
  - New fields: `pv_dwelling_co2_factor_kg_per_kwh: Optional[float]`,
    `pv_exported_co2_factor_kg_per_kwh: Optional[float]`.
  - CO2 cascade now subtracts:
      pv_co2_credit = E_PV,dw × dwelling_CO2_factor
                    + E_PV,ex × exported_CO2_factor
    when the split + factors are set. None preserves the legacy
    zero-credit behaviour for synthetic CalculatorInputs constructions.

Wiring (cert_to_inputs.py):
  - New constant: `_PV_EXPORT_FUEL_CODE_TABLE_12 = 60` (SAP 10.2
    Table 12 code 60, "electricity sold to grid, PV") — the EXPORT
    factor key per Appendix M1 §6/§7/§8.
  - The dwelling CO2 factor is the effective monthly Table 12d Σ
    weighted by E_PV,dw,m at code 30 (Standard electricity); the
    exported CO2 factor is the same Σ weighted by E_PV,ex,m at
    code 60 ("Electricity sold to grid, PV"). Both reuse the
    existing `_effective_monthly_co2_factor` helper.

Test impact (CO2 residual cluster, re-pinned in this slice):

  Pre-Slice 46 → Post-Slice 46:
    - 0330 (no PV):                  -0.034 → -0.034   (unchanged ✓)
    - 0350 (PV + 5 kWh battery):     +0.171 → -0.084
    - 0380 (PV + 5 kWh battery):     +0.279 → -0.054
    - 2130 (PV + gas combi):         +0.299 → -0.046
    - 2225 (PV + 5 kWh battery):     +0.263 → -0.071
    - 2636 (PV + 5 kWh battery):     +0.219 → -0.058
    - 3800 (PV + 5 kWh battery):     +0.261 → -0.014
    - 9285 (PV + 5 kWh battery):     +0.157 → -0.098
    - 9418 (PV + 5 kWh battery):     +0.232 → -0.046
    - 9501 (PV, no battery):         +0.202 → -0.047

  Cluster magnitude dropped 3-5× — over-count flipped to slight
  under-count (-0.01..-0.10 vs +0.16..+0.28). The remaining negative
  residual is largely the same E_PV-magnitude bug from Slice 45 (PV
  is over-credited because the cascade thinks E_PV ≈ 3× the worksheet
  value for the 5-kWh-battery cohort). Slice 47 (cost cascade) + Slice
  S0380.48 (E_PV magnitude audit) will close the cluster further.

  Chain tests still <1e-4 — CO2 cascade isn't gated by the chain
  tests' SAP-rating-vs-worksheet assertions.

Test suite: 763 pass + 0 fail. Pyright net-zero per touched file
(calculator.py 0/0; cert_to_inputs.py 34/34; test_golden_fixtures.py 1/1).

Spec citations:
  - SAP 10.2 specification Appendix M1 §7 (p.94) — PV CO2 credit split.
  - SAP 10.2 Table 12d (p.194) code 60 — monthly CO2 factor for
    "electricity sold to grid, PV" (already in `tables/table_12.py`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
c7f38de984 Slice S0380.45: wire β-split into PE cascade per SAP 10.2 Appendix M1 §8
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 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
a14839af62 Slice S0380.44: SAP 10.2 Appendix M1 §3-4 PV β-factor calculator (no wiring)
Pure-function module + 13 unit tests for the photovoltaic onsite/export
split. No cascade wiring yet — Slices S0380.45..47 will wire β into the
PE / CO2 / cost cascades respectively (which currently all over-credit
the exported PV portion at the IMPORT factor).

Module: `domain/sap10_calculator/worksheet/photovoltaic.py`
  - `PhotovoltaicSplit` frozen dataclass — monthly β + (E_PV,dw,m,
    E_PV,ex,m) with annual-sum properties matching worksheet line
    refs (233a) and (233b).
  - `pv_beta_coefficients(Cbat)` — three coefficients keyed on battery
    capacity (kWh), capped at 15 per §3c:
      CPV1 = 1.610 - 0.0973 × Cbat
      CPV2 = 0.415 - 0.00776 × Cbat
      CPV3 = 0.511 + 0.0866 × Cbat
  - `pv_split_monthly(epv, dpv, battery_kwh)` — per §3d-4:
      R_PV,m = E_PV,m / D_PV,m
      β_m = min(exp(-CPV1 × (R_PV,m × CPV2)^CPV3), D_PV,m / E_PV,m)
      E_PV,dw,m = E_PV,m × β_m;  E_PV,ex,m = E_PV,m × (1 - β_m)

Edge cases (not in spec but implied by physics):
  - E_PV,m = 0 → β = 0; both onsite and exported = 0
  - D_PV,m = 0 → cap forces β = 0; all PV exports

Unit-test coverage (13 tests, AAA convention, `abs(diff) <= tol`):
  - β coefficient constants at Cbat=0, 5 (ASHP cohort), 15 (cap)
  - Cbat>15 clamps to 15; Cbat<0 clamps to 0 (defensive)
  - Hand-computed β worked example (no battery): β≈0.4864 at E_PV=100,
    D_PV=200 — pinned at 1e-7 against precomputed value AND at 1e-9
    against the live formula recomputation (load-bearing math pin)
  - Edge cases: E_PV=0 → no split; D_PV=0 → full export
  - Battery monotonicity: β increases with Cbat for fixed (E_PV, D_PV)
  - Energy conservation: E_PV,dw + E_PV,ex = E_PV per month + annually
  - Tuple length validation (raises on != 12 months)
  - Return shape pinned to `PhotovoltaicSplit` dataclass contract

Test suite: 750 → 763 pass + 0 fail. Pyright net-zero on new files.

Spec citation: SAP 10.2 specification Appendix M1 §3-4 (p.93-94).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
03cad50cb5 docs: handover after S0380.39..S0380.43 — cohort-2 API path 38/38 closed
Session shipped 5 slices that closed the entire cohort-2 API-path
cluster (S0380.39 bulk-fetch, S0380.40 parametrized test, S0380.41
RdSAP 21 → SAP 10.2 glazing alias, S0380.42 Decimal HALF_UP per-window
areas, S0380.43 SAP 631 → spec fuel).

Documents:
  - Cross-mapper parity at cascade established for all 38 cohort-2
    certs (and 9 cohort-1 ASHP); both paths < 1e-4 vs worksheet.
  - Tolerance tightening deferred — 1e-4 is the realistic floor at
    HEAD (worst residual 4.91e-5 on cert 2102).
  - Lessons learned: GOV.UK RdSAP 21 enum != cascade enum (codes
    needing remap are incremental as fixtures surface them);
    Decimal HALF_UP per-window areas extends the S0380.34/35
    pattern; SAP heating-type → spec fuel dispatch is the new
    forcing-function pattern for cert-lodgement inconsistencies.
  - Open front: golden-residuals → ~0 on PE/CO2. ASHP cluster
    (-7..-15 kWh/m² PE / +0.16..+0.28 t/yr CO2 across 7 certs with
    the same PCDB heat pump) is the highest-value single thread —
    likely SAP 10.2 Appendix L1 / Table 12 PE-factor or CO2-factor
    cascade gap. Three concrete diagnostic probes proposed.

Test baseline at HEAD: 750 pass + 0 fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
ae2303b775 Slice S0380.42: Decimal HALF_UP per-window areas per RdSAP10 §15 — closes cert 1536
Cert 1536 lodged window dimensions including (0.65 × 0.70) × 3
windows. In float arithmetic 0.65 × 0.70 = 0.45499999999999996,
which the `_round_half_up(float, dp)` helper snaps to 0.45 vs the
spec answer 0.46 (Decimal: 0.65 × 0.70 = 0.4550 exact, HALF_UP at
2 d.p. = 0.46). The shortfall of 0.01 m² × 3 windows = 0.03 m²
under-counted as ~0.073 W/K of conduction loss vs the worksheet's
windows_w_per_k = 25.6354 — closing the cert 1536 residual at
+0.00152 to <2e-6.

Same class of bug as the S0380.34/35 living-area / gross-wall /
party-wall closures (Decimal HALF_UP at the 0.005 boundary that
float drops). RdSAP10 §15 (p.66) lists "all element areas (gross)
including window areas: 2 d.p." — Decimal is the only arithmetic
that matches that boundary deterministically.

Three cascade sites now use Decimal HALF_UP for per-window areas:

- heat_transmission.py: `_decimal_round_half_up_product(W, H, 2)`
  replaces `_round_half_up(W × H, 2)` at the windows_w_per_k cascade
  AND at the per-bp window-area accumulation (the wall-net deduction
  branch must agree with the conduction branch for cascade-internal
  consistency, per the existing comment at line 575-583).

- internal_gains.py: `_decimal_window_area_2dp(W, H)` replaces the
  inline `_round_area_2dp(W × H)` in the daylight factor `g_l`
  sum so §5 (66)..(67) sees the same per-window areas as §3 (27).

- solar_gains.py: same Decimal helper replaces `_round_area_2dp` in
  `_wall_window_solar_gain_monthly_w` so §6 (74)..(81) area = (27).

The `_round_area_2dp` helpers were inlined per-module in pre-S0380.42
work; this slice deletes them since the Decimal-aware product
replaces all call sites. `_round_half_up` stays in heat_transmission
for non-product per-element area calls (single-value rounds).

Test impact:
  - Cohort-2 cert 1536 API path: +0.00152 → -1e-6 (<1e-4 ✓).
    Moves from _COHORT_2_API_OPEN to _COHORT_2_API_CLOSED. Cohort
    distribution: 37/38 exact (was 34/38 at start of session);
    only cert 2102 (-6.30 secondary-heating routing) remains open.
  - Cohort-2 cert 0300/9380 unchanged (already <1e-4 after S0380.41).
  - Cohort-1 ASHP 9/9 unchanged: <1e-4 on both paths.
  - Elmhurst 6-cert worksheet sweep: unchanged (lodges
    `window_width=area, window_height=1.0` per the Elmhurst lodging
    convention — Decimal(area) × Decimal(1.0) = Decimal(area), no
    rounding shift).

Test suite: 750 pass + 0 fail. Pyright net-zero per touched file
(heat_transmission 13/13; internal_gains 4/4 pre-existing; solar_gains
0/0; chain test 0/0).

Spec citation: RdSAP 10 Specification §15 "Rounding of data" p.66 —
"All element areas (gross) including window areas and conservatory
wall area: 2 d.p." Decimal is the float-precision-stable arithmetic
that matches this rule at the .005 boundary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
441ea8ecf1 Slice S0380.39: bulk-fetch 38 cohort-2 EPC API JSONs for cross-mapper parity
Adds scripts/fetch_cohort2_api_jsons.py (throwaway one-off) plus 38
golden fixtures under domain/sap10_calculator/rdsap/tests/fixtures/golden/
covering every cert in "sap worksheets/additional with api 2/".

Each JSON is the inner `data` payload from the gov.uk EPB
/api/certificate endpoint — the same shape EpcPropertyDataMapper
.from_api_response consumes today.

Required prerequisite for Slice B (parametrized API-path chain test
that mirrors the cohort-2 Summary-path sweep at 1e-4 vs worksheet).
Per the cross-mapper-parity primitive: API EPC and Elmhurst EPC must
produce SAP within 1e-4 of each other and of the worksheet — the SAP
cascade is the load-bearing equivalence check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
18aea8bdde docs: handover after S0380.31..S0380.38 — cohort-2 Summary path COMPLETE, thread 4 next
State at HEAD 883d66ac:
  * Cohort-2 Summary path: 38/38 < 1e-4 (was 33 exact + 5 <=0.07)
  * Cohort-1 ASHP: 9/9 < 1e-4 both paths (was 8/9 with cert 2636 at -0.015)
  * Test suite: 712 pass + 0 fails (was 710 + 10 at handover start)
  * _ASHP_COHORT_CHAIN_TOLERANCE: 0.04 -> 1e-4

Eight slices shipped:
  S0380.31: alt-wall window deduction from (31) per SAP 10.2 K2
            -> cert 2636 cantilever -0.015 -> -2.4e-6 both paths
  S0380.32: bare "Extension" window routing per RdSAP10 §3
            -> cert 9380 +0.027 -> -4.8e-6
  S0380.33: PV kWp 2 d.p. per RdSAP10 §15
            -> cert 6835 +0.015 -> -4.3e-5
  S0380.34: living area Decimal HALF_UP per RdSAP10 §15
            -> cert 2536 +0.0007 -> -9e-8
  S0380.35: gross-wall / party-wall Decimal HALF_UP per RdSAP10 §15
            -> certs 2800 / 4800 +0.0007 -> <3e-5
  S0380.36: tighten _ASHP_COHORT_CHAIN_TOLERANCE 0.04 -> 1e-4
  S0380.37: drop redundant cert 001479 hand-built fixture
  S0380.38: loosen FEE round-trip tolerance 1e-9 -> 1e-6

Pattern emerged: three slices (S0380.33/34/35) closed the same class of
bug -- RdSAP10 §15 "2 d.p." float-arithmetic boundary failures fixed by
Decimal HALF_UP. Documented in the handover as the most likely root cause
for any future +0.0007-ish residual.

User-stated next phase (thread 4): cohort-2 API-path closure via cross-
mapper parity, in bigger slices, with golden-residuals driven toward
zero. Concrete slice plan in the handover doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:47 +00:00
Khalim Conn-Kowlessar
92b0db9f16 Slice S0380.38: loosen FEE round-trip tolerance 1e-9 -> 1e-6
test_no_ac_cert_round_trips_fee_equals_space_heating_per_m2 encodes
a real SAP 10.2 invariant: when (108) = 0 (no fixed AC) and Appendix
H solar is absent (every cohort cert), (109) FEE must equal
space_heating_kwh / TFA.

The 1e-9 tolerance was too tight. The cascade computes:
  - FEE: sum_round_per_month(annual_98a) / TFA
  - space_heating_kwh: sum(monthly_98a_kwh) summed in calculator
The two paths sum the same 12 monthlies in different rounding
orders and disagree at ~8e-8 (cascade FEE = 95.39072333333334;
SH/TFA = 95.39072341347577).

1e-6 is two orders of magnitude tighter than any meaningful path
divergence (a stray 4-d.p. rounding step or unintended AC
contribution would blow past instantly) and ~12.5x looser than the
observed float-arithmetic drift, so the invariant still fires.

Also swaps pytest.approx for `abs(a - b) <= tol` per
[[feedback-abs-diff-over-pytest-approx]] (strict-pyright flags
pytest.approx as partially-unknown; nets -1 error on the file).

Test baseline: 712 pass + 0 fails (was 712 + 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
a774188680 Slice S0380.37: drop cert 001479 hand-built fixture — covered by passing production-path chain tests
Cert 001479 was added in ee98dbe0 as "skeleton + 11 RED pins" — a
hand-built EpcPropertyData intended to cascade to worksheet
P960-0001-001479.pdf at 1e-4 for 9 SapResult fields. The skeleton
was never finished; the 9 _FIXTURE_PINS pin-checks have been red
the entire time (at HEAD: sap_score 65 vs 69, space_heating
9715 vs 8104 kWh, etc.).

Meanwhile the production-path chain tests for the same cert have
landed at 1e-4 vs the worksheet's continuous SAP 69.0094 and are
GREEN at HEAD:
  - test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly
    (Summary PDF -> extractor -> mapper -> calc, 1e-4 vs worksheet)
  - test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly
    (API JSON -> mapper -> calc, 1e-4 vs worksheet)
  - 5 test_summary_001479_*_<detail> mapper-shape unit tests

These exercise the actual from_elmhurst_site_notes /
from_api_response code paths the production runtime uses, which
is strictly stronger coverage than a hand-built mirror.

Drops 001479 from _FIXTURE_PINS / _FIXTURE_MODULES and deletes the
stub _elmhurst_worksheet_001479.py. Also fixes the stale "Slice
62 iteration" reference in test_summary_pdf_mapper_chain.py.

Test baseline: 9 fewer fails (10 -> 1; remaining FEE-round-trip
1e-9 noise to be fixed in S0380.38).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
4b22f56d45 Slice S0380.35: round gross-wall and party-wall areas in Decimal arithmetic per RdSAP10 §15 — closes cohort-2 cert 2800 / 4800 +0.0007 SAP residuals
RdSAP10 §15 p.66 (Rounding of data):
    "All element areas (gross) including window areas and
     conservatory wall area: 2 d.p."

Certs 2800 and 4800 lodge heat_loss_perimeter = 21.25 m and
room_height = 2.30 m. The exact-decimal products
    21.25 * 2.30 = 48.8750 (gross wall area)
     6.25 * 2.30 = 14.3750 (party wall area)
sit ON the HALF_UP rounding boundary and must round to 48.88
and 14.38 m^2. Float representation drops them BELOW the
boundary:
    21.25 (float) * 2.30 (float) ~= 48.87499...
                       HALF_UP 2 d.p. = 48.87
     6.25 (float) * 2.30 (float) ~= 14.37499...
                       HALF_UP 2 d.p. = 14.37
The 0.01 m^2 area shortfall feeds into (29a) net wall area and
(32) party wall area, and into (31) total external area for
(36) thermal bridging — propagating a +0.0007 SAP residual via
the U-weighted heat-loss sums.

Adds `_decimal_round_half_up_sum` helper and routes both
gross-wall and party-wall sums through it, mirroring the
S0380.34 fix on `_living_area_fraction`. Certs that sit off
the .005 boundary (i.e. nearly all) are unaffected; certs
that land on it close from +0.0007 → <5e-5.

Cohort-2 distribution after S0380.31..S0380.35:
    38 exact (was 36 exact + 2 <=0.07).
Cohort-1 ASHP cohort: 9/9 <1e-4 (unchanged).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
61c215bf1f Slice S0380.34: round living area in Decimal arithmetic per RdSAP10 §15 — closes cert 2536 +0.0007 SAP residual
RdSAP10 §15 p.66 (Rounding of data):
    "All internal floor areas and living area: 2 d.p."

Cert 2536 (3 habitable rooms → Table 27 fraction 0.30,
TFA 45.65 m^2) sits ON the HALF_UP rounding boundary:
    0.30 (exact) * 45.65 = 13.6950
    HALF_UP 2 d.p.        = 13.70
                            (worksheet fLA = 13.70 / 45.65 = 0.3001)

Float arithmetic drops the spec product BELOW the boundary:
    0.30 (binary) ~= 0.2999999...
    product ~= 13.69499...
    HALF_UP 2 d.p. = 13.69
                     (cascade fLA = 13.69 / 45.65 = 0.29989)

The 0.00021 fLA shortfall feeds straight into the worksheet
(91) -> (92) MIT blend, undershoots MIT by ~0.001 C, and
shaves 0.29 kWh off (98c) useful space heating — a +0.0007
SAP residual via the (211) main heating fuel x p/kWh.

Compute the product in Decimal so HALF_UP lands on the exact
.005 decimal boundary the spec defines. Certs that sit off the
boundary (e.g. 2800/4800: 0.30 x 46.87 = 14.0610 -> 14.06 in
both Decimal and float) are unaffected.

Cohort-2 distribution after S0380.31..S0380.34:
    36 exact + 2 <=0.07 (was 35 exact + 3 <=0.07).
Cert 2536: +0.000715 -> -9.2e-8.

The remaining 2800 / 4800 +0.0007 residuals come from a
different cause (off the HALF_UP boundary) — defer to a
separate slice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
1b12f995f8 Slice S0380.33: round synthesized PV kWp to 2 d.p. per RdSAP10 §15 — closes cert 6835 +0.015 SAP residual
RdSAP10 §15 p.66 (Rounding of data):
    "kWp for photovoltaics, etc.: 2 d.p."

Cert 6835 lodges Photovoltaic Supply as "Proportion of roof
area = 40" (no explicit kWp). Per RdSAP10 §11.1 b) p.60 the
cascade synthesizes kWp = 0.12 × PV area where PV area is
roof_area / cos(35°). For cert 6835:
    PV area = 36.9 × 0.40 / cos(35°) = 18.0186 m^2
    kWp unrounded = 0.12 x 18.0186  = 2.16224
    kWp at 2 d.p. = 2.16             (matches worksheet
                                       "Cells Peak = 2.16")

SAP 10.2 §M1 EPV = 0.8 x kWp x S x ZPV. With the 0.0022 kWp
delta the cascade was overstating PV generation by 1.5448 kWh/yr,
adding -0.20 GBP to (252) total PV credit, dropping (255) total
energy cost by 0.20, lowering ECF and raising SAP by +0.015.

Cohort-2 distribution after S0380.31..S0380.33:
    35 exact + 3 <=0.07 (was 34 + 4 at S0380.32 HEAD).
Cert 6835: +0.014534 -> -4.3e-5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
1c53451373 Slice S0380.32: route bare \"Extension\" window location to BP[1] per RdSAP10 §3 — closes cert 9380 +0.027 residual
RdSAP10 §3 p.17:
    "When specifying windows and doors, for each building part
     assessor allocates windows and doors to the corresponding
     wall (the appropriate main wall or each alternative wall).
     For each building part, software will deduct window/door
     areas contained in the relevant wall areas."

SAP 10.2 §3 p.16:
    "Wall area is the net area of walls after subtracting the
     area of windows and doors."

Cert 9380's Summary PDF lodges 2 windows on its single extension,
but pdftotext wraps "1st" onto a preceding layout line while
"Extension" lands on a separate line — the Elmhurst extractor
captures only the second token. `_window_bp_index` previously
matched "main" / "1st"-"4th" prefixes but fell through bare
"Extension" to BP[0] (main), causing the cascade to deduct ext1
windows from the main wall:
    Worksheet (29a): main 60.60 × 0.70 + ext1 18.25 × 0.53 = 52.0925
    Pre-fix cascade: main 59.01 × 0.70 + ext1 19.84 × 0.53 = 51.8222
                     Δ -0.27 W/K → SAP +0.027

This slice adds bare "extension" (when num_parts >= 2) as a sibling
to the ordinal-prefix matches. Closes cert 9380 +0.027 → -4.8e-6.

Cohort-2 distribution after S0380.31 + S0380.32:
    34 exact + 4 ≤0.07 (was 33 exact + 5 ≤0.07).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
28e5265df2 Slice S0380.31: deduct alt-wall window opening from (31) net external area — closes cert 2636 cantilever residual -0.015 → -2.4e-6
SAP 10.2 Appendix K eqn (K2) p.84:
    HTB = y × Σ(Aexp)
where Aexp is "the total area of external elements calculated at
worksheet (31)". The worksheet (31) column header reads "Total NET
area of external elements" — net of openings.

Cert 2636 (dr87-0001-000898 line 187): (31) = 160.33 m² =
47.70 main net + 11.57 alt net + 42.92 roof + 39.18 ground floor
+ 3.74 cantilever + 11.52 windows + 3.70 doors.

Pre-fix cascade summed the alt-wall at its 12.76 m² gross (no
opening deduction) — (31) was 161.52, driving (36) to 24.228 vs
worksheet 24.0495 (Δ +0.1785 W/K). That drift propagated through
(39) HTC → MIT → space heating, leaving cert 2636 at Δ -0.015
SAP — the only ASHP cohort cert above the 1e-4 floor.

`alt_walls_total_area` aggregates per-alt-wall gross at line 736;
this slice subtracts `alt_window_area` from it in the (31) sum so
the alt-wall contribution is net, matching the (29a) net-area
convention already applied per-element to the A×U sums.

Cohort-1 ASHP cohort: 9/9 certs < 1e-4 Summary path (was 8/9 with
cert 2636 at -0.015). Cert 2636 API path also closes to < 1e-4 —
the bug was path-symmetric in the cascade, not in either mapper.
Cohort-2 unchanged at 33 exact + 5 ≤0.07.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
8c269dbe2d docs: handover after S0380.26-30 precision-floor closure
Documents the 5-slice session that closed the prior handover's
"precision floor" cluster end-to-end:

  S0380.26  RdSAP10 §5.8 dry-lining adjustment (cert 7700)
  S0380.27  floor_construction_type → _main_floor_u_value (cert 9796)
  S0380.28  SAP 10.2 Appendix N fn 43 reciprocal η interpolation
            (closes the +0.03..+0.06 ASHP cluster cohort-wide)
  S0380.29  _ASHP_COHORT_CHAIN_TOLERANCE 0.07 → 0.04
  S0380.30  glazing codes 8-15 (RdSAP 21 schema) — closes API path
            cohort-1 +0.014..+0.031 cluster

Final state:
  Cohort-2 Summary path (38): 33 exact + 5 ≤0.07
  Cohort-1 ASHP cohort (7): 6/7 <1e-4 both Summary + API paths
  cert 2636 -0.015 (cantilever, path-symmetric) — only open thread

The prior `HANDOVER_CERT_0380_MIT_CASCADE.md` had concluded the
+0.04 ASHP cluster was unfixable without Elmhurst access; the
spec citation (SAP 10.2 Appendix N fn 43) was sitting in the same
PDF that handover referenced. Be skeptical of "spec-precision
floor" framing — see [[feedback-spec-floor-skepticism]].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
452749091d Slice S0380.30: extend g_L + g⊥ Table 6b to RdSAP 21 codes 8-15 — closes API path cohort residual cluster
Per the RdSAP 21 schema in [datatypes/epc/domain/epc_codes.csv][1], the
`glazing_type` enum extends to 15 codes; the legacy SAP 10.2 Table 6b
cascade lookups in `internal_gains.py:106` and `solar_gains.py:178`
only knew codes 1-7. Every API-path cert in the cohort lodges
`glazing_type` via the RdSAP 21 numbering, and triple-glazed
lodgements surface as **code 14** ("triple glazing, installed 2022+").

Pre-slice the cascade fell through to the 0.80 / 0.76 double-glazed
defaults for codes 8-15:

  Internal gains g_L (Table 6b):
    code 14 → default 0.80 (DG)        vs spec 0.70 (TG)
    → daylight factor over-bonused → lighting kWh under-counted

  Solar gains g⊥ (Table 6b):
    code 14 → default 0.76 (DG)        vs spec 0.68 (TG)
    → solar gains over-counted

For cert 0350-2968-2650-2796-5255 (semi-detached, 9 triple-glazed
windows lodged as code 14), this drove:
  lighting_kwh_per_yr: cascade 221.79 vs Summary-path 228.44
    (-6.65 kWh/yr — daylight bonus too generous → lighting too low)
  space_heating_kwh_per_yr: cascade 7000.21 vs Summary-path 6996.94
    (+3.28 kWh/yr — extra solar gains lower HP demand)
  net ECF: -0.0022 vs Summary-path → SAP +0.031

Same mechanism on the other 5 cohort-1 ASHP API certs.

Fix: extend both lookup tables with the RdSAP 21 additions per the
schema CSV semantics:

  | code | description (RdSAP 21)          | g_L  | g⊥   |
  |------|----------------------------------|------|------|
  |  8   | triple glazing, known data      | 0.70 | 0.68 |
  |  9   | triple glazing, 2002-2022       | 0.70 | 0.68 |
  | 10   | triple glazing, pre-2002         | 0.70 | 0.68 |
  | 11   | secondary glazing, normal-E      | 0.80 | 0.76 |
  | 12   | secondary glazing, low-E         | 0.80 | 0.76 |
  | 13   | double glazing, 2022+            | 0.80 | 0.76 |
  | 14   | triple glazing, 2022+            | 0.70 | 0.68 |
  | 15   | single glazing, known data       | 0.90 | 0.85 |

Solar gains also adds code 7 (double known data) for
`_G_PERPENDICULAR_BY_GLAZING_TYPE` to align with the existing
`_G_LIGHT_BY_GLAZING_CODE` code-7 entry (which already mapped to
0.80 = double).

Outcome — Cohort-1 ASHP cohort API path:
  cert 0380:  +0.025  →  +1e-6     (close to exact)
  cert 0350:  +0.031  →  +2.2e-5   (close to exact)
  cert 2225:  +0.029  →  -4.8e-5   (close to exact)
  cert 2636:  +0.015  →  -0.015    (sign flip; cantilever-specific
                                    residual surfaces; same |Δ| as Summary)
  cert 3800:  +0.023  →  -2e-5     (close to exact)
  cert 9285:  +0.029  →  -3.4e-5   (close to exact)

5 of 6 API path certs now sit at <1e-4 vs worksheet. Cert 2636
matches its Summary-path residual (-0.015) — the cantilever fixture
has its own non-glazing residual to be diagnosed separately.

Cohort-2 Summary path unchanged (33 exact + 5 ≤0.07) — the cohort-2
certs lodge glazing codes 1-7 (RdSAP 17 numbering still surfaces in
Elmhurst Summary PDF lookups), so codes 8-15 only affect the
RdSAP-21-schema API path.

Golden API fixture pins updated to reflect the tightened cascade-vs-API
alignment (7 certs: 0380, 0350, 2225, 2636, 3800, 9285, 9418). SAP
integer residuals unchanged (all sit at +0).

Pyright net-zero on touched files (22 → 22).

Tests: 710 → **711** pass (+1 new: cert 0350 fixture-shape test for
glazing_type=14 routing to g⊥=0.68 with `total_solar_gains_monthly_w[0]
≈ 67.00 W` (vs pre-slice 74.88 W at the DG default), proving code 14
hits the triple-glazed Table 6b row.) 10 expected fails unchanged.

[1]: datatypes/epc/domain/epc_codes.csv (RdSAP-Schema-21.0.1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
529100187e Slice S0380.28: SAP 10.2 Appendix N footnote 43 reciprocal η interpolation — closes the +0.03..+0.06 ASHP precision-floor cluster
Per SAP 10.2 Appendix N, PDF p.101 footnote 43 (line 7053):

  "For the efficiency values, the interpolated efficiency is the
  reciprocal of linear interpolation between the reciprocals of the
  efficiencies."

i.e. 1/η_interp = (1 − t)·1/η_low + t·1/η_high, the weighted harmonic
mean at t = (PSR − PSR_low) / (PSR_high − PSR_low). Cascade was using
**linear** interpolation directly on η — a +0.15..+0.25% over-estimate
in the typical PSR range (1.2..1.5) for ASHPs in the cohort.

Cohort fixture: cert 3336-2825-9400-0512-8292 (Mitsubishi PUZ-WM50VHA,
PCDB 104568). MIT/η-zone cascade matches worksheet EXACTLY (every line
86..92, every month), but η_main_heating cascade 225.443 vs worksheet
224.923 → main_heating_fuel +5.24 kWh/yr too high → ECF 1.5474 vs ws
1.5503 → SAP +0.04 vs worksheet 78.3739. Back-solving the worksheet's
η_main implies η_space_1 = 224.923 / 0.95 ≈ 236.76.

Closed form at PSR=1.40151, bracketing PCDB rows PSR 1.2
(η_space_1=253.9) and PSR 1.5 (η_space_1=229.2):
  Linear (pre-slice):     253.9 + (229.2 − 253.9) × 0.6717  = 237.31  ✗
  Reciprocal (footnote 43): 1 / ((1 − 0.6717)/253.9 + 0.6717/229.2)
                          = 1 / 0.004224                    = 236.74  ✓

The harmonic mean is curvature-aware: linear interpolation under-
penalises efficiency drops at higher PSR (η typically falls off as
PSR increases past the system's design point) by averaging on η
rather than 1/η. SAP 10.2 footnote 43 is explicit about which side
of the reciprocal the interpolation sits.

Outcome:

Cohort-2 Summary path (38 certs):
  exact (<1e-4): 23 → **33** (+10)
  ≤±0.07:        15 → **5**  (-10: HP certs close to exact)
  ±0.07..0.5:     0 → 0
  ±0.5..1:        0 → 0
  ±1+:            0 → 0
  RAISES:         0 → 0

Cohort-2 HP cluster post-slice:
  0100  +0.00003   ←  was +0.00283
  0320  -0.00001   ←  was +0.01801
  0330  -0.00004   ←  was +0.01772
  2336  +0.00003   ←  was +0.01778
  3336  +0.00001   ←  was +0.04005  (worst residual closes exact)
  4536  -0.00002   ←  was +0.01312
  9036  -0.00003   ←  was +0.02159
  9796  +0.00000   ←  was +0.00174  (post-S0380.27)
  2536  +0.00072   ←  was +0.00163
  2800  +0.00068   ←  was +0.00436
  4800  +0.00068   ←  was +0.02939
  9370  +0.00002   ←  was +0.00174
  9421  +0.00001   ←  was +0.00117

Cohort-1 ASHP cohort (7-cert cohort + new chain test certs):
  cert 0380:  +1e-6   ←  was +0.034 (Mitsubishi PUZ-WM50VHA, the
                                     canonical first-HP cohort cert)
  cert 3800:  -2e-5   ←  was +0.021
  cert 9418:  -3e-7   ←  was +0.00004
  cert 9285:  -3e-5   ←  was +0.021
  cert 2636:  -0.015  ←  was +0.003 (cantilever fixture; remaining
                                     residual is non-η in nature)

5 of 7 cohort-1 ASHP certs now hit delta < 1e-4 vs worksheet — the
+0.04 spec-precision-floor cluster diagnosed in
HANDOVER_CERT_0380_MIT_CASCADE.md is the linear-vs-reciprocal η
interpolation bug, not a spec-floor at all. The handover doc's "no
public spec or BRE data field would distinguish these" claim was
incorrect — SAP 10.2 footnote 43 is the resolution.

API path (golden fixtures): 6 ASHP cohort residuals updated to reflect
the cascade closure:
  cert 0380 PE: -14.7865 → -14.6848 kWh/m²; CO2: +0.2774 → +0.2780 t/yr
  cert 0350 PE: -7.9281  → -7.8741;        CO2: +0.1697 → +0.1701
  cert 2225 PE: -11.9175 → -11.8557;       CO2: +0.2617 → +0.2621
  cert 2636 PE: -9.7153  → -9.6692;        CO2: +0.2189 → +0.2193
  cert 3800 PE: -9.7551  → -9.6838;        CO2: +0.2598 → +0.2603
  cert 9285 PE: -8.1110  → -8.0466;        CO2: +0.1559 → +0.1564

All SAP integer residuals unchanged (cascade tracks the EPC integer
SAP at residual 0 across the cohort).

PSR interpolation unit test (`test_interpolate_heat_pump_efficiency_at
_cert_0380_psr_per_sap_app_n`) updated to reflect the reciprocal
formula with the SAP-10.2-footnote-43 spec citation and closed-form
asserts (η_space_1 ≈ 234.5235; η_water_3 ≈ 285.0861 at PSR=1.43).

Pyright net-zero (1 → 1 across touched files: pcdb/parser.py,
tests/test_pcdb_table_362_lookup.py, rdsap/tests/test_golden_fixtures.py).

Tests: 710 pass (was 710 pre-slice with linear interp + un-updated
pins; net-zero because the 6 golden pin updates + 1 interp test update
exactly offset the 6 + 1 failures the formula change introduced), 10
expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
8b63087618 Slice S0380.27: thread floor_construction_type into _main_floor_u_value — closes cert 9796 +0.55 → +0.00174
Per RdSAP10 §5 page 29 "Floor infiltration (suspended timber ground
floor only)":

  Age band A-E:
    a) if floor U-value < 0.5, assume "sealed" → 0.1
    b) if retro-fit + no U → "sealed" → 0.1
    otherwise "unsealed" → 0.2

The cascade routes the (12) sealed/unsealed verdict through
`_main_floor_u_value`, which calls `u_floor` to compute the BS EN ISO
13370 U-value the spec rule keys on. That helper was a stale duplicate
of the real heat-transmission path that did NOT respect the per-bp
`floor_construction_type` lodgement:

  Pre-slice:  u_floor(construction=int_or_None, description=None, ...)
  Cascade:    u_floor(construction=int_or_None, description="Suspended
              timber" if floor_construction_type else <fallback>, ...)

For cert 9796-3058-6205-0346-9200 (Mid-Terrace bungalow age D,
46.87 m² / 15.0 m perimeter, suspended-timber lodged):
  - Broken `_main_floor_u_value` routes through the solid default
    (no description, construction=None) → BS EN ISO 13370 solid →
    U=0.49 W/m²K.
  - 0.49 < 0.5 → spec rule (a) fires → (12) = 0.1 (sealed).
  - Real heat-transmission cascade routes through the suspended branch
    via `effective_floor_description = floor_construction_type` →
    U=0.56 → unsealed → (12) = 0.2.

The 0.1 ach gap then propagated:
  (18) infiltration_rate 0.74 → ws 0.84 (cascade -0.10)
  (25)m Jan 0.82               → ws 0.91 (cascade -0.09)
  (38)m Jan 29.08 W/K          → ws 32.37 (cascade -3.29 W/K)
  (39) Jan 110.35 W/K          → ws 113.64 (cascade -3.29 W/K)
  HLP Jan 2.35 W/m²K           → ws 2.42 (cascade -0.07)
  T_h2 Jan 19.11°C             → ws 19.07 (cascade +0.04)
  MIT Jan 18.51°C              → ws 18.45 (cascade +0.06)
  SAP +0.55 vs worksheet 90.13.

Fix mirrors heat_transmission's `effective_floor_description` rule in
`_main_floor_u_value`: the per-bp `floor_construction_type` takes
precedence over a joined `epc.floors[].description` because it's the
explicit Elmhurst Summary §3/§9 surface. Inlined the description join
(vs importing `_joined_descriptions` from heat_transmission) so
cert_to_inputs stays free of cross-module private-symbol imports.

Cohort-2 outcome (38 certs, Summary path):
  exact (<1e-4): 23 → 23
  ≤±0.07:        14 → **15**  (+1: cert 9796 +0.55 → +0.00174)
  ±0.5..1:        1 → **0**   (last cohort-2 mid-range gap closes)

The remaining cert 9796 +0.00174 SAP residual is the cohort-1 HP-COP
precision floor (the same +0.001..+0.04 SAP that the other 10
triple-glazed HP certs sit at; see handover thread 3).

Cohort-1 golden fixture cert 8135-1728-8500-0511-3296 (Semi-detached
age C, suspended-timber ground floor with floor_construction=2 lodged
but description=None pre-slice) had the same bug:
  Pre-slice: u_floor returned 0.48 (solid branch via construction=2
             present-but-not-suspended) → false sealed verdict (12)=0.1
  Post-slice: u_floor returns 0.54 (suspended branch via description=
              "Suspended timber") → correct unsealed verdict (12)=0.2
  PE residual:  -4.9611 → **-0.0748** kWh/m² (+4.89 closer to API EPC)
  CO2 residual: -0.0678 → **+0.0246** t/yr  (closer to API EPC)
  SAP residual: 0 → 0 (unchanged, EPC integer)

Pin updated on cert 8135 to reflect the new (correct) cascade-vs-API
alignment; no other golden fixtures shifted.

Pyright net-zero per touched file:
  cert_to_inputs.py:                  35 → 35
  tests/test_cert_to_inputs.py:       13 → 12 (suppressed pre-existing
                                       private-import error on
                                       _water_heating_worksheet_and_gains
                                       at the same time as adding
                                       suppressions for the two new
                                       private imports)
  tests/test_golden_fixtures.py:       1 → 1
  tests/test_summary_pdf_mapper_chain.py: 0 → 0

Tests: 708 → 710 pass (+2 new: `_main_floor_u_value` routes
suspended-timber via per-bp lodgement; cert 9796 chain pin against
worksheet 90.1318 within ±0.07 ASHP-cohort spec floor), 10 expected
fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
9fbbad9068 Slice S0380.26: RdSAP10 §5.8 dry-lining adjustment on alt walls — closes cert 7700 -0.44 → +5e-5
Per RdSAP10 §5.8 final note + Table 14 page 41:

  "For drylining including laths and plaster use Rinsulation = 0.17 m²K/W."

Applied additively to the base U-value of an otherwise-uninsulated wall:

  U_adjusted = 1 / (1/U_base + 0.17)  — rounded to 2 d.p. half-up.

Closed form for the cohort fixture (cavity-as-built age C, U_base=1.5):

  1 / (1/1.5 + 0.17) = 1.19522... → 1.20 ✓ matches worksheet

Cert 7700-3362-0922-7022-3563 (Summary_000905.pdf / dr87-0001-000905.pdf)
is an End-Terrace house age C lodging:
  - Main wall: CavityWallDensePlasterDenseBlock, Filled Cavity, U=0.70
  - Alt wall 1: 14.44 m² Cavity As-Built, Dry-lining: Yes (worksheet
    `CavityWallPlasterOnDabsDenseBlock`, U=1.20)

Pre-slice the Elmhurst alt-wall mapper hard-coded `wall_dry_lined="N"`
and the cascade ignored the field everywhere — alt-wall U routed to the
cavity-as-built default (1.50), giving fabric (33) 148.72 W/K vs
worksheet 144.38 (Δ +4.33 W/K = ~+0.44 SAP). Worksheet "SAP value" line
lodges unrounded SAP 63.4425.

Implementation:
  1. `AlternativeWall.dry_lined: bool = False` on the Elmhurst surveys
     dataclass.
  2. Elmhurst extractor reads "Alternative Wall N Dry-lining: Yes/No"
     into the new field.
  3. `_map_elmhurst_alternative_wall` propagates `wall_dry_lined="Y"`
     instead of the hard-coded "N".
  4. `u_wall` gains a `dry_lined: bool = False` kwarg and a single
     §5.8 adjustment site at the as-built bucket (bucket=0). Insulated
     buckets already absorb the dry-lining R via Table 14.
  5. `_alt_wall_w_per_k` passes `dry_lined=alt_wall.wall_dry_lined == "Y"`.

Scope is the alt-wall path only — main BPs in the corpus all lodge
`wall_dry_lined="N"` (or the Summary PDF omits the field for the main
wall), so the main-wall call site is untouched. Conservative regression
posture per the user's strict cohort-pin convention.

Cohort-2 outcome (38 certs, Summary path):
  exact (<1e-4): 22 → **23**  (+1: cert 7700 -0.44 → +4.87e-05)
  0.07..0.5:      1 → **0**   (-1: cert 7700 closes out)
  0.5..1:         1 → 1       (cert 9796 unchanged — MIT precision floor)
  RAISES:         0 → 0

Cohort-1 ASHP cohort untouched: all certs lodge wall_dry_lined="N", so
the alt-wall call site short-circuits to the original cascade. Verified
no regressions across the 22 previously-exact cohort-2 certs either.

Pyright net-zero on all 8 touched files (183 → 183).

Tests: 704 → 708 pass (+4 new: u_wall §5.8 adjustment fires
correctly; cavity-as-built unchanged without flag; insulated bucket
unaffected by flag; heat_transmission alt-wall delta = 14.44 × 0.30
W/K; cert 7700 full chain hits worksheet 63.4425 at < 1e-4),
10 expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
9547fa1f5f docs: handover for cohort-2 closure + precision-floor next steps
Captures 5 slices shipped this session (S0380.21..25):
  - Table 3a rows 1+4 + PCDB keep-hot dispatch
  - Per-BP roof exposure (Ext1 flat roof on flats)
  - RdSAP §11.1 b) % of roof area PV synthesis
  - SAP code 631 → house coal secondary fuel
  - SAP codes 2111/2113 → control type 2

Cohort-2 outcome: 22/38 exact (<1e-4), max residual ±0.55 SAP,
0 RAISES, 0 big-gaps. All structural cascade gaps closed.

Open threads diagnosed in detail:
  1. Cert 7700 -0.44 SAP — wall U code conflict
     (_WALL_INSULATION_NONE=4 vs Elmhurst "As Built"=4). Wider than
     a single slice; needs regression testing.
  2. Cert 9796 +0.55 SAP — MIT precision floor (Mid-Terrace
     bungalow + HP, +0.06°C across all months). Same mechanism as
     cohort-1 HP-COP residuals.
  3. API-path closure for all 38 certs (deferred).
  4. Tighten cohort-1 chain tests to 1e-4 once thread 2 closes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
474052d303 Slice S0380.25: SAP codes 2111/2113 are type 2 not type 3 — closes 0652 + 6835
Per SAP 10.2 spec page 171 Table 4e "Heating system controls" — boiler
systems with radiators (Group 1):
  2110: "Time and temperature zone control by arrangement of plumbing
        and electrical services"                          → type 3
  2111: "TRVs and bypass"                                 → type 2
  2112: "Time and temperature zone control by device in PCDB" → type 3
  2113: "Room thermostat and TRVs"                        → type 2

`_CONTROL_TYPE_BY_CODE` previously bucketed 2111 + 2113 with the type 3
codes, but neither lodges any time-zone control — they're TRV-class
controls (closer to programmer + room thermostat). The misclassification
propagated through SAP 10.2 Table 9 to swap the elsewhere-zone
off-period pattern from (7, 8) to (9, 8) — i.e. the spec's "heating
0700-0900 and 1800-2300" pattern (footnote b) instead of "heating
0700-0900 and 1600-2300" (footnote a). Under-counted MIT by ~0.67 °C
across the year, dropping space-heating demand and over-predicting SAP:
  - cert 0652-3022-1205-2826-1200: +1.93 → -1e-5
  - cert 6835-3920-2509-0933-5226: +0.72 → +0.015

Cohort-2 outcome (38 certs, Summary path):
  exact (<1e-4): 21 → **22**  (+1: cert 0652 closes)
  ≤±0.07:        13 → **14**  (+1: cert 6835 moves from ±0.5..1)
  ±0.5..1:        2 → **1**   (-1: cert 6835 closes out)
  ±1..5:          1 → **0**   (-1: cert 0652 closes out)

No cohort-1 regressions (all certs there use codes 2106 / 2206;
neither uses 2111/2113).

Pyright net-zero (cert_to_inputs.py 35→35, test 13→13).

Tests: 704 pass (existing control-type test extended; +2 new
assertions for codes 2111/2113), 10 expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
9a091234cf Slice S0380.23: RdSAP §11.1 b) PV %-of-roof-area synthesis — closes cert 6835 -13.37 → +0.72
RdSAP 10 specification page 60 §11.1 b) (Photovoltaics): "If the kWp
(or DNC) is not known use the following: PV area is roof area for
heat loss (before amendment for any room-in-roof), times percent of
roof area covered by PVs, and if pitched roof divided by cos(35°).
If there is an extension, the roof area is adjusted by the cosine
factor only for those parts having a pitched roof. kWp is 0.12 ×
PV area. If not provided in the RdSAP data set then facing South,
pitch 30°, modest overshading."

Wire-through:
  1. `Renewables.pv_percent_roof_area: Optional[int]` — new field on
     the Elmhurst site-notes dataclass.
  2. Elmhurst extractor `_extract_renewables` parses Summary §19.0
     row "Proportion of roof area" (cert 6835: "40").
  3. Elmhurst mapper `from_elmhurst_site_notes` surfaces it through
     `epc.sap_energy_source.photovoltaic_supply.none_or_no_details
     .percent_roof_area` — mirrors the API mapper's lodgement shape.
  4. `cert_to_inputs._synthesize_pv_arrays_from_percent_roof_area`
     synthesizes a single PV array via the spec formula when
     `photovoltaic_arrays` is empty AND a `percent_roof_area > 0`
     lodgement is present. Fires inside
     `_pv_generation_kwh_per_yr`, so both rating + demand cascades
     pick it up.

Cohort-2 outcome (38 certs, Summary path):
  exact (<1e-4): 20 → 20
  ±0.07..0.5:   1 → 1
  ±0.5..1:      1 → **2**  (cert 6835 closes -13.37 → +0.72)
  ±1..5:        1 → 1
  ±5+:          2 → **1**  (-1: cert 6835 moves out of big-gap band)

Cert 6835 verified end-to-end:
  - kWp = 0.12 × 36.9 × 0.40 / cos(35°) = 2.1622
    (worksheet "Cells Peak = 2.16, Orientation = South, Elevation =
    30°, Overshading = Modest")
  - Cascade PV generation = 1493.88 kWh/yr vs worksheet 1492.33
    (<0.1% delta — kWp-rounding artefact).
  - Cascade SAP 80.92 vs worksheet 80.20 (+0.72, in the ±0.5..1 band).

The residual +0.72 likely traces to the PV-cost cascade's
used-in-dwelling / exported split rather than the synthesis — the
kWh figure is within rounding of the worksheet.

Pyright per-file: net-zero
  - cert_to_inputs.py 35 → 35
  - test_cert_to_inputs.py 13 → 13
  - mapper.py 32 → 32
  - elmhurst_site_notes.py 0 → 0
  - elmhurst_extractor.py 0 → 0

Tests: 702 → 703 pass (+1 new RdSAP §11.1 b synthesis test), 10
expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
7136edf2fb Slice S0380.22: per-BP roof exposure — closes cert 0036 Ext1 flat roof
For multi-BP dwellings the dwelling-level `exposure.has_exposed_roof`
flag (derived from `dwelling_type` via `_dwelling_exposure`) zeroed
out ALL BPs' roof contributions uniformly. That's wrong when a flat
has an extension with its own external roof — e.g. ground-floor flat
with a single-storey extension whose flat roof is exposed.

Replace the global suppression with a per-BP signal:
  - Per-BP `roof_construction_type` containing "another dwelling
    above" → that BP's roof is party → suppress.
  - Otherwise BP 0 (Main) falls back to the dwelling-level flag
    (covers flat lodgements that don't explicitly mark the Main
    roof type).
  - Extensions (i > 0) expose their roof by default unless their
    own roof_construction_type lodges as party.

Cohort cert 0036-6325-1100-0063-1226 (ground-floor flat, age D):
  - Main lodges roof_construction_type = "Another dwelling above"
    → contributes 0 W/K (matches worksheet line (30) "External roof
    Main 57.93 m² × U=0 = 0.0").
  - Ext1 lodges roof_construction_type = "Flat" → contributes
    1.09 m² × U=2.30 = 2.507 W/K (matches worksheet "External roof
    Ext1 1.09 m² × U=2.30 = 2.507", spec line (30)).
  - Cascade SAP closes from +0.2987 → -6e-6 vs worksheet 62.7471.

Houses + bungalows are unaffected: dwelling-level flag stays True
and the per-BP guard only activates on explicit party-roof lodgement.
Single-BP flat tests stay correct: the per-BP guard is a no-op when
no roof_construction_type is lodged (i==0 → falls back to dwelling-
level flag).

Spec citation:
  - RdSAP 10 §3 / §5.11 — heat-loss surfaces and party-roof
    treatment. SAP 10.2 spec line (30) sums external roofs only;
    party roofs sit in the (32) party-element channel with U=0.

Cohort-2 distribution (38 certs, Summary path) shifts:
  exact (<1e-4): 19 → **20**  (+1: 0036)
  0.07..0.5:     2  → **1**   (-1: 0036 → exact)

Pyright net-zero (heat_transmission.py 13→13, test file 71→71).
Test counts: 702 → 703 pass (+1 new test), 10 expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
f7d863a9fa Slice S0380.21: Table 3a row 1 (no keep-hot) + row 4 dispatch — closes 9 cohort-2 RAISES
SAP 10.2 spec p.160 Table 3a rows:
  Row 1 ("Instantaneous, without keep-hot facility"):
      (61)m = 600 × fu × n_m / 365   with fu = min(1, V_d,m / 100)
  Row 4 ("Instantaneous, with keep-hot, not controlled by time clock"):
      (61)m = 900 × n_m / 365

Add `combi_loss_monthly_kwh_table_3a_row_1_no_keep_hot()` and
`combi_loss_monthly_kwh_table_3a_row_4_keep_hot_no_time_clock()` to
`worksheet/water_heating.py`. Extend `pcdb_combi_loss_override` to
dispatch via the PCDB keep_hot_facility / keep_hot_timer fields lodged
at raw positions 58/59 (extracted in Slice S0380.20):

  kh ∈ {0, None}            → row 1   (600 × fu × n/365, no keep-hot)
  kh = 1, timer = 1         → row 3   (cascade default 600 × n/365)
  kh = 1, timer ∈ {0, None} → row 4   (900 × n/365, no time clock)
  kh ∈ {2, 3}               → UnresolvedPcdbCombiLoss (electric or
                              mixed keep-hot — Table 3a Note 2
                              fuel-split between (61)m and (219)m
                              deferred until a fixture exercises it).

Closes 9 of the 11 cohort-2 RAISES from Slice S0380.20 — all PCDF 15709
+ 10315 certs with no keep-hot lodgement now compute to abs(delta) <
1e-4 vs the dr87 worksheet. Verified end-to-end on cert 7800-1501-0922-
7127-3563 (Potterton Promax Combi 28 HE+A, PCDF 15709): Jan (61) =
600 × 0.778795 × 31/365 = 39.6866 kWh, matching worksheet line ref
exactly. The 2 newly-visible cohort-2 issues (cert 6835 -13.37 SAP, cert
0652 +1.93 SAP) were hidden behind the previous strict-raise — they
surface unrelated cascade gaps, not regressions.

Re-add 0390-2954-3640-2196-4175 (Firebird oil PCDF 9005) to the golden
fixture cohort dropped in Slice S0380.20:
  - `_EXPECTATIONS` with re-pinned SAP/PE/CO2 residuals (-7 / -26.0093
    kWh/m² / -2.5211 t/yr) — the cert now cascades end-to-end via the
    no-keep-hot row.
  - `_PCDB_CHAIN_EXPECTATIONS` pins PCDF index 9005 + winter eff 0.864
    (Table 105 fraction).

Spec citations (per [[feedback-spec-citation-in-commits]]):
  - SAP 10.2 spec p.160 Table 3a rows 1 & 4 (formula columns) +
    pdftotext of `sap-10-2-full-specification-2025-03-14.pdf | sed -n
    '15280,15410p'` (Notes 1 & 2 on fu / electric keep-hot routing).
  - STP09-B04 §5.3 "Influence of Keep-hot facility" — origin of the
    600 / 900 kWh/yr keep-hot baselines.

Pyright per-file: net-zero on all touched files
(water_heating.py 1→1, cert_to_inputs.py 35→35, tests unchanged).

Test counts: 697 → 702 pass (+5 new tests), 10 expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
1abc339848 docs: handover for Table 3a no-keep-hot continuation + SAP 10 spec PDFs
Adds the next-agent handover and the BRE technical papers referenced
by the cohort-2 negative-band investigation:

  - `HANDOVER_TABLE_3A_NO_KEEP_HOT.md` — picks up from Slice S0380.20.
    Covers cohort distribution at HEAD `4879e8c3`, the verified
    Table 3a Row 1 spec formula `(61)m = 600 × fu × nm / 365`, the
    dispatch recipe for `pcdb_combi_loss_override`, watch-outs (cert
    0360 / cohort-1 cert 000490 behaviour after the slice lands), the
    diagnostic probe script, test baselines, and the open-thread
    priority list (Ext1 roof, HP-COP, big-gap 2102, API path, parity).

  - `specs/STP09-B04_Combi_boiler_tests.pdf` — 2009 BRE methodology
    paper (Alan Shiret, BRE) defining the combi-loss test programme
    that produced the SAP Table 3a 600/900 kWh/yr keep-hot assumptions.
    Source: https://bregroup.com/documents/d/bre-group/stp09-b04_combi
    _boiler_tests.

  - `specs/sap10 technical papers/S10TP-{02..13}.pdf` — full SAP 10
    supporting technical paper set (Issue 1.2 / 1.3 / 1.4 across the
    eight papers). S10TP-12 §9.4 confirms: "No changes to the SEDBUK
    calculation method for water heating efficiency were considered
    necessary" — so the STP09-B04 (SAP 2009) Table 3a methodology
    carries through to SAP 10 unchanged.

These docs replace web-fetched references with locally-tracked copies
so the slice S0380.21 implementor can grep / pdftotext them directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
0adb34eaf2 Slice S0380.20: extract PCDB keep-hot fields + strict-raise for no-keep-hot combis
Surfaces the SAP 10.2 Appendix J Table 3a sub-row dispatch gap that
masked +0.2..+0.4 SAP residuals on 11 cohort-2 PCDB-listed combi
certs. Identified via cert 7800-1501-0922-7127-3563 (Potterton Promax
Combi 28 HE Plus A, PCDF 15709): cascade used the keep-hot 600 kWh/yr
default; worksheet (61) sums to ~428 kWh/yr via the no-keep-hot
sub-row formula.

Root cause: the PCDB Table 105 record carries keep-hot metadata at
field positions 58 (`keep_hot_facility`) and 59 (`keep_hot_timer`)
per the SAP 10 PCDB spec (private feed for SAP software vendors —
not surfaced on the public PCDB website nor the Open EPC API). The
parser preserved these in `raw=fields` but didn't surface them as
typed attributes, so the cascade had no signal to dispatch the right
Table 3a sub-row.

Two-part change:

1. `domain/sap10_calculator/tables/pcdb/parser.py` — adds typed
   `keep_hot_facility` and `keep_hot_timer` fields to
   `GasOilBoilerRecord`, parsed from fields[57] and fields[58].
   Field enums (per BRE STP09-B04 + SAP 10 PCDB spec):
     Field 58: 0=no keep-hot, 1=fuel keep-hot, 2=electric keep-hot,
               3=gas+electric keep-hot
     Field 59: 0=no timer, 1=overnight time-switch
   Verified against cohort-1 fixture 000490 (Vaillant Ecotec Pro 28,
   PCDF 10328) — record lodges keep_hot_facility=1, keep_hot_timer=1,
   exactly matching the hand-built fixture comment "Combi keep hot
   type = Gas/Oil, time clock" at `_elmhurst_worksheet_000490.py:
   277-280`.

2. `domain/sap10_calculator/rdsap/cert_to_inputs.py` — adds
   `UnresolvedPcdbCombiLoss` exception. `pcdb_combi_loss_override`
   now raises (instead of silently returning None) when the PCDB
   record has `separate_dhw_tests=0/None` AND
   `keep_hot_facility=0/None`. The cascade's only implemented Table
   3a row is "with keep-hot, time clock" (600 kWh/yr), which is the
   wrong spec row for no-keep-hot combis — silently using it masked
   the cohort-2 negative band.

The ETL was re-run to refresh `pcdb_table_105_gas_oil_boilers.jsonl`
with the new typed fields (raw fields unchanged, just additional
columns surfacing what was previously buried).

Cohort distribution after slice:

  cohort-1 cert 000490 (Vaillant PCDF 10328, kh=1): NO RAISE — cascade
    keep-hot 600 default IS the spec-correct row. Tests still GREEN.
  cohort-2: 10 exact + 13 sub-±0.07 + 2 ±0.07..0.5 + 1 ±0.5..1 +
            1 ±5+ + 11 RAISES.

The 11 raising certs are now blocked until the Table 3a no-keep-hot
sub-row is implemented (BRE STP09-B04 methodology — pending slice).
Previously these certs silently produced +0.2..+0.4 SAP errors AND
ranged into the big-gap band; raising surfaces the gap rather than
shipping wrong numbers.

Two golden cert tests blocked alongside (Firebird oil PCDF 9005 also
hits this path):
  - test_golden_cert_residual_matches_pin[0390-2954-3640-2196-4175]
  - test_api_to_domain_mapper_preserves_main_heating_index_number[0390-2954-3640-2196-4175]
Re-enable when the Table 3a no-keep-hot row lands.

Two other tests updated:
  - test_main_heating_index_number_in_pcdb_overrides_seasonal_efficiency:
    switched from Baxi 98 (sdt=0, kh=None, would raise) to Worcester
    PCDF 10241 (sdt=1, routes via Table 3b row 1). Asserts 0.885 not
    0.66.
  - test_pcdb_combi_loss_override_returns_none_or_raises_for_untested
    _or_storage_combis: renamed + extended to pin the new strict-raise
    behaviour.

Pyright net-zero per file:
  - domain/sap10_calculator/rdsap/cert_to_inputs.py: 35 (baseline 35)
  - domain/sap10_calculator/tables/pcdb/parser.py: 0
  - domain/sap10_calculator/tables/pcdb/__init__.py: 0
  - domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py: 13 (baseline 13)
  - domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py: 1 (was 2 — improved)

Regression baseline: 697 pass + 10 fail (= prior 699 + 10 - 2 dropped
golden parametrize entries for cert 0390-2954-3640-2196-4175).

Spec refs:
- SAP 10 PCDB spec (private SAP software vendor feed) — keep-hot
  facility / timer / electric-heater fields at positions 58 / 59 / 60.
- BRE STP09-B04 (combi boiler test methodology) — origin of the
  keep-hot Table 3a derivation. URL: https://bregroup.com/documents/d
  /bre-group/stp09-b04_combi_boiler_tests
- SAP 10.2 Appendix J Table 3a row-selection — to be implemented per
  PCDB keep-hot dispatch in a follow-up slice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
15b3df1778 Slice S0380.19: count Elmhurst shower outlets by type (no more hardcoded 1)
Surfaces the lodged shower multiplicity from the Elmhurst Summary §16
on the EPC. Previously `_map_elmhurst_sap_heating` hardcoded:

  electric_shower_count = 1 if has_electric_shower else None
  mixer_shower_count    = 0 if has_electric_shower else None

losing the count for any cert with ≥ 2 outlets. Cert
7800-1501-0922-7127-3563 lodges TWO instantaneous electric showers
("Shower 01" + "Shower 11") but the mapper produced
`electric_shower_count=1`. After this slice:

  electric_shower_count = Σ(s for s in showers if s.outlet_type
                              == "Electric shower")
  mixer_shower_count    = Σ(s for s in showers if s.outlet_type
                              != "Electric shower")

**Cascade SAP effect:** None on cert 7800. Appendix J's eq J16
(`N_ES,per_outlet = N_shower / N_outlets`) and eq J18 (Σ_j E_ES,j)
are symmetric in N_electric_showers when there are no mixer outlets,
so the lodged (64a) kWh and (247a) cost are unchanged. The fix is
correctness-by-construction, not a delta-closer for the negative-band
certs (their +0.69 GBP total-cost gap traces to the gas hot-water
kWh path — separate slice).

**Hand-built fixture updates (5):** the cohort-1 hand-builts at
`domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_*.py`
previously omitted `electric_shower_count` / `mixer_shower_count`
(implicitly None), which matched the mapper's pre-slice None
sentinel. Updated each to the lodged counts the mapper now surfaces:
  000474: 1 mixer  → (0, 1)
  000477: 1 mixer  → (0, 1)
  000480: 1 mixer  → (0, 1)
  000490: 1 mixer  → (0, 1)
  000516: 1 mixer  → (0, 1)
000487 (already at (1, 0) for an electric-shower lodging) unchanged.

Tests:
- `test_summary_7800_two_electric_showers_count_as_two_not_one` —
  pins the multi-shower mapping for cert 7800 (Summary_000890.pdf).
- 5 hand-built field-parity tests
  (`test_from_elmhurst_site_notes_matches_hand_built_*`) now pass at
  the new integer counts instead of None.

Pyright net-zero per file:
- datatypes/epc/domain/mapper.py: 32 (baseline 32)
- backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression baseline: 699 pass + 10 fail (= prior 698 + 10 + 1 new).

Spec refs:
- SAP 10.2 Appendix J §1a — outlet counting drives `N_outlets` used
  in eq J6/J7 (mixer shower water draw) and eq J16/J17/J18 (electric
  shower energy).
- Cert 7800-1501-0922-7127-3563 Summary §16 "Showers" lodgement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
33ae3cc693 Slice S0380.18: u_party_wall flat default per RdSAP10 Table 15 footnote*
Closes cert 0036-6325-1100-0063-1226 (the cohort's first FLAT fixture)
from Δ -0.3737 → +0.2987 by applying the RdSAP 10 Table 15 footnote *
rule: flats/maisonettes with unknown party-wall construction default
to U=0.0 W/m²K (both sides are heated dwellings, no heat loss).

Worksheet dr87-0001-000910.pdf line ref (32) lodges:
    Party walls Main   24.13 m²   U=0.00   A×U = 0.0000 W/K
matching the Table 15 footnote *. The cascade was applying the U=0.25
*house* default to this lodging because:
  - Elmhurst Summary lodged `party_wall_type='U Unable to determine'`
  - mapper translated it to `party_wall_construction=0` (the cross-
    mapper-parity "unknown" sentinel)
  - `u_party_wall(0)` fell through to `return 0.25` (the final-branch
    default — same path as `u_party_wall(None)`)

That produced cascade `party_walls_w_per_k = 24.13 × 0.25 = 6.03` W/K
of heat-loss excess, propagating through (39) HTC → (97)..(98c) space
heat demand → (211) main fuel kWh → (255) total cost → (257) ECF →
(258) SAP rating. Net effect: cascade SAP 62.3734 vs worksheet 62.7471.

Two-part fix:

1. `domain/sap10_ml/rdsap_uvalues.py:u_party_wall` — add
   `is_flat: bool = False` keyword argument. When True AND
   `party_wall_construction in (None, 0)` (both the API-mapper None
   path and the Elmhurst-mapper 0 sentinel for "Unable to determine"),
   return 0.0 instead of the house default 0.25. Spec citation: RdSAP
   10 Table 15 footnote * ("for flats and maisonettes with unknown
   party-wall construction").

2. `domain/sap10_calculator/worksheet/heat_transmission.py` — wire
   the cascade to pass `is_flat=_is_flat_or_maisonette(epc.property
   _type)`. Adds a new helper `_is_flat_or_maisonette` distinct from
   the existing `_is_house` (which excludes bungalows from
   *cantilever* detection — bungalows ARE houses for party-wall
   purposes per the spec). The new helper checks both the descriptive
   form ("Flat" / "Maisonette") and the SAP schema enum-as-string
   form ("2" / "3" — per `datatypes/epc/domain/epc_codes.csv
   property_type` rows: 0=House, 1=Bungalow, 2=Flat, 3=Maisonette,
   4=Park home).

The schema-enum collision was the bug-fix-with-a-bug: an initial
implementation used "1"/"2" (Flat/Maisonette per intuition) but those
are actually Bungalow/Flat per the schema, which routed all 10
bungalow certs onto the flat path. Corrected pre-commit.

Cohort-2 Summary-path delta after slice:

  cert 0036  (Flat)      Δ -0.3737  →  Δ +0.2987   ✓ improved by +0.67
  10 bungalow certs                  unchanged (correctly NOT flat)
  5 non-flat house certs in band     unchanged (different root cause —
                                     next slice)

Bungalow certs (cohort 1 + 2) verified unchanged at delta ≤ +0.04 each.

Tests added (5):
- `test_u_party_wall_unknown_for_flat_returns_table15_footnote_zero`
  pins the spec rule on the helper.
- `test_u_party_wall_unknown_sentinel_zero_treated_as_unknown_for_flat`
  pins the Elmhurst-mapper `0` sentinel parity.
- `test_u_party_wall_known_solid_still_returns_zero_when_is_flat_false`
  pins precedence: explicit Solid code overrides the is_flat flag.
- `test_summary_0036_flat_unknown_party_wall_routes_to_u_zero` chain-
  test through `from_elmhurst_site_notes` + cert_to_inputs +
  calculate_sap_from_inputs to assert `party_walls_w_per_k == 0` at
  1e-4 tolerance.

Pyright net-zero per file:
- domain/sap10_ml/rdsap_uvalues.py: 1 (baseline 1)
- domain/sap10_calculator/worksheet/heat_transmission.py: 13 (baseline 13)
- domain/sap10_ml/tests/test_rdsap_uvalues.py: 66 (baseline 66)
- backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression baseline: 698 pass + 10 fail (= prior 694 + 10 + 4 new).

Note: the remaining +0.2987 residual on cert 0036 is in (30) external
roof — worksheet lodges Ext1 flat roof Plasterboard insulated U=2.30
giving 2.51 W/K; cascade has roof_w_per_k=0 (Ext1 roof contribution
missing). Separate slice.

Spec refs:
- RdSAP 10 Table 15 ("U-values of party walls") row 4 — house unknown
  default 0.25 W/m²K.
- RdSAP 10 Table 15 footnote * — flat/maisonette unknown default
  0.0 W/m²K.
- `datatypes/epc/domain/epc_codes.csv` rows
  `property_type,{0..4},...` — SAP/RdSAP schema property-type enum.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
69668ec634 Slice S0380.16: add 'Normal' → cylinder_size=2 (110 L) for cohort 2
Unblocks two 38-cert-cohort certs that previously raised
`UnmappedElmhurstLabel("cylinder_size", 'Normal')` at extraction:
  cert 2536-2525-0600-0788-2292  ws SAP=79.7264
  cert 9421-3045-3205-1646-6200  ws SAP=87.4495

Both Summary §15.1 lodgements read "Cylinder Size: Normal"; both dr87
worksheets lodge line ref (47) "Store volume = 110.0000" L (extracted
from `Hot Water Cylinder → Cylinder Volume 110.00`). RdSAP 10 §10.5
Table 28 documents the "Normal (90-130 litres)" descriptor whose
midpoint is 110 L — the canonical Elmhurst label string in
`datatypes/epc/surveys/elmhurst_site_notes.py` is "Normal (90-130
litres)", and the worksheet's exact 110 L matches the midpoint.

Two-line fix:
  +    "Normal": 2,           in `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`
  +    2: 110.0,              in `_CYLINDER_SIZE_CODE_TO_LITRES`

The cascade enum 2 is consistent with the existing
`cert_to_inputs.py` docstring's documented (but not-yet-observed)
code 2 → Normal slot, alongside code 3 (Medium / 160 L) and code 4
(Large / 210 L) added in earlier slices.

Slice keeps tight: two mapping unit tests pinning `cylinder_size == 2`
for both certs at extraction. Post-fix the first-attempt cascade
deltas vs worksheet are:
  cert 2536  Δ +0.0244   (was: RAISES)
  cert 9421  Δ +0.0296   (was: RAISES)

Both deltas now sit in the same systematic +0.02..+0.07 small-gap
band as ~12 other first-attempt certs in cohort 2 — chain test +
±0.07 pin would just paper over a known systematic residual that the
user has explicitly asked to drive towards 1e-4, not toward ±0.07.
Following slice will investigate the shared systematic offset and
close cert 2536 / 9421 along with the rest of the +0.04 band on
the chain.

Pyright net-zero per file:
  - datatypes/epc/domain/mapper.py: 32 (baseline 32)
  - domain/sap10_calculator/rdsap/cert_to_inputs.py: 35 (baseline 35)
  - backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression baseline: 691 pass + 10 fail (= prior 689 + 10 + 2 new GREEN).

Spec refs:
- RdSAP 10 §10.5 Table 28 — "Cylinder Volume" Normal band 90-130 L,
  midpoint 110 L (also the canonical Elmhurst label suffix).
- Cert 2536 worksheet `dr87-0001-000889.pdf` line ref (47) = 110.0000.
- Cert 9421 worksheet `dr87-0001-000884.pdf` line ref (47) = 110.0000.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
c732c21836 docs: handover — Summary + API cohort expansion to 38 additional certs
Hands off the next workstream: the 38 cert subdirs at
`sap worksheets/additional with api 2/`. Each subdir is named after
the 20-digit EPC cert reference and contains a Summary PDF + dr87
worksheet PDF. API JSONs are NOT in the dataset but ARE fetchable
via the existing `EpcClientService` (token in `backend/.env` as
`OPEN_EPC_API_TOKEN`).

User's stated ordering: Elmhurst Summary mapping FIRST, API path
SECOND. Folder names = cert refs; need to verify the matching before
bulk-pinning (any mis-filed PDF would silently invalidate slice
work).

Handover ships with verified dataset and first-attempt baselines:

  - Folder-vs-cert sweep: **38/38 match** at handover (postcode
    parity check between Summary PDF and Open EPC API).
  - First-attempt Summary-path probe across 38 certs:
      24  closed at ±0.07 (first-try, zero new slices needed)
       9 ~ small gap (<1 SAP) — likely 1 slice each
       3 ✗ big gap (>1 SAP) — multi-slice investigation
       2 RAISES UnmappedElmhurstLabel: cylinder_size='Normal'

The two `Normal` cylinder raises are the immediate Phase 1 slice —
Slice S0380.15's strict-enum pattern paid off on its first new
cohort by surfacing the gap at extraction time instead of as a
downstream SAP delta.

Workstream phases documented in the handover:

  Phase 0: folder-vs-cert sweep (already done — 38/38)
  Phase 1: fix 'Normal' cylinder unmapped-label raise
  Phase 2: bulk-pin the 24 first-try-closures as chain tests
  Phase 3: close the 9 small-gap certs one slice each
  Phase 4: investigate the 3 big-gap certs (likely HP-routing)
  Phase 5: fetch + persist API JSON for all 38, run API path tests
  Phase 6: cross-mapper EPC parity (Summary EPC ≡ API EPC) — the
    user's stated north-star

Includes:
  - Paste-able diagnostic probe scripts (Summary path + folder-vs-
    cert sweep + .env loader + EpcClientService usage example).
  - Full table of first-attempt deltas per cert with classifications.
  - All 15 prior-session slice commits indexed.
  - Memory references to the slicing / methodology conventions.
  - Per-cert diagnostic recipe template.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
395ad30c48 Slice S0380.13: widen cantilever gate to accept "House" descriptive form
Closes cert 2636 to spec floor (Δ +0.5167 → +0.0323) by accepting
both the EPC schema enum-as-string ("0") AND the Elmhurst Summary
mapper's descriptive form ("House") for the cantilever-detection
property-type gate at `heat_transmission.py:768`.

Root cause: slice 102f-prep.9 (commit 06b4ef3d) added cantilever
detection gated on `epc.property_type == _PROPERTY_TYPE_HOUSE` where
`_PROPERTY_TYPE_HOUSE = "0"`. That matches the API mapper's encoding
(schema enum), but the Summary mapper produces "House" (descriptive)
and the hand-built worksheet fixtures also use "House" — so neither
triggers the gate and the cantilever path silently no-ops on the
Summary path. Cert 2636's worksheet (28b) "Exposed floor Main 3.74
× 1.20 = 4.4880" is the cantilever — without surfacing it the
cascade missed 4.488 W/K of floor heat loss.

Three-encoding origins:
- API mapper:        property_type='0'      (schema enum-as-string)
- Summary mapper:    property_type='House'  (descriptive from §1)
- Hand-built fixtures: property_type='House' (legacy convention)

Fix: replace the equality check with a `_is_house()` helper that
accepts the {"0", "House"} frozenset. Centralised so future
property-type sensitive gates can call the same helper.

Forcing function: cert 2636 first-attempt Summary SAP closes from
Δ +0.5167 (after S0380.12 walls fix) to Δ **+0.0323** — within the
±0.07 ASHP-cohort spec floor. `floor_w_per_k` moves from 19.1982
(ground floor only) to 23.6862 (ground 19.20 + cantilever 4.49 =
worksheet (28a) + (28b) exact match).

Cohort closure status (6 of 7 ASHP certs at spec floor):
  cert  Δ vs worksheet  spec floor?
  0380  +0.0594         ✓
  0350  +0.0458         ✓
  2225  +0.0441         ✓
  2636  +0.0323         ✓  ← this slice
  3800  +0.0442         ✓
  9285  +0.0502         ✓
  9418  +2.5973         ✗  (Daikin EDLQ05CAV3 — final cert)

Boiler hand-built parity verified intact: 5 hand-built cohort certs
(000474, 000477, 000480, 000490, 000516) all use property_type=
"House" and now also fire the cantilever gate, but none have
floor1_area > floor0_area + 1m² (the cantilever-area trigger) so
their cascade output is unchanged. Regression suite 683 pass + 10
fail (= handover baseline 669 + 10 + 17 new GREEN tests across
S0380.2..S0380.13).

Pyright net-zero on edited files:
  domain/sap10_calculator/worksheet/heat_transmission.py: 13
    (baseline; no new errors)
  backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Spec / precedent refs:
- Slice 102f-prep.9 (commit 06b4ef3d) — RdSAP cantilever-exposed-
  floor detection (originally API-only via `property_type=="0"` gate).
- SAP 10.2 Table 20 — U_exposed_floor (age D + no insulation →
  1.20 W/m²K, the cohort's cantilever U-value).
- Cert 2636 worksheet `dr87-0001-000898.pdf` line refs (28a)+(28b)
  sum 23.6862 W/K (exact cascade match after this slice).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
18f8e0f6c5 docs: handover — start cert 0380 Summary → EPC → calculator path
The 7-cert ASHP cohort API path is closed at the spec-precision
floor (this session). Next workstream is the Summary path for cert
0380 — the user's preferred starting point because the Summary +
worksheet PDFs surface labelled intermediate values that the API
path lacks.

Cert 0380 Summary PDF (`Summary_000899.pdf`) is already in the
test fixtures dir; just needs a path constant + RED chain test.
Previous handover flagged the extractor at Δ -58.37 SAP for HPs
— the immediate diagnostic is whether the mapper surfaces
main_heating_category=4 and main_heating_index_number=104568.

The handover also documents the user's "Elmhurst-specific"
challenge worth re-exploring: closed boiler certs hit 1e-4 vs
Elmhurst via the same cascade, so the residual is precisely at the
Appendix N3.6 PSR interpolation step. Cross-check with the BRE
xlsx canonical calculator is suggested.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
8b5a8db7e1 docs: handover refresh — cohort closed to spec-precision floor
Updates the handover with the final state after 11 slices:
- All 7 ASHP cohort certs cascade SAP integer == lodged (residual 0).
- Continuous SAP residual clusters within +0.030..+0.060.
- BRE web confirmed max_output_kw values (4.39 / 3.933) match cascade
  exactly — the remaining drift is NOT a max_output bug.
- Cascade (39) annual avg HLC EXACTLY matches worksheet (39) at 4 dp
  for cert 0380 and 2225 — HLC is NOT the bug either.
- Implied drift is ~0.15% in η_space interpolation precision, likely
  in Elmhurst's internal rounding convention (not in public SAP 10.2
  spec or BRE PCDB).

Recommends Path A (ship Layer 4 chain tests at ±0.07 SAP tolerance)
as the spec-precision floor. Path B (close to 1e-4) requires Elmhurst
implementation access that's outside public docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
d3058bf1d5 Slice 102f-prep.11: Track 6 ASHP cohort fixtures + register 7 golden pins
Fetches the API JSON for each of the 6 previously-missing ASHP
cohort certs (0350, 2225, 2636, 3800, 9285, 9418) into
tests/fixtures/golden/ so they're tracked alongside cert 0380 (the
cohort anchor lodged earlier). Each cert's residual against its
GOV.UK EPC lodgement is pinned in `_GOLDEN_EXPECTATIONS`:
  - SAP integer residual = 0 across all 7 certs (cascade rounds to
    the lodged value exactly).
  - PE residual: -7.93 to -14.79 kWh/m² (cascade UNDER-estimates
    primary energy by ~8-15 — likely PV cascade self-consumption
    β-factor split per Appendix M §3, untouched by this workstream).
  - CO2 residual: +0.16 to +0.28 t/yr (cascade OVER-estimates by ~0.2).

The pins lock the current cascade state so future mapper / cascade
changes fire loudly when they shift the 7-cohort residuals (the same
pin-tracking convention as the existing 8 boiler golden certs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
2605a7bf6e Slice 102f-prep.10: Alt-wall opening allocation per window_wall_type
RdSAP §1.4.2: window openings deduct from the gross of the wall they
pierce. The cert schema lodges `window_wall_type` on each SapWindow:
code 1 = main wall, codes 2/3 = alternative walls 1/2. Cohort
ground-truth: cert 2636 BP0 lodges one window (1.14 × 1.04 ≈ 1.19 m²)
with `window_wall_type=2` → it pierces alt.1 (12.76 m² cavity
unfilled at age D → U=0.70).

Pre-fix the cascade subtracted ALL openings from the BP's (main+alt)
gross then routed each alt at its FULL gross — over-counting alt's
contribution by 1.19 × U_alt and under-counting main by 1.19 × U_main.
For cert 2636: 1.19 × (0.70 − 0.25) = +0.535 W/K cascade walls excess,
matching the observed cascade walls 20.56 vs worksheet 20.024.

`_window_on_alt_wall` translates the per-window `window_wall_type`
code; the per-BP loop aggregates alt-wall windows into
`alt_window_area_by_bp`, passes that opening area through to
`_alt_wall_w_per_k` (alt.1 only — no cohort cert exercises alt.2
windows), and adds the deducted area back to the main wall's net
area so the conservation invariant holds.

Cohort impact: cert 2636 cascade walls closes from 20.5595 → 20.0240
(spec-exact to 1e-3). Cascade (37) closes from 114.7067 → 114.1846
(Δ +0.0134 from a small thermal-bridging area rounding diff). Cert
2636 SAP shifts from -0.0055 → +0.0323 — joining the cohort cluster
(all 7 ASHP certs now within +0.030 to +0.059 SAP).

The current near-zero cancellation state for cert 2636 was hiding
two opposite cascade errors (over-count walls + under-count η_space).
This slice closes walls correctly; the remaining +0.03 SAP cluster
across all 7 certs is the systematic PSR-denominator HLC×ΔT drift
documented in the handover (not max_output, which BRE confirmed
is 4.39 kW exactly).

Zero regressions on Elmhurst hand-built fixtures, closed-cert Layer
4 1e-4 chain gates, or golden cert residual pins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
0c112852bf Slice 102f-prep.9: RdSAP cantilever exposed-floor detection (closes cert 2636)
RdSAP "first floor over passageway" rule — when an upper storey has
larger floor area than the storey immediately below, the excess
overhangs an unheated space or external air and routes through
Table 20's U_exposed_floor (1.20 W/m²K for age-D + no insulation,
the modal cohort lodging).

Cohort ground-truth: cert 2636 BP0 floor 1 (42.92 m²) − floor 0
(39.18 m²) = 3.74 m². Worksheet (28b) "Exposed floor Main: 3.74 ×
1.20 = 4.4880" matches the spec rule exactly.

`_part_geometry` now computes `cantilever_floor_area_m2` per BP.
The per-BP loop in `heat_transmission_from_cert` injects U×A onto
the floor accumulator and includes the area in (31) total external
area (which feeds (36) thermal bridges).

Gated to avoid false positives on flats and sub-ground multi-storey
shapes:
  - `property_type == "0"` (house) — excludes flats (cert 9501 BP0
    has 6.85 m² floor 0 + 74.43 m² floor 1; the diff is stairwell
    access, not a real cantilever).
  - `excess >= 1 m²` — excludes 2-dp rounding artefacts (cert 001479
    Main BP0 lodges floor 1 = 30.77 vs floor 0 = 30.45 → 0.32 m²
    drift that's not a real cantilever; would otherwise add 0.4
    W/K and break the closed-cert 1e-4 Layer 4 chain gate).
  - `excess / prev_area < 0.25` — excludes sub-ground / partial-
    storey shapes (cert 7536 BP0: 33.7/17.28 = 195% — not a real
    cantilever; floor 0 likely a partial vestibule, not the full
    ground footprint).

Cohort impact: cert 2636 SAP residual closes from +0.4873 → -0.0055
(by far the largest cohort outlier becomes the closest match).
Zero regressions: 654 pass + 10 pre-existing baseline fails (9 cert
001479 hand-built skeleton + 1 FEE). All 7 ASHP certs now cluster
within ±0.06 SAP vs worksheet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00
Khalim Conn-Kowlessar
ba8c7062cd docs: handover update — slices 102f-prep.1-8 shipped, cohort analysis
Refreshes the handover with the full session's work:
- All 7 ASHP cohort certs' MIT cascade matches worksheet (92) at 1e-3.
- 6/7 cohort SAP residuals cluster at +0.03..+0.06 vs worksheet.
- Identified PSR-formula drift root cause: max_output_kw ≈ 4.40 kW
  back-solved from 3 certs' worksheet η_space pins, vs the 4.39 lodged
  at PCDB position 47 (likely a field-position misread; needs BRE web
  cross-check for PCDB 104568 / 102421).
- Identified cert 2636's +0.49 outlier as missing cantilever Exposed
  floor (3.74 m² = upper-floor 42.92 − ground-floor 39.18 area diff).

Recommends Path A (resolve max_output + cantilever to land 1e-4) or
Path B (widen Layer 4 tolerance to 0.1 with documented limitations).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:46 +00:00