Commit graph

214 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
e4bf4e70e8 Slice S0380.153: SAP 10.2 Table 3 — not-separately-timed DHW for solid-fuel boilers
SAP 10.2 Table 3 (PDF p.160) provides three primary-loss rows keyed off
the DHW timing arrangement, the middle row giving winter h=5 / summer
h=3 for "Cylinder thermostat, water heating NOT separately timed".

Solid-fuel boiler systems (Table 4a codes 151-161 — independent boilers,
open-fire + back boilers, closed room heaters with boilers, range cooker
boilers, stoves with boilers) do not ship with dual programmers. Per
SAP 10.2 §9.2.4 (PDF p.27) these are "independent solid fuel boilers,
open fires with a back boiler and room heaters with a boiler" — the
appliance itself is the timer. DHW timing follows the burn schedule,
not a separate cylinder programmer, so the middle Table 3 row applies.

Pre-slice `_separately_timed_dhw` returned True for any cylinder +
non-electric HW fuel cert (the S0380.140 gate), routing solid-fuel
boilers through h=3 year-round (the third row, "Cylinder thermostat,
water heating separately timed"). That under-counted winter (59)m
by ~21 kWh/month × 8 winter months across the affected cohort, with
the under-counted water-heating gain propagating into MIT / SH / SAP.

New gate: `sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES`
(frozenset of {151, 153, 155, 156, 158, 159, 160, 161}) — added before
the existing cylinder-present fallback. The post-S0380.140 electric-
immersion / heat-pump / no-main branches are unchanged. Table 4b
liquid-fuel boilers (101-141) keep the True default — modern gas/oil
installations standardly include dual programmers and the worksheet
confirms `oil 1` / `oil pcdb 1..3` / `pcdb 1` are pinned exact at
h=3 year-round.

Worksheet evidence (heating-systems corpus property 001431):
  - solid fuel 3 (SAP code 160 range cooker boiler + WHC=901
    cylinder thermostat): worksheet (59)m winter = 64.58 (h=5, p=0)
    and summer = 41.92 / 43.31 (h=3, p=0). Cascade closes ΔSAP +0.30
    → −0.0000, Δcost −£6.84 → −0.00, ΔPE −214 → −0.00 (4-metric exact).
  - solid fuel 2 (SAP code 158 closed room heater + back boiler):
    same Table 3 fix narrows ΔSAP +2.06 → +1.86. Remaining ~1.86 SAP
    is the SAP 10.2 §12.4.4 immersion-in-summer rule for back-boilers
    (codes 156, 158) — the worksheet has summer (59)m = 0 because the
    Elmhurst P960 lodges `Summer Immersion: Yes` + the spec routes
    Jun-Sep HW through an electric immersion at η=100%. That's a
    bigger lift (monthly HW efficiency + fuel-split plumbing) and is
    a follow-up slice.

Other corpus variants: no impact (verified via cohort sweep). The
gate is narrow by SAP code so only the 2 affected variants move.

Extended handover suite: 897 pass / 0 fail (+1 from new AAA test).
Pyright net-zero (43 → 43, transient +1 fixed via `EpcPropertyData`
import on the new test's `_cylinder_epc_for` return annotation).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:27:12 +00:00
Khalim Conn-Kowlessar
d4f6ff0f2f Slice S0380.152: SAP 10.2 Table 3 — primary loss for solid-fuel back-boilers
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss" verbatim:

  "Primary circuit loss applies when hot water is heated by a heat
   generator (e.g. boiler) connected to a hot water storage vessel
   via insulated or uninsulated pipes (the primary pipework)."

The spec rule does NOT restrict to Table 4b gas/oil boilers — any
boiler connected to a cylinder via primary pipework incurs the loss.
The cert's `water_heating_code` is the discriminator:

  - WHC=901/902/914 (HW from main heating system) + wet boiler +
    cylinder → primary loss applies (back-boiler / wet boiler heats
    cylinder via primary loop).
  - WHC=903 (HW from a separate electric immersion / secondary) → no
    primary loss even when the main is a wet boiler.

Pre-slice `_primary_loss_applies` only covered Table 4b gas/oil boiler
codes (101-141). Table 4a solid-fuel boiler codes 151-161 (manual /
auto / range-cooker boilers, closed room heater + back-boiler, open
fire + back-boiler, wood pellet + back-boiler) fell through and
primary loss silently went to zero — under-counting §5 (72) water-
heating internal gain by ~74 W cohort-wide for every WHC=901 solid-
fuel back-boiler variant.

Worksheet evidence on the 001431 corpus (all age G, same cylinder):
  - solid fuel 2 (code 158, WHC=901): ws (59) ≈ 505 kWh/yr   → apply
  - solid fuel 3 (code 160, WHC=901): ws (59) ≈ 643 kWh/yr   → apply
  - solid fuel 5 (code 153, WHC=903): ws (59) = 0            → skip
  - solid fuel 4..11 (633/636 non-boilers, WHC=903): skip

The fix:
  - `_primary_loss_applies(...)` gains a `water_heating_code: Optional[int]`
    parameter (default None for back-compat with synthetic tests).
  - New branch after the Table 4b fallback: `_is_wet_boiler_main(main)`
    + `water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES` → True.
  - Call site `_primary_loss_override` passes
    `epc.sap_heating.water_heating_code`.

Heating-systems corpus impact:
  - solid fuel 3 (code 160, WHC=901): +1.31 → +0.30 SAP
                                       PE -918.6 → -214.3 kWh/yr
  - solid fuel 2 (code 158, WHC=901): +2.77 → +2.06 SAP
                                       PE -1241.7 → -754.1 kWh/yr
  - All other variants: unchanged

SF2 doesn't fully close because the worksheet's (59) is winter-only
(0 in summer) but the cascade applies the year-round Table 3 formula
via `_separately_timed_dhw=True` (cylinder + non-electric HW fuel).
Remaining residual is a follow-up — likely a
`_separately_timed_dhw=False` rule for solid-fuel back-boilers (HW
timing tied to the room fire, not separately programmed).

Pyright net-zero (43 → 43). Extended handover suite: 895 → 896 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:59:08 +00:00
Khalim Conn-Kowlessar
fb173cdf3f Slice S0380.151: RdSAP 10 §4.1 Table 5 — extract-fans age-band default
RdSAP 10 Specification §4.1 Table 5 "Ventilation parameters" (PDF p.28)
verbatim — "Extract fans" entry:

  • Number of extract fans if known
  • If number is unknown:
      Not park home:
        Age bands A to E      all cases             → 0
        Age bands F to G      all cases             → 1
        Age bands H to M      up to 2 hab. rooms    → 1
                              3 to 5 hab. rooms     → 2
                              6 to 8 hab. rooms     → 3
                              more than 8 hab. rooms → 4
      Park home:
        Age band F            all cases             → 0
        Age bands G onwards   all cases             → 2

The Elmhurst Summary §12.0 renders "No. of intermittent extract fans: 0"
as the form for *unknown*; every other §2 chimney/flue line item follows
"number if known, or 0 if not present" and the cascade trusts the lodged
value verbatim. Only extract fans have a non-zero age-band default.

Pre-slice the cascade read the lodged 0 verbatim → cohort-wide -0.044
ACH ventilation deficit (= -2.6 W/K HLC, = -1.2% SH demand, = ~-0.3 SAP
per variant). All 25 cascade-OK corpus variants are age G + 4 habitable
rooms + not park home → Table 5 default = 1 fan.

New helper `_rdsap_extract_fans_default(age_band, habitable_rooms, *,
is_park_home)` + wiring in `ventilation_from_cert` applies
`max(lodged, table_5_default)` so the spec minimum fires when lodging
is below it.

Heating-systems corpus impact (25 cascade-OK variants):

  oil 1, oil pcdb 1/2/3            +0.27..+0.29 → EXACT (<1e-4)
  electric 1, solid fuel 5/6/7/8   +0.28..+0.43 → EXACT
  pcdb 1, ashp                     +0.41 / +0.18 → ±0.02
  electric 3/6/7/8/9, sf 4/9/10/11 +0.39..+0.60 → +0.08..+0.12
  electric 5                       -0.74 → -1.18  (Cluster B over-shoot)
  electric 2                       -0.24 → -0.46  (Cluster C HW gap)
  gshp                             +1.09 → +0.94  (Cluster C HW gap)
  solid fuel 2/3                   +3.08 / +1.76  → +2.77 / +1.31

Cluster A (cohort-wide HLC deficit) is closed. The four remaining open
fronts (Clusters B + C) are now visible without offsetting bugs:
  - Cluster B (Table 9c step 12 R sign): electric 5, solid fuel 2/3
  - Cluster C (HW kWh cascade): gshp + electric 2 (Appendix N3)
                                solid fuel 2/3 (Table 4b HW efficiency)

Golden-fixture re-pins:
  cert 0240 (age J, TFA 118): PE +2.18 → +5.80, CO2 +0.13 → +0.32
  cert 0390-2954 (age F, TFA 360): PE -28.27 → -27.97, CO2 -2.74 → -2.71

Pyright net-zero (44 → 44). Extended handover suite: 893 → 895 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:26:53 +00:00
Khalim Conn-Kowlessar
a658f73613 Slice S0380.150: SAP 10.2 §12 / Appendix F2 — 18-hour high-rate for pumps + lighting
SAP 10.2 §12 (PDF p.45 lines 2280-2283):

  "The 18-hour tariff is only for use with electric CPSUs with
   sufficient energy storage to provide space (and possibly water)
   heating requirements for 2 hours. Electricity at the low-rate price
   is available for 18 hours per day, with interruptions totalling 6
   hours per day, with the proviso that no interruption will exceed 2
   hours. The low-rate price applies to space and water heating, while
   electricity for all other purposes is at the high-rate price."

SAP 10.2 Appendix F2 (PDF p.63 lines 3809-3812):

  "F2 Electric CPSUs using 18-hour electricity tariff. The 18-hour
   low rate applies to all space heating and water heating provided
   by the CPSU. The CPSU must have sufficient energy stored to provide
   heating during a 2-hour shut-off period. The 18-hour high rate
   applies to all other electricity uses."

Table 12a Grid 2 omits 18-hour / 24-hour from its 7-hour / 10-hour
table; pre-slice the cascade's `_other_fuel_cost_gbp_per_kwh` fell
through Grid 2's `NotImplementedError` to
`prices.standard_electricity_p_per_kwh` (Table 32 code 30 = 13.19
p/kWh). Per §12 + Appendix F2 the 18-hour rule is explicit fraction =
1.0 at the high rate — pumps, fans, and lighting bill at the 18-hour
high rate (Table 32 code 38 = 13.67 p/kWh).

All 41 heating-systems corpus variants lodge `meter_type='18 Hour'`,
so this gap was cohort-wide. Pre-slice the cascade undercounted
pumps + lighting cost by (13.67 − 13.19) × kWh on every variant:

  oil 1            Δcost -£9.31 → -£6.69   (closed £2.62, pumps 265 +
                                            lighting 282 × £0.0048)
  oil pcdb 1/2     Δcost -£8.32 → -£6.29   (closed £2.03)
  oil pcdb 3       Δcost -£8.91 → -£6.29   (closed £2.62)
  pcdb 1           Δcost -£11.10 → -£9.07  (closed £2.03)
  ashp             Δcost -£5.57 → -£4.22   (closed £1.35, lighting only)
  electric 1..9    Δcost shift ~ -£1.35..+£1.35  (lighting only;
                                                  storage / room-heater
                                                  certs carry pumps_fans
                                                  = 0)
  solid fuel 4..11 Δcost ~ -£1.55 (lighting only)
  gshp             Δcost -£26.48 → -£25.12 (closed £1.35)

Pyright net-zero (43 → 43). Extended handover suite: 892 → 893 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 09:34:09 +00:00
Khalim Conn-Kowlessar
35ea664db8 Slice S0380.149: Table 4f — circulation pump dispatch by pump age + wet-boiler gate
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" — Heating system circulation pump rows:

  Circulation pump, 2013 or later                 41 kWh/yr
  Circulation pump, 2012 or earlier              165 kWh/yr
  Circulation pump, unknown date                 115 kWh/yr

Pre-slice the cascade hardcoded `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY[2]
= 160 kWh/yr` (115 Unknown CH + 45 gas flue fan) for category=2 gas
boilers and fell through to `_DEFAULT_PUMPS_FANS_KWH_PER_YR = 130`
for any other category. Both shortcuts ignored the per-cert
`central_heating_pump_age` lodging AND incorrectly applied
circulation pump electricity to dry electric storage / direct-acting
/ room heater systems (no primary water loop).

Implementation:

  - Mapper: `_elmhurst_pump_age_int` now recognises both "Pre 2013"
    and "2012 or earlier" string forms as the SAP10 enum 1 (Pre 2013).
    Pre-slice "2012 or earlier" silently returned 2 (2013 or later)
    on the entire oil corpus, mis-applying the 41 kWh post-2013
    circulation pump to certs that lodge "2012 or earlier" via
    Elmhurst Summary §14 "Heat pump age".
  - New `_is_wet_boiler_main(main)` gate: identifies wet-boiler
    systems by Table 4a/4b code range (101-141 gas/oil, 151-161
    solid fuel, 191-196 electric boilers), PCDB Table 322 record,
    or category ∈ {1, 2} fallback. Heat pumps (cat 4) return False
    per Table 4f note "Not applicable for electric heat pumps from
    database". Electric storage / direct / room heater codes
    (401-499, 601-699) return False — they have no primary loop.
  - New `_table_4f_circulation_pump_kwh(main)` dispatches on
    `central_heating_pump_age`:
        None / 0 → 115 kWh (Unknown date)
        1        → 165 kWh (Pre 2013 / 2012 or earlier)
        2        →  41 kWh (2013 or later)
  - New `_table_4f_main_1_gas_boiler_flue_fan_kwh(main)` extracts
    the gas-flue-fan 45 kWh logic from the old category dispatch.
    Gated on `_is_wet_boiler_main` + gas fuel + fan_flue_present.
  - Remove `_PUMPS_FANS_KWH_BY_MAIN_CATEGORY` and
    `_DEFAULT_PUMPS_FANS_KWH_PER_YR` constants (the new helpers
    replace both).

Worksheet evidence for the wet-boiler gate:

  electric 1 (code 191 electric boiler):   ws (230c) = 41 kWh ✓
  electric 5 (code 402 electric storage):  ws (231)  =  0 kWh ✗
  solid fuel 2 (code 158 anthracite):      ws (230c) = 41 kWh ✓
  solid fuel 9 (code 636 wood stove):      ws (231)  =  0 kWh ✗
  oil 1 (code 127 condensing oil):         ws (230c) = 165 kWh ✓
  oil pcdb 3 (PCDB 18573):                 ws (230c) = 41 kWh ✓

Cascade impact across heating-systems corpus (vs S0380.148 state):

  | Variant        | SAP Δ        | Cause |
  |----------------|--------------|-------|
  | oil 1          | +0.60→+0.40  | 165 + 100 = 265 ≡ worksheet exact |
  | oil pcdb 1/2   | -0.15→+0.36  | 41 + 100 = 141 ≡ ws exact |
  | oil pcdb 3     | +0.59→+0.39  | same |
  | pcdb 1         | -0.03→+0.50  | 41 + 100 = 141 ≡ ws (was over) |
  | electric 1     | -0.06→+0.45  | 41 (wet electric boiler) |
  | electric 3-9   | -0.1..-1.4→  | 0 (dry storage/UFH) |
  |                | +0.5..+0.6   | was 130 default; now 0 |
  | solid fuel 2-8 | various      | 41 (boilers) — partial closures |
  | solid fuel 9-11| -0.2→+0.5    | 0 (room heaters) — was 130 |

Re-pins reflect spec-correct application. Per
[[feedback-software-no-special-handling]]: pre-slice near-zero pins
were masking pre-existing offsetting cascade gaps; spec correctness
unmasks them.

Golden fixtures impact:

  - cert 0240 (dual oil combi, pump_age=0 Unknown): PE +2.52→+2.18
  - cert 0390 (Firebird PCDF oil, pump_age=0): PE -28.08→-28.27
  - cert 6035 (gas combi, pump_age=2 post-2013): PE +47.29→+46.42

Cert 6035 closer to zero (post-2013 41 kWh < pre-slice 115 unknown).
Cert 0240/0390 small shifts from removing the gas-cat-2 hardcoded
160 path for oil mains.

Tests:
  - test_sap_table_4f_circulation_pump_dispatches_per_central_heating_
    pump_age — asserts oil 1 inputs.pumps_fans_kwh_per_yr == 265
    (165 Pre 2013 + 100 liquid fuel) ± 1.0.
  - test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
    100_kwh (S0380.148) still passes.

Extended handover suite: 892 pass, 0 fail. Pyright net-improved
(removed unused `main_category` variable, file 33→32 errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 09:14:11 +00:00
Khalim Conn-Kowlessar
1b1f45b679 Slice S0380.148: Table 4f — liquid fuel boiler flue fan and fuel pump (100 kWh/yr)
SAP 10.2 Table 4f (PDF p.174) "Electricity for fans, pumps and other
auxiliary uses" row:

  Liquid fuel boiler — flue fan and fuel pump   100 kWh/yr  c) d)

Note c): "Applies to all liquid fuel boilers that provide main heating,
but not if boiler provides hot water only. Where there are two main
heating systems include two figures from this table."

Pre-slice the cascade's `_table_4f_additive_components` only wired:
  - (230a) MEV / MVHR
  - (230e) Main 2 gas-boiler flue fan (45 kWh)
  - (230g) Solar HW pump

The liquid-fuel sibling row was missing — oil 1 worksheet (230d) and
oil pcdb 3 worksheet (230d) both lodge 100 kWh/yr "oil boiler pump"
that the cascade was silently skipping.

Implementation:

  - Add `_LIQUID_FUEL_CODES = frozenset({4, 71, 73, 75, 76})` and new
    `is_liquid_fuel_code(fuel_code)` helper in
    `domain/sap10_calculator/tables/table_32.py`. Mirror of
    `is_electric_fuel_code` — routes through `_to_table_32_code`
    normalisation so Elmhurst-derived Table 32 codes (e.g. code 23
    = bulk wood pellets, solid) don't collide with API enum codes
    (where 23 = B30D community).
  - Extend `_table_4f_additive_components` to add 100 kWh for Main 1
    when `is_liquid_fuel_code(main.main_fuel_type)` returns True
    (`isinstance(int)` guard for the `Union[int, str]` field). Mirror
    the same gate for Main 2 per Note c) "Where there are two main
    heating systems include two figures".
  - LPG is GAS (Table 4b/4f convention, Ecodesign classification) —
    `_LIQUID_FUEL_CODES` deliberately excludes 2/3/5/9 LPG codes.

Cascade impact across heating-systems corpus:

  | Variant   | SAP Δ       | Cost Δ      | PE Δ        |
  |-----------|-------------|-------------|-------------|
  | oil 1     | +1.18→+0.60 | -£27→-£14   | -276→-124   |
  | oil pcdb 1| +0.42→-0.15 |  -£10→+£3.4 |  -84→+67    |
  | oil pcdb 2| +0.42→-0.15 |  -£10→+£3.4 |  -84→+67    |
  | oil pcdb 3| +1.16→+0.59 | -£27→-£14   | -271→-120   |
  | pcdb 1    | +0.57→-0.03 | -£13→+£0.6  | -109→+42    |

Cohort closures: pcdb 1 EXACT (-0.03), oil pcdb 1/2 closed to -0.15.

Golden fixtures impact:

  - cert 0240 (dual-main oil combi 130): SAP integer 73→72 (resid
    +0→-1), PE +1.02→+2.52, CO2 +0.11→+0.14. Dual-main certs add
    2 × 100 = 200 kWh aux per Note c). Cert's published SAP 73
    suggests the dual-main Q_space split (main_heating_fraction)
    may also need wiring — slice candidate.
  - cert 0390 (Firebird PCDF 9005 oil combi): PE -28.50→-28.08
    (CLOSER to zero), CO2 -2.75→-2.73 (CLOSER to zero), SAP +7
    unchanged.

Test:
  test_sap_table_4f_liquid_fuel_boiler_flue_fan_and_fuel_pump_adds_
  100_kwh — asserts oil pcdb 3 inputs.pumps_fans_kwh_per_yr ≥ 230
  (130 base + 100 liquid fuel boiler aux).

Extended handover suite: 891 pass, 0 fail. Pyright net-zero (44=44).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:53:23 +00:00
Khalim Conn-Kowlessar
7dceeff24b Slice S0380.147: Appendix D Eq D1 — Table 4b non-PCDB boilers (winter/summer monthly cascade)
SAP 10.2 Appendix D §D2.1 (2) Equation (D1) (PDF p.57):

  If the boiler provides both space and water heating, and the summer
  seasonal efficiency is lower than the winter seasonal efficiency,
  the efficiency is a combination of winter and summer seasonal
  efficiencies according to the relative proportion of heat needed
  from the boiler for space and water heating in the month concerned:

              Q_space + Q_water
  η_water,m = ───────────────────────────────
              Q_space/η_winter + Q_water/η_summer

  where Q_space (kWh/month) is the quantity calculated at (98c)m
  multiplied by (204) or by (205);
        Q_water (kWh/month) is the quantity calculated at (64)m;
        η_winter and η_summer are the winter and summer seasonal
        efficiencies (from Table 4b).

Pre-slice the cascade only wired Eq D1 for PCDB-tested boilers (the
`pcdb_record` branch in `_apply_water_efficiency`). For non-PCDB
Table 4b boilers (`sap_main_heating_code` 101-141) where the cert
lodges no `main_heating_index_number`, the cascade fell through to
the scalar `water_efficiency_pct` divisor — which resolved via WHC
901 inherit to Table 4b WINTER eff (wrong direction; spec wants the
monthly Eq D1 blend).

This slice:

  - Adds `domain/sap10_calculator/tables/table_4b.py` with the full
    41-row Table 4b (winter, summer) pair dict for codes 101-141
    verbatim from SAP 10.2 PDF p.168 (Table 4b).
  - Refactors `_apply_water_efficiency` parameter from
    `pcdb_record: Optional[GasOilBoilerRecord]` to
    `eq_d1_winter_summer_pct: Optional[tuple[float, float]]` —
    decouples the Eq D1 input from the PCDB record so a Table 4b
    fallback can populate it without faking a PCDB record.
  - Resolves Eq D1 inputs at the call site with priority order:
        1. PCDB Table 105 winter/summer (existing path)
        2. SAP 10.2 Table 4b (PDF p.168) winter/summer when PCDB
           absent + WHC=901 (`_WHC_FROM_MAIN_HEATING`, the spec form
           of "boiler provides both space and water heating").
    §9.4.11 -5pp interlock applies symmetrically to both columns of
    whichever (winter, summer) tuple is resolved.

Oil 1 cert worksheet (217)m verified Jan 81.83 / Apr 81.42 / May
79.94 / Jun-Sep 72.00 / Dec 81.86 — exact back-solve to Eq D1 with
Table 4b code 127 (winter 84, summer 72). Annual HW fuel (219) =
Σ (64)m × 100 / (217)m = 3638.99 kWh/yr ≡ cascade post-slice.

Cascade impact:

  Heating-systems corpus (worksheet-pinned, oil 1 only on pin grid):
    oil 1  SAP +1.76 → +1.18  (Δ -0.59)
           cost -£40.60 → -£27.12  (Δ +£13.48)
           CO2  -129.22 → -55.36   (Δ +73.86 kg/yr)
           PE   -590.02 → -275.52  (Δ +314.50 kWh/yr)
    Remaining oil 1 residual is Table 4f auxiliary energy (cascade
    pumps_fans 130 kWh vs worksheet 265 kWh — missing the oil-boiler
    pump 100 kWh + CH pump 130 vs ws 165). Follow-up slice.

  Golden fixtures (cert-pinned, integer-rounded PE):
    cert 0240 (dual oil combi 130, no cylinder): PE +0.05 → +1.02
    cert 6035 (gas combi 104, no cylinder):      PE +46.10 → +47.29
    Both shifts reflect spec-correct Eq D1 now firing for non-PCDB
    combi-no-cylinder configs. The pre-slice near-zero pin on cert
    0240 was masking offsetting cascade gaps (likely Table 4f
    auxiliary energy and/or dual-main Q_space split per (98c)m ×
    (204) which the cascade currently treats as full demand).

Following [[reference-unmapped-sap-code]] discipline, the new Table
4b dict is the canonical spec-source — `domain.sap10_ml.sap_
efficiencies._SPACE_EFF_BY_CODE` still carries the winter column for
the ML feature cascade and is left in place per the sap10_ml
deprecation plan (separate migration).

Test:
  test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_
  table_4b_boiler_with_cylinder — asserts cert 1431 oil 1 HW fuel
  annual = 3638.99 ± 1.0 kWh/yr (matches worksheet (219)).

Extended handover suite: 890 pass, 0 fail. Pyright net-zero (44=44).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:22:46 +00:00
Khalim Conn-Kowlessar
bd193e06fc Slice S0380.146: Table 3 primary loss — Table 4b non-PCDB regular boilers with cylinder
SAP 10.2 Table 3 (PDF p.160) "Primary circuit loss":

  "Primary circuit loss applies when hot water is heated by a heat
   generator (e.g. boiler) connected to a hot water storage vessel via
   insulated or uninsulated pipes (the primary pipework). Primary loss
   is set to zero for the following:
       Electric immersion heater
       Combi boiler ...
       CPSU ..."

A Table 4b regular (non-combi, non-CPSU) gas or liquid-fuel boiler
feeding a cylinder is in neither zero-loss list, so primary loss must
apply. Pre-slice the Elmhurst-path fallback in `_primary_loss_applies`
only covered PCDB Table 322 records (S0380.142) — when the cert lodges
a Table 4b code (e.g. oil 1 sap_main_heating_code 127 "Condensing oil
boiler") with no PCDB index and no `main_heating_category` lodgement,
primary loss silently fell through to zero.

This slice extends the Elmhurst-path fallback in `_primary_loss_applies`
to fire when `sap_main_heating_code` is in the Table 4b code range
(101-141) and NOT in the combi/CPSU sub-row exclusion set per Table 3:

  Combi codes:  103, 104, 107, 108, 112, 113, 118, 128, 129, 130
  CPSU codes:   120, 121, 122, 123

Oil 1 worksheet (59)m daily rate = 1.3972 kWh/day uniform = 14 ×
[0.0245 × 3 + 0.0263] (uninsulated pipework, has cylinder thermostat +
separately timed DHW → h=3 winter & summer per Table 3 split). Annual
sum = 365 × 1.3972 ≈ 510 kWh/yr — matches the worksheet's (59) annual.

Cascade impact on heating-systems corpus:
  - oil 1 SAP residual +2.66 → +1.76 (Δ -0.90)
         cost  -£61.24 → -£40.60 (Δ +£20.64)
         CO2   -242.27 → -129.22 (Δ +113.05 kg/yr)
         PE  -1050.49 → -590.02   (Δ +460.47 kWh/yr)

Only the oil 1 variant moves — every other cascade-OK variant either
already routes primary loss via the PCDB Table 322 branch (oil pcdb 1/
2/3, pcdb 1) or via the boiler-category {1,2} branch. The other oil
codes 124/125/126/131/132 + range-cooker codes 133-141 are gated for
free by the same dispatch when their certs surface in future cohorts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:20:50 +00:00
Khalim Conn-Kowlessar
b1478cff63 Slice S0380.145: Table 4e temperature adjustment — apply (92)m → (93)m offset per Table 9c step 8
SAP 10.2 Table 4e (PDF p.170-173) "Heating system controls":

  3. The 'Temperature adjustment' modifies the mean internal
     temperature and is added to worksheet (92)m.

SAP 10.2 Table 9c step 8 (PDF p.184): "Apply adjustment to the mean
internal temperature from Table 4e, where appropriate".

Pre-slice the cascade hardcoded `control_temperature_adjustment_c
=0.0` at all three call sites of `mean_internal_temperature_monthly`
and `space_heating_section_with_results`. The §8 heat loss calc
therefore drove off (92)m unchanged → §8 SH demand under-counted on
every cert whose `main_heating_control` lodges a non-zero adjustment.

Table 4e adjustments by code (full p.170-173 coverage):

  Group 0 — No heating system:
    2699: +0.3
  Group 1 — Boilers with radiators/UFH (+ micro-CHP):
    2101, 2102: +0.6   (no thermo / programmer-only)
    2103..2113: 0
  Group 2 — Heat pumps:
    2201, 2202: +0.3
    2203..2210: 0
  Group 3 — Heat networks:
    2301, 2302: +0.3
    2303..2314: 0
  Group 4 — Electric storage:
    2401 (Manual charge):                  +0.7
    2402 (Automatic charge):               +0.4
    2403 (Celect):                         +0.4
    2404 (HHR controls):                    0
  Group 5 — Warm air:
    2501, 2502: +0.3
    2503..2506: 0
  Group 6 — Room heaters:
    2601: +0.3
    2602..2605: 0
  Group 7 — Other systems:
    2701, 2702: +0.3
    2703..2706: 0

New `_control_temperature_adjustment_c(main)` helper consults
`_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE` (52 entries, full Table 4e
coverage). Strict-raises `UnmappedSapCode` on present-but-unmapped
codes per [[reference-unmapped-sap-code]] so spec-coverage gaps
surface at test time. The helper is wired to all three call sites
of the MIT/SH orchestrators in cert_to_inputs.

Corpus impact — closes the +2.5 SAP cluster substantially:

  Variant | control |  pre  →  post  | delta
  ------- | ------- | -------------- | -----
  e3 (401)|  2401   | +2.55 → -0.09  | -2.46  (massive close)
  e6 (404)|  2402   | +1.33 → -0.17  | -1.50
  e7 (408)|  2402   | +1.29 → -0.20  | -1.49
  e2 (524)|  2502   | +0.47 → -0.18  | -0.65
  e5 (402)|  2402   | +0.07 → -1.43  | -1.50  (regressed —
                                              previously net-zero
                                              from offsetting bugs)

Cumulative |ΔSAP| across these 5: 5.71 → 2.07 (-3.64 pts closed).
electric 3 / 6 / 7 / 8 / 9 now all within 0.20 SAP of worksheet.
Golden fixtures unchanged (API certs in those tests don't lodge
non-zero-adjustment control codes; suite stays 888 pass).

Extended handover suite: 888 pass, 0 fail (was 887 + 1 new AAA test).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:50:10 +00:00
Khalim Conn-Kowlessar
ec6661cbb6 Slice S0380.144: Table 11 — per-Table-4a-code secondary fraction dispatch for electric storage heaters + remove code 408 from §A.2.2 forced-secondary set
SAP 10.2 Table 11 (PDF p.188) "Fraction of heat supplied by
secondary heating systems" — the "Electric storage heaters (not
integrated)" row splits by Table 4a sub-type:

  - not fan-assisted:                                       0.15
  - fan-assisted:                                           0.10
  - high heat retention (as defined in 9.2.8):              0.10

Plus separate rows:
  Integrated storage/direct-acting electric systems:        0.10
  Electric room heaters:                                    0.20
  Other electric systems (e.g. underfloor):                 0.10

Cross-referenced with SAP 10.2 Table 4a (PDF p.166) Electric
storage codes:

  401: Old (large volume) storage heaters     — not fan-assisted
  402: Slimline storage heaters                — not fan-assisted
  403: Convector storage heaters               — not fan-assisted
  404: Fan storage heaters                     — fan-assisted
  405: Slimline + Celect                       — not fan-assisted
  406: Convector + Celect                      — not fan-assisted
  407: Fan + Celect                            — fan-assisted
  408: Integrated storage + direct-acting      — "Integrated"
  409: High heat retention                     — HHR
  421: Underfloor heating                      — "Other electric"

Pre-slice the cascade defaulted `_secondary_fraction` to 0.10 for
every forced electric-storage code (Elmhurst mapper leaves
`main_heating_category=None`, dispatch falls through to the
`_SECONDARY_HEATING_FRACTION_DEFAULT` 0.10), missing the 0.15
not-fan-assisted sub-row on codes 401/402/403/405/406.

Two compounding spec-citable fixes:

(a) New `_SECONDARY_FRACTION_BY_ELECTRIC_STORAGE_CODE` dispatch dict
    consulted before the category-based lookup in
    `_secondary_fraction`. Routes each Table 4a 4xx code to its
    Table 11 sub-row fraction.

(b) Code 408 removed from `_FORCE_SECONDARY_FOR_MAIN_CODES`.
    SAP 10.2 §A.2.2 (PDF p.~189) verbatim: "This applies to main
    heating codes 401 to 407, 409 and 421" — 408 is explicitly
    NOT in the spec's forced list. The integrated storage+direct-
    acting heater's direct-acting element acts as the secondary
    already, so the calculation doesn't add another.

Corpus impact (electric variants — Elmhurst mapper path):

- electric 3 (SAP 401): sec_frac 0.10 → 0.15; CO2 -117.84 →
  -108.88; PE -1121.97 → -1093.18. SAP / cost residual unchanged
  because the off-peak meter routes the cost calc through the
  `_ZERO_FUEL_COST_FOR_OFF_PEAK` sentinel + legacy scalar-field
  math which bills main and secondary at the same off-peak low
  rate (7.41 p/kWh) — main-vs-secondary split is cost-neutral.
- electric 5 (SAP 402): sec_frac 0.10 → 0.15; CO2 -11.08 → -2.48;
  PE -161.03 → -133.36. Same cost-invariance.
- electric 7 (SAP 408): forced-secondary removed → cascade secondary
  fuel kWh 891 → 0 (matches worksheet); CO2 -37.86 → -53.57;
  PE -498.47 → -549.37. SAP residual unchanged (same off-peak
  cost-invariance).
- electric 4/6/8/9: no change (categories 404/409/421 keep their
  existing 0.10 dispatch).

The remaining +2.55 SAP residual on electric 3 (+1.29 on electric 7)
is now confirmed to be driven by space-heating DEMAND undercount
(cascade SH demand 10083 kWh vs worksheet 11088 kWh for electric 3;
8914 vs 9529 for electric 7), not by sec_frac dispatch. That's a
separate slice — likely §9 MIT calc or §8 gains/HLC for storage-
heater R values, follow-up after this slice.

Extended handover suite: 887 pass, 0 fail (was 886 + 1 new AAA test).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:27:46 +00:00
Khalim Conn-Kowlessar
eda6f449e4 Slice S0380.143: RdSAP 10 §10.11 Table 29 — derive cylinder insulation defaults from construction age band when §15.1 lodges "No Access"
RdSAP 10 Specification §10.11 Table 29 page 56 — "Heating and hot
water parameters" → row "Hot water cylinder insulation if not
accessible":

  Age band of main property A to F: 12 mm loose jacket
  Age band of main property G, H:   25 mm foam
  Age band of main property I to M: 38 mm foam

Pre-slice the Elmhurst mapper passed through cylinder_insulation_type
and cylinder_insulation_thickness_mm as None whenever §15.1 lodged
"Cylinder Size: No Access" (the inaccessible-cylinder lodging form)
because the Summary doesn't carry the measured insulation label /
thickness on inaccessible cylinders. The cascade's §4 (56)m water
storage loss override at `_cylinder_storage_loss_override` then
returned None (gates on `insulation_type == _CYLINDER_INSULATION_
TYPE_FACTORY` + thickness lodged), so the worksheet's (56)m sum was
dropped entirely from (62)m.

Cert pcdb 1 (corpus 001431, Potterton KOA PCDB 716 + 110 L cylinder
+ §15.1 "No Access" + age G 1983-1990) exposes the gap: worksheet
(56)m monthly ≈ 59.06 kWh ((51) factor 0.024 from Note 1 formula
L = 0.005 + 0.55 / (t + 4) at t = 25 mm) × (52) volume factor 1.0294
× (53) Table 2b temperature factor 0.702 — annual sum ≈ 695 kWh,
missing from the pre-slice cascade entirely.

New helper
`_resolve_elmhurst_inaccessible_cylinder_insulation(age_band)` in
`datatypes/epc/domain/mapper.py` returns the
`(insulation_type_code, thickness_mm)` tuple for age G/H (factory
foam, 25 mm) and I/J/K/L/M (factory foam, 38 mm). Age bands A-F
(loose jacket, 12 mm) raise `UnmappedElmhurstLabel` — no current
Elmhurst corpus member is age A-F with §15.1 = "No Access", and the
loose-jacket SAP10 cylinder_insulation_type enum value is not yet
plumbed into the calculator's `cylinder_storage_loss_factor_table_2`
dispatch (only factory=1 is exercised). The strict-raise mirrors the
[[reference-unmapped-sap-code]] pattern so a future fixture forces
the loose-jacket extension explicitly.

`_map_elmhurst_sap_heating` calls the resolver before constructing
SapHeating; the accessible-cylinder path stays unchanged
(measured label + thickness from §15.1).

Corpus impact:

- pcdb 1 (only "No Access" cylinder variant in the corpus):
  SAP +2.86 → +0.57; cost -£63.22 → -£12.55; CO2 -328.74 → -51.19;
  PE -1257.97 → -109.46. The remaining residual is a ~1.3% cascade-
  side undercount on space-heating demand (cascade SH 7900 kWh vs
  worksheet (98c) 8004 kWh) plus minor pumps/fans rate noise — well
  within the spec-cascade floor.

Combined with S0380.141 (§9.4.11 -5pp interlock on SH + Eq D1) and
S0380.142 (§4 lines 7700/7702 cylinder-presence gates), the
pre-slice pcdb 1 residual SAP +6.95 closes to +0.57 (-92% magnitude),
cost -£157.61 to -£12.55, PE -3135.30 to -109.46.

Extended handover suite: 886 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:03:10 +00:00
Khalim Conn-Kowlessar
7f9074fca9 Slice S0380.142: §4 (61)m/(59)m cascade — cylinder presence gates combi=0 + primary loss applies for PCDB Table 322 boilers
SAP 10.2 §4 line 7702 (PDF p.137):

  Combi loss for each month from Table 3a, 3b or 3c (enter '0' if
  not a combi boiler)

SAP 10.2 Table 3 (PDF p.160) zero-loss list for primary circuit loss:

  Electric immersion heater
  Combi boiler (including when it is part of a combined heat pump and
  boiler package and provides all the hot water)
  CPSU (including electric CPSU)
  Boiler and thermal store within a single casing
  Separate boiler and thermal store connected by no more than 1.5 m
  of insulated pipework
  Direct-acting electric boiler
  Heat pump (...) with hot water vessel integral to package

Combi boilers are defined by Table 3's zero-loss list entry: they
provide instantaneous DHW with no storage vessel. A cert that lodges
a hot-water cylinder therefore has a non-combi heat generator —
the cylinder bypasses any instantaneous-DHW capability and the
boiler acts as a regular boiler for the DHW circuit.

Two compounding gaps for PCDB Table 322 (gas/oil boiler) records
with a lodged cylinder:

(a) (61)m combi loss: pre-slice the cascade routed every PCDB record
    through `pcdb_combi_loss_override` regardless of cylinder
    presence. For PCDB regular boilers (subsidiary_type=0, store_
    type=0, separate_dhw_tests=0) this dispatched to Table 3a row 1
    "Instantaneous without keep-hot" — 600 kWh/yr. Cert pcdb 1
    (Potterton KOA PCDB 716 + 110 L cylinder) exposed this: worksheet
    (61)m = 0 ; cascade was lodging 600 kWh/yr keep-hot loss on a
    regular oil boiler.

(b) (59)m primary loss: `_primary_loss_applies` gated on
    `main_heating_category in {1, 2}`. The Elmhurst path leaves
    `main_heating_category=None`, so the gate returned False even
    when the cert lodged a PCDB Table 322 (gas/oil boiler) record +
    a cylinder. Worksheet (59)m sum ~1177 kWh ; cascade was zero.

Fix:

- `_water_heating_worksheet_and_gains` now zeroes combi_loss_override
  whenever `epc.has_hot_water_cylinder` is True (top-level gate
  preceding the `pcdb_combi_loss_override` dispatch). Preserves the
  existing non-cylinder fallback for HP / no-PCDB / community-heat
  certs that lack a main_heating_category lodgement.

- `_primary_loss_applies` extends the Elmhurst-path fallback: when
  `main_heating_index_number` resolves to a PCDB Table 322 record,
  return True (the cert is implicitly a boiler — Table 3 row 1 covers
  any "heat generator (e.g. boiler) connected to a hot water storage
  vessel via insulated or uninsulated pipes").

Corpus impact:

- pcdb 1 (Potterton KOA + cylinder, the only PCDB Table 322 + cylinder
  combination in the corpus): SAP +3.40 → +2.86; cost -£75.68 →
  -£63.22; CO2 -397.02 → -328.74; PE -1601.74 → -1257.97.

- Golden cert 0390-2954-3640-2196-4175 (Firebird oil combi PCDF 9005
  + cylinder): PE -26.37 → -28.50; CO2 -2.55 → -2.75. Combi-loss
  removal (-600 kWh/yr) exceeded the primary-loss gain (~5-10 kWh
  given the cert's insulated pipework + thermostat lodging), so the
  net (62) shifted down. Direction is more spec-correct: the spec
  treats a combi feeding a cylinder as a regular boiler for DHW,
  matching the (61)m=0 + (59)m>0 worksheet behaviour.

Extended handover suite: 885 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:53:19 +00:00
Khalim Conn-Kowlessar
6636f1c333 Slice S0380.141: §9.4.11 boiler interlock — extend −5pp adjustment to both space-heating efficiency and the PCDB Equation D1 water cascade
SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock":

  For the purposes of the SAP, an interlocked system is one in which
  both the space and stored water heating are interlocked. If either
  is not, the 5% seasonal efficiency reduction is applied to both
  space and water heating; if both are interlocked no reductions are
  made.

Table 4c (PDF p.169-170) lodges -5 for both Space and DHW columns on
the "No boiler interlock — regular boiler" row. Pre-slice the cascade
applied the -5pp adjustment ONLY to the `water_eff` scalar fallback
(`cert_to_inputs.py:4354`) and missed:

  (a) the SH efficiency path (cascade kept the raw PCDB winter eff for
      space heating);
  (b) the PCDB Equation D1 monthly cascade (Eq D1 received raw
      winter/summer values without the -5pp adjustment).

RdSAP §3 (PDF p.57) defines boiler interlock as "Assumed present if
there is a room thermostat and (for stored hot water systems heated
by the boiler) a cylinder thermostat. Otherwise not interlocked."
Cert pcdb 1 (Potterton KOA PCDB 716 + 110 L cylinder + Cylinder Stat:
No) reproduces the pattern: worksheet (210) = 60% = PCDB winter
65 - 5; worksheet (217)m monthly Eq D1 pivots on (winter 60,
summer 48) not (65, 53).

The SH path is further gated on `pcdb_main is not None` because
§9.4.11 only applies to "gas and liquid fuel boilers" — cert 000565
(ASHP Main 1) keeps its raw SH eff. The combi-fed-cylinder DHW path
(cert 000565 WHC 914 to PCDB combi Main 2) continues to receive its
existing -5pp via the `water_pcdb_main` gate (unchanged).

Corpus impact: pcdb 1 SAP residual +6.95 → +3.40; cost -£157.61 →
-£75.68; CO2 -845.81 → -397.02; PE -3135.30 → -1601.74. No other
variant has PCDB main + cylinder + no thermostat, so the other 24
corpus pins are unchanged.

Extended handover suite: 884 pass, 0 fail (was 883 + 1 new AAA test
pinning the §9.4.11 SH eff path).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:33:01 +00:00
Khalim Conn-Kowlessar
068088bc2f Slice S0380.140: §4 cylinder storage loss — extractor picks up §16 thermostat lodging + Table 2b note b restricts ×0.9 to boiler/warm-air/HP systems
Two compounding bugs were over-counting the SAP 10.2 §4 (56)m cylinder
storage loss by ~76 kWh/yr across all 17 cylinder-with-immersion
corpus variants (cascade HW kWh 2460.40 vs worksheet 2384.12):

(1) **Extractor gap.** Elmhurst Summary §15.1 "Hot Water Cylinder"
    block lodges `Cylinder Size` / `Insulation Thickness` but NOT
    `Cylinder Thermostat`. The thermostat is lodged separately in
    §16 "Recommendations" as `Cylinder thermostat (Already installed)`.
    The extractor only searched §15.1, so `cylinder_thermostat`
    resolved to None for every variant on property 001431. The
    cascade then defaulted `has_cylinder_thermostat=False`, applying
    SAP 10.2 Table 2b's ×1.3 "no thermostat" multiplier.

(2) **Cascade spec gap.** `_separately_timed_dhw` returned True for
    any cylinder-lodged cert regardless of HW fuel. Per SAP 10.2
    Table 2b note b) (PDF p.159):

    > "Multiply Temperature Factor by 0.9 if there is separate time
    >  control of domestic hot water (boiler systems, warm air systems
    >  and heat pump systems)"

    Electric immersion is NOT in the bracketed list — the ×0.9
    reduction is restricted to boiler / warm-air / HP systems. Pre-
    slice the cascade over-applied ×0.9 on electric-immersion certs.

Combined, the cascade computed TF = 0.60 × 1.3 × 0.9 = 0.702 vs the
worksheet's TF = 0.60 (base — thermostat present, immersion exempt).
After both fixes the cascade HW kWh matches the worksheet's (64) at
1e-3 precision (2384.116 vs 2384.12).

Corpus impact (16 cylinder-with-immersion variants on 18-hour meter):

| variant      | SAP_c shift | Cost shift |
|--------------|------------:|-----------:|
| electric 1   | -0.20 →   -0.06 |  -£3.34 |
| electric 2   | -1.27 →   +0.47 |  -£4.44 |
| electric 3   | +2.42 →   +2.55 |  -£2.91 |
| electric 5   | -0.06 →   +0.07 |  -£3.06 |
| electric 6   | +1.19 →   +1.33 |  -£3.20 |
| electric 7   | +1.14 →   +1.29 |  -£3.35 |
| electric 8   | -0.41 →   -0.26 |  -£3.50 |
| electric 9   | -0.24 →   -0.12 |  -£2.91 |
| solid fuel 4-11 | -0.45..-0.09 → -0.29..+0.10 | -£3 to -£4 |

The HW kWh line closes cleanly; some SAP residuals sign-flip slightly
because the cascade's now-correct HW kWh exposes the SH+Sec demand
mismatch for storage heaters (electric 3/6/7 — open driver is the
Table 11 `main_heating_category=None` default for codes 401/402,
queued for a mapper-side slice).

Tests:
- new AAA test `test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b`
- 16 corpus pins re-tightened (8 electric + 8 solid fuel)

Extended handover suite: 883 pass (was 882; +1 new test), 0 fail.
Pyright net-zero on touched files (43 → 43 errors, all pre-existing).

Per [[feedback-spec-citation-in-commits]] +
[[feedback-spec-floor-skepticism]] (the "HW +76 kWh uniform overcount"
across 17 variants traced to TWO spec-citable defaults the cascade
was getting wrong, not a precision floor).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:03:58 +00:00
Khalim Conn-Kowlessar
c4db37db19 Slice S0380.139: route _is_off_peak_meter through tariff_from_meter_type canonical dispatch (bare '18 Hour' lodging)
Pre-slice `_is_off_peak_meter` carried its own string-dispatch that
only recognised the RdSAP 10 long form `"off-peak 18 hour"`. The bare
`"18 Hour"` lodging (Elmhurst Summary §14.2 surface form, lodged by
41/41 corpus variants) fell into the catch-all `return False` branch.
That mis-classified every 18-hour cert as non-off-peak for the
secondary / PV cost paths and billed electric secondary heating at
standard 13.19 p/kWh (Table 32 code 30) instead of the 18-hour low
rate 7.41 p/kWh (Table 32 code 40).

The fix routes `_is_off_peak_meter` through `tariff_from_meter_type`
so every lodging form already recognised there (int 1/4/5, `"18 Hour"`,
`"off-peak 18 hour"`, `"Dual"`, `"Dual (24 hour)"`, numeric strings)
is consistently classified. Single (code 2) stays standard; Unknown
(code 3) retains the heuristic "electric end-uses on Unknown meters
typically come from E7-eligible dwellings whose tariff the assessor
couldn't pin down — apply off-peak". Per
[[feedback-zero-error-strict]] the now-dead `_RDSAP_DEFINITELY_OFF_PEAK`
frozenset is deleted (canonical dispatch covers the same codes).

Spec citation per [[feedback-spec-citation-in-commits]]:

> RdSAP 10 §17 page 85 row 10-2 (Electricity meter): "Dual / single /
>  10-hour / 18-hour / 24-hour / unknown"
> RdSAP 10 §12 page 62: "if the meter is dual 18-hour/24-hour it is
>  18-hour/24-hour tariff"

Corpus impact (6 storage-heater / underfloor variants on forced
secondary):

| variant | SAP code | old ΔSAP | new ΔSAP |
|---|---:|---:|---:|
| electric 3 | 401 | -0.10 | +2.42 |
| electric 5 | 402 | -2.48 | -0.06 |
| electric 6 | 404 | -1.14 | +1.19 |
| electric 7 | 408 | -1.08 | +1.14 |
| electric 8 | 409 | -2.54 | -0.41 |
| electric 9 | 421 | -2.76 | -0.24 |

Total absolute SAP residual across the cluster: 10.10 → 5.46.

The 3 sign-flipped variants (electric 3/6/7) surface a separate
cascade bug: `_secondary_heating_fraction_for_category` defaults to
0.10 when the mapper leaves `main_heating_category=None` for electric
storage, but the worksheet for codes 401/402 uses 0.15 = Table 11
Cat 7. Mapper-side fix queued.

Tests:
- new AAA test `test_is_off_peak_meter_recognises_bare_18_hour_lodging`
  covers 7 lodging forms (bare, lowercase, long-form, Single, standard,
  Unknown+electric, Unknown+non-electric)
- 6 corpus pins re-tightened (electric 3/5/6/7/8/9)

Extended handover suite: 882 pass (was 881; +1 new test), 0 fail.
Pyright net-zero on touched files (43 → 43 errors, all pre-existing).

Per [[reference-unmapped-sap-code]] strict-dispatch routing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:39:43 +00:00
Khalim Conn-Kowlessar
a830e85565 Slice S0380.138: route every off-peak callsite through the per-tariff Table 32 low-rate (electric +5..+9 SAP cluster + spillover)
Pre-slice every off-peak callsite in `cert_to_inputs.py` —
`_space_heating_fuel_cost_gbp_per_kwh`, `_hot_water_fuel_cost_gbp_per_kwh`,
`_secondary_fuel_cost_gbp_per_kwh`, `_pv_dwelling_import_price_gbp_per_kwh` —
hardcoded `prices.e7_low_rate_p_per_kwh = 5.50` p/kWh (Table 32 code 31,
the 7-hour low rate) regardless of the cert's actual tariff. Every
18-hour cert was thereby under-charged 1.91 p/kWh × off-peak kWh on
its space-heating, hot-water, and secondary-heating cost rows.

Per RdSAP 10 §19 Table 32 (p.95):

> "Electricity ... 7-hour tariff (low rate / off-peak) — code 31 5.50 p/kWh
>  ... 10-hour tariff (low rate) — code 33 7.50 p/kWh
>  ... 18-hour tariff (low rate) — code 40 7.41 p/kWh
>  ... 24-hour tariff — code 35 6.61 p/kWh"

The fix routes through a new `_off_peak_low_rate_gbp_per_kwh(tariff)`
helper that reads the existing per-tariff Table 32 lookup
(`_TARIFF_HIGH_LOW_RATES_P_PER_KWH`). A companion
`_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)` covers
the secondary / PV paths that detect off-peak via the
`_is_off_peak_meter` heuristic (RdSAP meter code 3 = Unknown is treated
as off-peak for electric end-uses), falling back to the SEVEN_HOUR rate
when the meter resolves to STANDARD — codifying the heuristic that the
literal 5.50 constant used to embed.

Per [[feedback-zero-error-strict]] the now-dead
`PriceTable.e7_low_rate_p_per_kwh` field is deleted (no fallback can
silently re-introduce the 5.50 hardcode); the field's docstring +
RDSAP_10_TABLE_32_PRICES instantiation update to point at the new
helpers.

Corpus closure (all 18-hour cohort):

- 8 electric variants — SAP +5.85..+9.64 → -0.10..-2.76; cost
  -£135..-£222 → +£2..+£64
- ashp +5.67 → +0.24 SAP (-£131 → -£5.57)
- gshp +5.16 → +1.15 SAP (-£119 → -£26)
- solid fuel 4..11 — SAP +1.59..+2.04 → ±0.45 (cost ±£10)

Golden 0240 PV path also closes (was raising UnmappedSapCode on
Unknown-meter probe — surfaced an unreachable PV literal that the
meter-heuristic helper now resolves).

Tests:
- new AAA test `test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7`
  exercising the EIGHTEEN_HOUR fallback at the helper level
- 19 corpus pins re-tightened (8 electric + ashp + gshp + 8 solid-fuel
  + golden 0240's implicit pin)

Extended handover suite: 881 pass (was 880; +1 new test), 0 fail.
Pyright net-zero on touched files (43 → 43 errors, all pre-existing).

Per [[feedback-spec-citation-in-commits]] +
[[feedback-worksheet-not-api-reference]] +
[[reference-unmapped-sap-code]].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:22:54 +00:00
Khalim Conn-Kowlessar
3542186f18 Slice S0380.137: extend Table 4a R-dispatch to electric storage / direct-acting / underfloor / ceiling (cluster)
Continuation of S0380.135's Table 4a per-heating-system responsiveness
dispatch (`_RESPONSIVENESS_BY_SAP_CODE` in cert_to_inputs.py). The
solid-fuel coverage closed 10 corpus variants; this slice extends the
dispatch to the electric heating SAP code ranges from SAP 10.2 Table
4a (PDF p.170):

  401  Old (large volume) storage heaters             R=0.00
  402  Slimline storage heaters                       R=0.20
  403  Convector storage heaters                      R=0.20
  404  Fan storage heaters                            R=0.40
  405  Slimline storage heaters + Celect-type ctrl    R=0.40
  407  Fan storage heaters + Celect-type ctrl         R=0.60
  408  Integrated storage+direct-acting heater        R=0.60
  409  High heat retention storage heaters (§9.2.8)   R=0.80
  421  In concrete slab (off-peak only)               R=0.00
  422  Integrated (storage+direct-acting)             R=0.25
  423  Integrated with low off-peak                   R=0.50
  424  In screed above insulation                     R=0.75
  425  In timber floor / immediately below covering   R=1.00
  515  Electricaire system                            R=0.75
  691  Panel, convector or radiant heaters            R=1.00
  694  Water- or oil-filled radiators                 R=1.00
  701  Electric ceiling heating                       R=0.75

A few electric storage codes (402, 403, 405, 407) carry a *different*
R value in the 24-hour tariff section of Table 4a vs the off-peak
section (e.g. Slimline 402 = R=0.20 off-peak / R=0.40 24-hour). This
dict captures the off-peak value as the default because the 24-hour
tariff is rare in the corpus (no variant lodges it). If a 24-hour-
tariff cert surfaces with one of these codes the dispatch needs to be
promoted to a (sap_code, tariff) lookup; until then the off-peak
default applies.

Heating-systems corpus impact — 6 electric corpus variants re-pinned:

  variant       SAP  R    ΔSAP   was       ΔPE      was
  electric 3    401  0.00 +9.43  +14.70   -1059   -3189
  electric 5    402  0.20 +6.76  +10.97     -96   -1798
  electric 6    404  0.40 +7.82  +10.97    -494   -1770
  electric 7    408  0.60 +7.58   +9.68    -428   -1277
  electric 8    409  0.80 +5.84   +6.89    +200    -224
  electric 9    421  0.00 +6.77  +12.03    +154   -1976

3/6 PE residuals close to ±200 kWh (electric 5/8/9). The remaining
+5..+9 SAP residuals across all electric variants suggest a separate
shared cascade gap (likely Table 12a high/low-rate fraction or pumps/
fans electric handling — fuel cost is consistently under-counted by
~£100-£220 across the cluster). Queued for follow-up.

electric 1 (SAP 191 Direct acting electric boiler) and electric 2
(SAP 524 Air source heat pump) unchanged — both have spec R=1.0
already (matched the Table 4d emitter fallback).

Extended handover suite: 880 pass / 0 fail (+1 new AAA test
covering the 17 electric R-dispatch entries).

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

No golden fixture impact — no golden cert lodges a covered electric
SAP code via the cascade path that would shift residuals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:03:46 +00:00
Khalim Conn-Kowlessar
4d004790db Slice S0380.136: route _is_electric_main / _is_electric_water via the canonical T32-first normaliser (dual-fuel closure)
`_is_electric_main` and `_is_electric_water` hand-rolled a literal set
check `code in {10, 25, 29}` ∪ `{30..40}` to classify a fuel code as
electricity. The set conflated two enums:

  - {10, 25, 29}    — API enum codes (epc_codes.csv row main_fuel):
                        10 = electricity (backwards compat)
                        25 = electricity (community)
                        29 = electricity (not community)
  - {30, 31, ..., 40} — Table 32 codes (RdSAP 10 spec p.95):
                          30 = standard tariff
                          31/32 = 7-hour low/high
                          33/34 = 10-hour low/high
                          35 = 24-hour heating
                          38/40 = 18-hour high/low

API enum codes 1-29 collide with Table 32 codes 1-29 for unrelated
fuels — API 10 = "electricity" vs Table 32 10 = "dual fuel (mineral +
wood)". S0380.135's EES dispatch sets `main_fuel_type` to Table 32
codes (BDI → 10 for dual fuel), so a dual-fuel main was silently
mis-classified as electric. The `_space_heating_fuel_cost_gbp_per_kwh`
tariff branch then re-routed solid fuel 6's space heating cost through
the 18-hour-low electric rate (5.50 p/kWh) instead of dual-fuel 3.99
p/kWh — solid fuel 6 SAP residual −7.38 → −11.37 in S0380.135.

The fix promotes the existing `table_32._is_electric_code` to public
`is_electric_fuel_code` and routes both `_is_electric_main` and
`_is_electric_water` through it. The canonical helper normalises a
fuel code via T32-first then API-translate fallback (same convention
as `unit_price_p_per_kwh`), so a Table-32-code-10 dual-fuel main
classifies as non-electric correctly.

Subtle behaviour change: API enum code 25 ("electricity (community)")
maps via API_FUEL_TO_TABLE_32 to Table 32 code 41 ("heat from electric
heat pump (community)") which is a heat network billed at the heat-
network rate (4.24 p/kWh single rate), not at the off-peak electric
tariff. Pre-S0380.136 the literal-set check would have treated this
as direct electric and applied the Table 12a high/low-rate split —
that was wrong; community heat networks don't have an off-peak split.
The new canonical helper correctly excludes code 41 from
_ELECTRIC_FUEL_CODES.

Heating-systems corpus impact:

  solid fuel 6 (Dual Fuel Anthracite Wood, SAP 160):
    ΔSAP  −11.3731 → +1.9493   (now in cluster with other solid-fuel)
    Δcost +£268.44 → −£44.91
    ΔPE   unchanged (PE wasn't affected by the cost mis-routing)

No other corpus variants moved — none have `main_fuel_type` in the
ambiguous API/T32 collision range that was previously mis-classified.

Extended handover suite: 879 pass / 0 fail (+2 from new AAA tests
covering both `_is_electric_main` and `_is_electric_water` dual-fuel
non-electric classification + API code 29 → electric / API code 25 →
heat-network non-electric semantics).

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

No golden fixture impact — no golden cert lodges `main_fuel_type=10`
(dual fuel) on the cascade path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:37:14 +00:00
Khalim Conn-Kowlessar
829a3318dc Slice S0380.135: dispatch responsiveness via Table 4a SAP code (solid-fuel cluster)
SAP 10.2 spec line 15271:

  "R = responsiveness of main heating system (Table 4a or Table 4d)"

The cascade's `_responsiveness` was keyed solely on `heat_emitter_type`
(Table 4d), which is correct for systems whose responsiveness is
determined by the emitter (gas / oil / HP boilers feeding radiators or
UFH). But for systems with intrinsically low responsiveness — solid-
fuel room heaters, range cookers, independent solid-fuel boilers —
the spec lodges R directly in Table 4a against the heating-system SAP
code, and that value overrides any emitter-based lookup.

For solid fuel 8 (SAP code 160 = "Range cooker boiler (integral oven
and boiler)", lodged with radiators emitter), pre-slice the cascade
returned R = 1.0 (radiators) instead of the spec-correct R = 0.50
(Table 4a p.169). The Table 9b mean-internal-temperature adjustment
then over-estimated heating-system response, under-estimating space
heating demand by ~10% (cascade demand 6874.80 kWh vs worksheet EPC
implied 7566 kWh).

The fix adds a new dispatch `_RESPONSIVENESS_BY_SAP_CODE` consulted
first in `_responsiveness`; SAP codes not in the dict fall through to
the existing Table 4d emitter lookup.

Table 4a entries added (SAP 10.2 PDF p.169-170):

  151  Manual feed independent boiler                R=0.75
  153  Auto (gravity) feed independent boiler        R=0.75
  155  Wood chip/pellet independent boiler           R=0.75
  156  Open fire with back boiler to radiators       R=0.50
  158  Closed room heater with boiler to radiators   R=0.50
  159  Stove (pellet-fired) with boiler to radiators R=0.75
  160  Range cooker boiler (integral oven+boiler)    R=0.50
  161  Range cooker boiler (independent oven+boiler) R=0.50
  631  Open fire in grate                            R=0.50
  632  Open fire with back boiler (no radiators)     R=0.50
  633  Closed room heater                            R=0.50
  634  Closed room heater with boiler (no radiators) R=0.50
  635  Stove (pellet fired)                          R=0.75
  636  Stove (pellet fired) with boiler (no rads)    R=0.75

Heating-systems corpus impact — 10 solid-fuel variants re-pinned:

  variant         ΔSAP   was        Δcost  was      ΔPE     was
  solid fuel 2    +2.64  +4.79     -£60    -£110    -1211   -2292
  solid fuel 3    +1.32  +4.43     -£30    -£102     -935   -2496
  solid fuel 4    +1.59  +4.13     -£37     -£95     +151   -1097
  solid fuel 5    +1.70  +2.71     -£39     -£62     +160    -331
  solid fuel 6   -11.37  -7.38    +£268   +£168      +87   -1313  ← see below
  solid fuel 7    +2.04  +5.82     -£47    -£131      +44   -1638
  solid fuel 8    +1.81  +4.24     -£42     -£98      +88   -1308
  solid fuel 9    +1.71  +3.44     -£39     -£79     +155    -510
  solid fuel 10   +1.75  +5.14     -£40    -£118     +120   -1315
  solid fuel 11   +1.62  +4.35     -£37    -£100     +171    -962

7/10 PE residuals close to ±220 kWh (down from -331..-2496). 9/10 SAP
residuals tighten to +1.32..+2.64 (down from +2.71..+5.82).

solid fuel 6 (Dual Fuel Anthracite Wood, SAP 160) SAP residual
regresses -7.38 → -11.37 while PE closes +87. The dual-fuel cascade
has a separate bug now exposed by the more-accurate demand calc;
queued for a follow-up slice.

Non-solid-fuel variants (15) unchanged — their SAP codes aren't in
the new dispatch dict so they fall through to Table 4d as before.
Electric storage Table 4a rows (193-196, 422-424, 515, 701) and the
spec's other low-responsiveness codes can be added in follow-up
slices as electric corpus variants are unblocked.

Extended handover suite: 877 pass / 0 fail (+1 new responsiveness
AAA test). Pyright net-zero on touched files (43 → 43).

No golden fixture impact — no golden cert lodges a solid-fuel SAP
code via the cascade path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 13:11:36 +00:00
Khalim Conn-Kowlessar
7530ed3f4a Slice S0380.134: pin corpus PE against cascade demand-mode (apples-to-apples)
The SAP 10.2 worksheet computes each existing-dwelling metric in two
distinct blocks:

  1. "ENERGY RATING" block — uses Table 12 regulated prices + UK-
     average climate. Produces SAP score (Block 11a), total fuel
     cost (255), total CO2 (272).
  2. "EPC COSTS, EMISSIONS AND PRIMARY ENERGY" block — uses Table 32
     prices + postcode-specific climate. Produces total CO2 (272)
     again with different value, total PE (286).

The two blocks operate on different space-heating demand kWh per
SAP 10.2 §13 (e.g. solid fuel 8: 21097 kWh in rating block vs
16813 kWh in EPC block for London W6).

The corpus regression test was extracting all four pins and asserting
against the cascade's rating-mode result (`cert_to_inputs`). That was
apples-to-apples for SAP/cost/CO2 (the first `(255)` and `(272)`
matches the regex finds ARE in the rating block) but apples-to-
oranges for PE: the `(286)` Total PE only exists in the EPC block,
so every PE pin was comparing rating-mode cascade output against
EPC-block worksheet output. The mismatch inflated every PE residual
by 10-15% of total PE.

The fix runs both cascade modes in the Act phase and assigns:

  - rating-mode result → SAP / cost / CO2 residuals
  - demand-mode result (`cert_to_demand_inputs`) → PE residual

25 corpus _CorpusExpectation entries re-pinned. Some closed
dramatically (apples-to-apples reveals the cascade was actually
correct):

  ashp         +1467.90 → -11.80  ← effectively closed
  oil pcdb 1/2 +2086.75 → -83.82
  oil pcdb 3   +1897.43 → -271.44
  electric 1   +2837.14 → +164.91
  electric 8   +2113.83 → -224.46
  solid fuel 5 +2359.85 → -330.84

Others surfaced larger demand-mode gaps that the block mismatch had
been hiding — these are real cascade gaps the next slices will
address:

  electric 3       -850.93 → -3189.22
  electric 5/6     +540/+568 → -1797.96 / -1769.84
  pcdb 1           -171.70 → -3135.30
  solid fuel 2/3   +440.75 / +1451.79 → -2292.47 / -2496.20

The corpus test docstring + per-block-attribution comment now make
the rating-vs-EPC block distinction explicit so future reviewers
don't repeat the same conflation.

Extended handover suite at HEAD post-slice: 876 pass / 0 fail
(unchanged — no test count change, just per-pin value updates).

Pyright net-zero on touched file (0 → 0).

No cascade behaviour change. No golden / unit-test impact (the bug
was specific to the corpus test's pin-extraction logic).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 10:41:47 +00:00
Khalim Conn-Kowlessar
0d2d41abbb Slice S0380.133: derive solid-fuel main fuel from §14.0 EES Code
The Elmhurst Summary §14.0 "Main Heating EES Code" is a three-letter
identifier that resolves to the specific fuel for solid-fuel main
heating systems. The §14.0 "Main Heating SAP Code" alone can't
disambiguate because Table 4a categorises solid-fuel systems by
appliance type rather than fuel — SAP code 160 ("Closed room heater
with boiler") is shared by anthracite, wood chips, dual fuel and
smokeless across the heating-systems corpus.

Three changes land together:

1. `MainHeating` dataclass (`elmhurst_site_notes.py`) gains a
   `main_heating_ees: str = ""` field for the §14.0 EES code.
2. `ElmhurstSiteNotesExtractor._extract_main_heating` reads "Main
   Heating EES Code" from §14.0.
3. `_map_elmhurst_sap_heating` adds a fourth fuel-derivation
   fallback (after the existing electric-SAP-code + §15.0-liquid-
   fuel branches): when `main_fuel_int is None` and the §14.0 EES
   code is in `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE`, use that
   dict's value as the main fuel.

Dict (corpus-derived, 10 entries → 7 distinct Table 32 fuels):

  BAF, BAI, RAM → 15  anthracite       (3.64 / 0.395 / 1.064)
  BCC           → 11  house coal       (3.67 / 0.395 / 1.064)
  BDI           → 10  dual fuel        (3.99 / 0.087 / 1.049)
  BKI           → 12  smokeless        (4.61 / 0.366 / 1.261)
  BQI           → 21  wood chips       (3.07 / 0.023 / 1.046)
  RPS           → 22  wood pellets bags (5.81 / 0.053 / 1.325)
  RUN           → 23  bulk pellets     (5.26 / 0.053 / 1.325)
  RWN           → 20  wood logs        (4.23 / 0.028 / 1.046)

Dict values are Table 32 fuel codes, NOT API `main_fuel` enum codes
— the API codes 1-9 collide with Table 32 codes for unrelated fuels
(e.g. API 5 = "anthracite" vs Table 32 5 = "bottled LPG main
heating"). `unit_price_p_per_kwh` / `co2_factor_kg_per_kwh` /
`primary_energy_factor` all check the Table 32 dict before falling
through to the API translation, so using Table 32 codes here avoids
the collision and routes cost/CO2/PE through the correct fuel row.

Heating-systems corpus impact — all 10 solid-fuel variants move
from `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` (assert-on-raise) back
onto the residual-pin grid in `_EXPECTATIONS`:

  variant         ΔSAP    Δcost      ΔCO2     ΔPE
  solid fuel 2   +4.79  -£110    -484 kg   +441 kWh   anthracite
  solid fuel 3   +4.43  -£102   -1206     +1452       anthracite
  solid fuel 4   +4.13   -£95    -714     +1655       anthracite
  solid fuel 5   +2.71   -£62    -301     +2360       house coal — smallest
  solid fuel 6   -7.38  +£168    -154     +2519       dual fuel — only negative
  solid fuel 7   +5.82  -£131    -758     +2968       smokeless
  solid fuel 8   +4.24   -£98     -15     +2513       wood chips
  solid fuel 9   +3.44   -£79      -8     +2428       wood pellets bags
  solid fuel 10  +5.14  -£118     -53     +1849       wood pellets bulk
  solid fuel 11  +4.35  -£100      -9     +1536       wood logs

Remaining residuals trace to heating-system efficiency / control
type — separate slices. 16 variants still in `_BLOCKED`: community
heating ×5, electric storage ×4, no system, oil non-Heating-oil ×5,
Bulk LPG ×1. Each is its own derivation slice.

Extended handover suite at HEAD post-slice: 876 pass / 0 fail (was
875 + 1 new EES wiring AAA test).

Pyright net-zero on touched files (45 → 45 — all pre-existing).

No golden fixture impact — no golden cert lodges an EES code via
the Elmhurst path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 10:04:28 +00:00
Khalim Conn-Kowlessar
0aa40b63cd Slice S0380.132: strict-raise MissingMainFuelType on empty main_fuel_type
The cascade's `_main_fuel_code` previously returned None when
`MainHeatingDetail.main_fuel_type` was anything other than an int
(empty string, None, or an unmapped string label). The downstream
`table_32.unit_price_p_per_kwh(None)` then silently defaulted to mains
gas (3.48 p/kWh / CO2 0.21 kg/kWh / η 0.45 / PE 1.22) — a misleading
fallback where cost may happen to be close but CO2 / PE / efficiency
are completely wrong for the actual heating system.

Probe of the heating-systems corpus surfaced 26 of 41 controlled-
variable variants with `main_fuel_type=''`:

  Community heating 1/2/3/4/6 (Table 4a 301-304)        5
  Electric 11/12/13/14 (Table 4a 5xx/6xx/7xx)           4
  No system (SAP code 699)                              1
  Oil 2 (HVO) / oil 3 (FAME) / oil 4 (FAME) /
    oil 5 (bioethanol) / oil 6 (B30K) (Table 4b)        5
  Solid fuel 2..11 (Table 4a 150-160 + 600-636)        10
  pcdb 3 (lodges 'Bulk LPG' string — mapper dict gap)   1

Each pre-slice carried a residual pin in `_EXPECTATIONS` encoding the
broken mains-gas-default state. Solid fuel 8's +0.87 ΔSAP — the
"smallest open residual" the user asked to investigate next — turned
out to be the net of compensating cost/efficiency errors; the CO2
delta was +3525 kg/yr and PE +4103 kWh/yr because the cascade was
costing wood chips as mains gas.

Two changes land together:

1. Add `MissingMainFuelType(ValueError)` to
   `domain/sap10_calculator/exceptions.py`. Semantics distinct from
   the sibling `UnmappedSapCode` (which is for unmapped int dispatch
   codes; this is for "value not resolvable to a SAP fuel code at
   all"). The error message names the lodged value + the
   `sap_main_heating_code` hint so the upstream mapper fix is
   obvious.

2. `_main_fuel_code` in `cert_to_inputs.py` now raises
   `MissingMainFuelType` when `main_fuel_type` is not an int.
   `main is None` still returns None (genuinely no main heating).

The 26 blocked corpus variants are lifted out of the
`_EXPECTATIONS` residual-pin grid into a new tuple
`_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` driving a new parametrised test
`test_heating_systems_corpus_blocked_variant_raises_missing_main_fuel_type`
that asserts the raise for each blocked variant. As mapper-side fixes
land (deriving fuel from `sap_main_heating_code` via SAP 10.2 Table
4a/4b/4f, or extending `_ELMHURST_MAIN_FUEL_TO_SAP10`), variants move
back onto the residual-pin grid.

Mirrors the [[reference-unmapped-sap-code]] / [[reference-unmapped-
api-code]] strict-raise pattern: forcing function for spec/mapper
completion at the cascade boundary instead of silently producing
wrong outputs.

Extended handover suite at HEAD post-slice: 875 pass / 0 fail (was
874; +1 from the new `_main_fuel_code` strict-raise unit test;
26 blocked corpus pins replaced 1:1 by 26 assert-on-raise tests).

Pyright net-zero (43 → 43 — all pre-existing `pytest.approx` flags).

No golden fixture impact — every golden cert carries an int
`main_fuel_type`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:43:27 +00:00
Khalim Conn-Kowlessar
14eee259b4 Slice S0380.131: flip Table 32 heating-oil price 7.64 → 5.44 (empirical)
The published RdSAP 10 Specification 10-06-2025 PDF Table 32 (p.95)
lists heating oil at 7.64 p/kWh. Two independent operational sources
both use 5.44 p/kWh for the same fuel:

  - Elmhurst P960 worksheets across all five oil-fired variants in
    `sap worksheets/heating systems examples/` (oil 1, oil pcdb 1/2/3,
    pcdb 1) lodge 5.4400 p/kWh on (240) "Space heating - main system 1"
    and (247) "Water heating (other fuel)" for every "FuelType: Heating
    oil" worksheet.
  - The gov.uk EPC register's lodging software back-solves to ~5.48
    p/kWh from cert 0240-0200-5706-2365-8010's lodged SAP 73 (oil + PV
    detached, age J). With heating-oil at 5.44 in the cascade this cert
    closes to ΔSAP = 0 exactly against its lodged value.

The BRE technical papers (`docs/specs/sap10 technical papers/`) carry
no Table 32 errata or fuel-price update, so the change is grounded in
empirical cross-source evidence rather than a spec citation — the
worksheet PDF is the source of truth per the project convention.

Post-flip residuals:

  Heating-systems corpus (cascade − worksheet ΔSAP_c):
    oil 1        −9.7030 → +2.6578
    oil pcdb 1  −11.6343 → +0.4239  ← within 1 SAP of closure
    oil pcdb 2  −11.6343 → +0.4239
    oil pcdb 3  −10.8674 → +1.1597
    pcdb 1       −9.4083 → +6.9521  ← largest remaining oil-cohort gap

  Golden fixtures (cascade − lodged SAP):
    0240-0200-5706-2365-8010  resid −10 → +0  ← EXACT closure
    0390-2954-3640-2196-4175  resid  −6 → +7  ← oil-price bug was
                                                 masking +13 SAP of
                                                 opposite-direction
                                                 cascade gaps; now
                                                 exposed for follow-up

PE / CO2 residuals are unaffected by the unit-price flip (cost-only
change). The 41-variant corpus regression guard (S0380.129) holds; all
other golden cohorts pass unchanged. Extended handover suite: 874 pass.

Bio-FAME (code 73) shows the inverse divergence on oil 3/4 worksheets
(worksheet 7.64 vs spec 5.44 — possible row-swap typo in the spec PDF)
but flipping it has no measurable cascade effect today, so deferred
until a cert that exercises it surfaces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 09:21:29 +00:00
Khalim Conn-Kowlessar
c848607718 Slice S0380.130: route Elmhurst oil mains via §15.0 Water Heating Fuel Type
Elmhurst Summary §14.0 Main Heating1 leaves "Fuel Type" empty for
Table 4b liquid-fuel boilers (heating oil / HVO / FAME / B30K /
bioethanol — SAP codes 120-141). Unlike gas boilers (codes 101-119)
where Elmhurst explicitly lodges "Mains gas", liquid-fuel boilers
take the fuel from §15.0 "Water Heating Fuel Type" since the same
boiler heats space + water.

Pre-slice:
  - `_elmhurst_main_fuel_int(mh.fuel_type)` returned None for the
    empty §14.0 fuel string.
  - The electric-SAP-code inference (`_ELECTRIC_SAP_MAIN_HEATING_CODES`)
    didn't fire because SAP 127 is a Table 4b oil boiler, not electric.
  - `main_fuel_type` fell through to the raw empty string.
  - `cert_to_inputs._main_fuel_code` returned None.
  - `table_32.unit_price_p_per_kwh(None)` defaulted to mains gas
    (3.48 p/kWh).
  - The cascade therefore priced ~13.7k kWh/yr of oil space + water
    heating at the gas tariff — a 56% under-count vs the worksheet's
    Table 32 oil rate.

Two complementary fixes:

1. Add "Heating oil" → 28 ("oil (not community)" per epc_codes.csv
   row main_fuel,28) to `_ELMHURST_MAIN_FUEL_TO_SAP10`. The existing
   `API_FUEL_TO_TABLE_32` then routes API 28 → Table 32 code 4
   (heating oil — 7.64 p/kWh / 0.298 kg CO2/kWh / 1.180 PE factor
   per RdSAP 10 spec p.95). This fix handles pcdb 1 directly because
   pcdb 1 lodges §14.0 "Fuel Type: Heating oil" explicitly.

2. Thread a §15.0-fuel fallback for the main_fuel inference: when
   `mh.fuel_type` is empty AND `mh.main_heating_sap_code` is in the
   Table 4b liquid-fuel range (120-141 per SAP 10.2 Table 4b
   "Seasonal efficiency for gas and liquid fuel boilers"), use the
   §15.0 water_heating_fuel as the main fuel too. Gated on the SAP
   code range so this can't accidentally fire on solid-fuel-mains
   + electric-HW certs (where §15.0 lodges "Electricity" for the
   immersion but the SH fuel is the solid fuel implicit in the SAP
   code). This fix handles oil 1 + oil pcdb 1/2/3 (where §14.0 is
   silent but §15.0 lodges "Heating oil").

Residual shifts at HEAD post-slice (5 variants legitimately re-pinned):

  oil 1       +13.67 SAP → -9.70 SAP (cascade now over-counts at the
                          spec's 7.64 p/kWh — vs worksheet's 5.44)
  oil pcdb 1/2 +11.17 → -11.63
  oil pcdb 3  +11.87 → -10.87
  pcdb 1      +21.90 → -9.41

Remaining negative residuals are the price-spec-vs-worksheet gap
queued for slice S0380.131 (5.44 vs 7.64 p/kWh oil). The mapper now
correctly identifies the fuel; what's left is the cascade tariff.

The other 36 corpus variants are unchanged — restricting the §15.0
fallback to SAP 120-141 keeps solid-fuel-mains and electric-mains
certs at their existing pins.

Extended handover suite at HEAD post-slice: **874 pass, 0 fail**
(was 873 + 1 new AAA test).

Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:59:48 +00:00
Khalim Conn-Kowlessar
82b8a16b40 Slice S0380.129: heating-systems corpus residual-pin regression guard
The 001431 corpus at `sap worksheets/heating systems examples/` now
has a permanent test module pinning cascade-vs-worksheet residuals
across all 41 populated heating-system variants. The corpus is a
controlled-variable test set — same dwelling (semi-detached, TFA 90 m²,
age G, W6 9BF, Elmhurst P960 worksheet format) under different heating
configurations — so every cascade-vs-worksheet residual is fully
attributable to the heating subsystem.

`test_heating_systems_corpus_residual_matches_pin` is parametrised by
variant folder name. Per variant it:

  1. Extracts Block 11a (individual) or Block 11b (community) pins
     from the P960 PDF — continuous SAP (`SAP value` row), total fuel
     cost (255)/(355), CO2 (272/372/382/383), PE (286/386/486/483).
  2. Routes the Summary PDF through ElmhurstSiteNotesExtractor →
     EpcPropertyDataMapper.from_elmhurst_site_notes → cert_to_inputs
     → calculate_sap_from_inputs.
  3. Asserts each of the four cascade outputs sits within an absolute
     tolerance of the pinned residual.

Tolerances are tight (SAP ±0.001, cost ±£0.01, CO2 ±0.1 kg/yr, PE
±0.1 kWh/yr) — the *expected residual* moves toward 0 as heating-
cascade gaps close; the *tolerance* never widens. Per
[[feedback-zero-error-strict]] + [[feedback-golden-residuals-near-zero]].

Pins captured at HEAD `729ee29c` (post-S0380.128). All 41 pass.
Smallest residual: `solid fuel 8` +0.87 SAP / −£20 cost (closest to
closure). First negative ΔSAP: `community heating 6` −6.87 SAP / +£158
cost (heat-pump heat network — only variant where cascade UNDERshoots
the worksheet).

Extended handover suite at HEAD post-slice: **873 pass, 0 fail**
(was 832 + 41 new parametrised cases).

Pyright net-zero on new file (0 → 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:36:08 +00:00
Khalim Conn-Kowlessar
729ee29c84 Slice S0380.128: extractor §14.0 closure falls back to "14.1 Community Heating"
Elmhurst Summary §14.0 Main Heating1 normally closes at "14.1 Main
Heating2", but community-heated dwellings and "no system" certs lodge
§14.0 followed directly by "14.1 Community Heating/Heat Network" (no
second main system exists on a community-heated dwelling). Pre-slice
the extractor's `_between("14.0 Main Heating1", "14.1 Main Heating2")`
returned an empty string for these shapes — every §14.0 field
(including `Main Heating SAP Code`) came back None, then the mapper
strict-raised `UnmappedElmhurstLabel` with "§14.0 Main Heating1 has
neither PCDF boiler reference (None) nor SAP code (None)".

The fix adds a `_section_lines_first_end(start, ends)` helper that
accepts a tuple of end-marker candidates and uses whichever appears
first after `start`. `_extract_main_heating` now closes §14.0 at
either "14.1 Main Heating2" or "14.1 Community Heating" — whichever
Summary lodges.

Impact on heating-systems corpus 001431 at `sap worksheets/heating
systems examples/`:

  Variant                  Pre-S0380.128 -> Post-S0380.128
  ------------------------ ------------------ -----------------
  community heating 1      mapper-raise   ->  SAP code 301 OK
  community heating 2      mapper-raise   ->  SAP code 302 OK
  community heating 3      mapper-raise   ->  SAP code 304 OK
  community heating 4      mapper-raise   ->  SAP code 302 OK
  community heating 6      mapper-raise   ->  SAP code 302 OK
  no system                mapper-raise   ->  SAP code 699 OK

Corpus tally: **35/41 -> 41/41 cascade-OK**. With all populated
variants now executing, the cascade-vs-worksheet residual cluster is
fully visible for the first time. Notably community heating 6 surfaces
the FIRST negative ΔSAP in the corpus (-6.87 — cascade undershooting
the worksheet rather than overshooting), a distinct diagnostic shape
worth investigating next.

The fix is structural (extractor section bracketing) — no spec rule
to cite. RdSAP 10 §17 page 85 row 1.0 ("Main Heating") + §17 row
10-1a ("Community Heat Source") confirm that community-heated certs
have only one main heating system (no Main 2 block).

Extended handover suite at HEAD post-slice: **832 pass, 0 fail**
(was 831 + 1 new AAA test).

Pyright net-zero on touched files (13 → 13 — pre-existing errors
unrelated).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:26:24 +00:00
Khalim Conn-Kowlessar
11ecac94dc Slice S0380.127: resolve Elmhurst "No Access" cylinder via RdSAP 10 Table 28
Elmhurst Summary §15.1 sometimes lodges "Cylinder Size: No Access" (the
inaccessible-cylinder lodging form). Pre-slice the mapper strict-raised
`UnmappedElmhurstLabel` because `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`
only carried the three lodged-size labels (Normal/Medium/Large).

Per RdSAP 10 Specification Table 28 page 55 ("Cylinder size"):

  > "Inaccessible:
  >   - if off-peak electric dual immersion: 210 litres
  >   - if from solid fuel boiler: 160 litres
  >   - otherwise: 110 litres"

And per §10.5.1 page 53:

  > "An electric immersion is assumed dual in the following cases:
  >  - cylinder is inaccessible and electricity tariff is dual"

So the 210-L "off-peak electric dual immersion" branch fires automatically
when both (a) cylinder is inaccessible AND (b) water heating is electric
AND (c) meter type is dual / off-peak (no separate dual-immersion lodging
required).

New helper `_resolve_elmhurst_inaccessible_cylinder_size` keys off
§15.0 "Water Heating Fuel Type" + §14.2 "Electricity meter type":

  - solid fuel water heating fuel (Anthracite, House coal, Wood, etc.)
    → 160 L → SAP10 cylinder_size enum 3 (Medium)
  - "Electricity" + dual/18-hour/24-hour/off-peak meter
    → 210 L → SAP10 cylinder_size enum 4 (Large)
  - otherwise → 110 L → SAP10 cylinder_size enum 2 (Normal)

`_elmhurst_cylinder_size_code` extended with optional water_heating_fuel
+ meter_type kwargs; the single call site at line 4459 threads
`survey.water_heating.water_heating_fuel_type` and
`survey.meters.electricity_meter_type`.

Property 001431 (the heating-systems corpus dwelling) lodges `pcdb 1`
with §14.0 Potterton oil boiler (PCDF 716) + §15.0 "Water Heating Fuel
Type: Heating oil" + §14.2 "Electricity meter type: 18 Hour" — water
fuel is oil (not electric, not solid fuel) → "otherwise" branch → 110 L
→ enum 2 (Normal). `pcdb 1` now cascade-executes (corpus tally 34 → 35
OK / 41 populated).

Extended handover suite at HEAD post-slice: **831 pass, 0 fail**
(was 830 + 1 new AAA test).

Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:09:42 +00:00
Khalim Conn-Kowlessar
e25aa02109 Slice S0380.126: resolve Elmhurst bare "Underfloor Heating" via RdSAP 10 §10.11
Elmhurst Summary §14.0 Main Heating1 sometimes lodges the bare form
"Heat Emitter: Underfloor Heating" without a subtype qualifier (in
screed / timber floor). The mapper's `_ELMHURST_HEAT_EMITTER_TO_SAP10`
dict only carried the qualified forms, so the bare lodging fell through
to None and was passed as a raw string into `MainHeatingDetail.heat_
emitter_type` — causing `_responsiveness` to strict-raise
`UnmappedSapCode` on every cert with this lodging (2 variants on the
heating-systems corpus: `electric 1` + `oil 6`).

Per RdSAP 10 Specification §10.11 Table 29 page 56 ("Heating and hot
water parameters"):

  > "Underfloor heating: If dwelling has a ground floor, then according
  >  to the floor construction (see Table 19 if unknown):
  >    - solid, main property age band A to E: concrete slab
  >    - solid, main property age band F to M: in screed
  >    - suspended timber: in timber floor
  >    - suspended, not timber: in screed
  >  Otherwise (i.e. upper floor flats), take floor as suspended"

New helper `_resolve_elmhurst_underfloor_subtype` keys off the main BP's
`floor.floor_type` + `construction_age_band` and returns:

  - SAP10.2 Table 4d emitter code 2 (in screed) → R=0.75 — for
    solid + age F-M, suspended-not-timber, and upper-floor-flat cases
  - SAP10.2 Table 4d emitter code 3 (timber floor) → R=1.0 — for
    suspended-timber

The solid + age A-E "concrete slab" branch (R=0.25) has no cert-side
enum entry yet, so the helper strict-raises `UnmappedElmhurstLabel`
when that combination lands — the next variant lodging an A-E solid
underfloor will surface the gap loudly per
[[reference-unmapped-sap-code]].

Property 001431 (the heating-systems corpus dwelling) lodges §9.0
"Type: S Solid" + §3.0 "Date Built: G 1983-1990" (age band G ∈ F-M)
→ "in screed" → code 2 → R=0.75. Both `electric 1` and `oil 6` now
cascade-execute (corpus tally 32 → 34 OK / 41 populated).

Extended handover suite at HEAD post-slice: **830 pass, 0 fail**
(was 829 + 1 new AAA test).

Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 00:00:36 +00:00
Khalim Conn-Kowlessar
f2e8b657ce Slice S0380.116: A_RR_shell rounded to 2 d.p. per RdSAP 10 §15 (p.66)
RdSAP 10 Specification §15 "Rounding of data" (PDF p.66):

  "For consistency of application, after expanding the RdSAP data into
   SAP data using the rules in this Appendix, the data are rounded
   before being passed to the SAP calculator. The rounding rules are:
     U-values: 2 d.p.
     All element areas (gross) including window areas and conservatory
     wall area: 2 d.p."

The §3.9.1 / §3.10.1 shell formula A_RR_shell = 12.5 × √(A_RR_floor /
1.5) produces a gross element area for the room-in-roof. Pre-slice the
cascade kept the raw float (e.g. cert 000565 BP[0]: 12.5 × √30 =
68.46532...), then subtracted lodged wall surfaces to obtain the (30)
residual roof area. The worksheet rounds A_RR_shell to 2 d.p. (68.47)
BEFORE the subtraction — per §15 above.

Cert 000565 has three BPs that fire this path (Main, Ext1, Ext3 — all
have detailed wall surfaces with no `slope` / `flat_ceiling` /
`stud_wall` lodgement, so §3.10.1 residual fires). Each contributes a
sub-rounding residual that the unrounded cascade was missing:

  BP[0] Main: 68.4653 → 68.47; residual 43.9653 → 43.97 (+0.0016 W/K)
  BP[1] Ext1: 59.5119 → 59.51; residual 18.2519 → 18.25 (−0.0007 W/K)
  BP[3] Ext3: 57.7350 → 57.74; residual 17.3450 → 17.35 (+0.0017 W/K)

Movement (HEAD `d0268a5b` → this slice) for cert 000565:
  roof_w_per_k        51.3768 → 51.3795 ✓ EXACT (Δ −0.0027 → 0.0)
  thermal_bridging   128.6448 → 128.6460 ✓ EXACT (Δ −0.0012 → 0.0)
  total_external_a   857.6323 → 857.6400 ✓ EXACT (Δ −0.0077 → 0.0)
  space_heating_kwh 59008.2363 → 59008.3499 ✓ EXACT (Δ −0.1136 → 0.0)
  main_fuel_kwh     34710.7272 → 34710.7941 ✓ EXACT (Δ −0.0669 → 0.0)
  total_fuel_cost    4680.2515 → 4680.2593 ✓ EXACT (Δ −0.0078 → 0.0)
  co2_kg_per_yr      6447.6161 → 6447.6263 ✓ EXACT (Δ −0.0102 → 0.0)
  sap_score_cont       28.5087 → 28.5087 ✓ EXACT (Δ +4.2e-5 → −4.7e-5)
  sap_score (int)           29 ✓ EXACT (preserved)
  ecf                  5.38682 → 5.38683 (vs ws 5.3868, Δ +3.2e-5)

Cert 000565 truly closes — every SAP-result field within 1e-4 of the
worksheet PDF.

Cohort safety: 6 cohort certs (000474..000516) unchanged — cohort
000516's roof routes through the Detailed branch with `slope` /
`flat_ceiling` / `stud_wall` lodgements, so `has_roof_lodgement=True`
short-circuits the §3.10.1 residual block. Cohort certs 000474/477/
480/487/490 are pre-S0380.103 hand-built fixtures whose RR fields don't
exercise the simplified A_RR_shell path (rir.floor_area=0 or
detailed_surfaces only).

Test added: `test_summary_000565_a_rr_shell_rounded_2_dp_closes_roof_
w_per_k_per_rdsap_10_section_15` pins the cascade roof_w_per_k = 51.3795
exactly (Δ ≤ 1e-4 vs worksheet (30) Σ).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:18:53 +00:00
Khalim Conn-Kowlessar
cc70e55917 Slice S0380.114: pump gain via Table 5a Note a) (SAP 10.2 p.177)
SAP 10.2 Table 5a (PDF p.177) verbatim:

  "Central heating pump in heated space, 2013 or later: 3 W"

  Note a): "Where there are two main heating systems serving
  different parts of the dwelling, assume each has its own
  circulation pump and therefore include two figures from this
  table. ... Set to zero in summer months. **Not applicable for
  electric heat pumps from database.** Where two main systems serve
  the same space a single pump is assumed."

The Note a) "not applicable for electric heat pumps" rule zeros the
pump GAIN only for HP-category systems themselves. Where a cert
lodges a non-HP main system alongside an HP, the non-HP system's
circulation pump still operates and dissipates 3/7/10 W into the
dwelling as an internal gain.

Pre-slice the cascade conflated TWO different spec rules:

  Table 4f (ELECTRICITY)  — HP pump electricity is in the COP, so
                             worksheet line 230b = 0 for HP certs.
  Table 5a (GAIN)         — HP-from-database pump gain is omitted
                             ONLY for that HP system, not for any
                             non-HP system in the same cert.

`_main_heating_category_from_cert(epc)` returned `details[0].
main_heating_category` and the caller zeroed pump_w whenever that
was category 4. This dropped the 3 W gain for any cert whose first
main system was an HP — even when system 2 was a non-HP boiler with
its own pump.

Cert 000565 lodges TWO main systems:
  [0] HP        (category 4)  pump_age "2013 or later"
  [1] Gas boiler (category 2)  pump_age None

Per spec the system [1] gas boiler's pump contributes 3 W (post-2013
date from [0]'s lodgement). Worksheet (70) confirms:

  Pumps, fans  3.0 3.0 3.0 3.0 3.0 0.0 0.0 0.0 0.0 3.0 3.0 3.0  (70)

Pre-slice cascade returned 0 every month, missing 24 W·months of
winter internal gains. Downstream: +10 kWh space heating, +£0.71
fuel cost, +0.90 kg CO2, -0.008 continuous SAP.

Cert 0380 (cohort-1 ASHP, HP-only):
  [0] HP (category 4)  pump_age unknown
  (no [1])

Worksheet (70) = 0 every month. Cascade post-slice: every main
system is HP → pump_w = 0 ✓ unchanged.

Fix:

`domain/sap10_calculator/worksheet/internal_gains.py`:
- Replace `_main_heating_category_from_cert` + the {4} set-membership
  check with `_all_main_systems_are_heat_pumps(epc)`. Returns True
  iff every lodged `main_heating_details[i].main_heating_category`
  equals 4. Pump gain is zeroed only in that case.
- Existing `_pump_date_category_from_cert` (reads [0]'s pump_age)
  unchanged — Elmhurst lodges the dwelling's pump_age on detail[0]
  regardless of which system the pump serves.

Cohort safety: all 6 cohort certs have a single main system (gas
boiler, category 2) → `all_main_systems_are_heat_pumps` returns
False → pump_w applies, same as the prior `else` branch. Cert 0380
(ASHP) has a single HP main → True → pump_w = 0, unchanged.

Cert 000565 cascade snapshot (HEAD 59de805e → this):
  (70)m pumps_fans gain   [0]*12  → [3,3,3,3,3,0,0,0,0,3,3,3] ✓ EXACT
  sap_score (int)             29 ✓ EXACT (preserved)
  sap_score_continuous   28.5007 → 28.508742  (Δ -0.0080 → +0.000042)
                                  **← essentially exact at 4.2e-5**
  ecf                     5.3876 →  5.386823  (Δ +0.0010 → +0.0002)
  total_fuel_cost_gbp    4680.97 → 4680.2515  (Δ +0.71 → -0.008)
  co2_kg_per_yr          6448.53 → 6447.6161  (Δ +0.90 → -0.010)
  space_heating_kwh     59018.52 → 59008.2363 (Δ +10.17 → -0.114)
  main_heating_fuel     34716.78 → 34710.7272 (Δ +5.98 → -0.067)

**Cert 000565 continuous SAP now exact at 1e-4 tolerance.** Every
intermediate (66-73, 83-84, 93-98, fuel/cost/CO2) closes the
worksheet at ≤1e-3 relative error.

Pyright net-zero (17 → 17 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:46:46 +00:00
Khalim Conn-Kowlessar
59de805e63 Slice S0380.113: H=0 gable lodgement deducts per RdSAP 10 §3.9.2 step (b)
RdSAP 10 §3.9.2 step (b) (PDF p.23) verbatim:

  "Software calculates the area of each gable or adjacent wall by
  using the equation:
         A_RR_gable = L_gable × (0.25 + H_gable) − [(H_gable − H_common_1)² / 2
                                                   + (H_gable − H_common_2)² / 2]"

Step (d):
  A_RR_final = A_RR_wall − (Σ A_common + Σ A_gable + Σ A_party
                            + Σ A_sheltered + Σ A_connected)

The spec equation is signed and applies for all L > 0 — including
H_gable = 0. When the gable is shorter than the common walls the
correction term `(H_gable − H_common)² / 2` exceeds the
L × (0.25 + H_gable) term, producing a negative A_RR_gable.
Elmhurst's worksheet evaluates the equation literally; the negative
value adjusts A_RR_final upward via step (d) without billing a
physical wall area.

Cert 000565 §8.1 lodges Ext3's RR (Simplified Type 2) with an
absent Gable Wall 2:

  Gable Wall 1   L=9.00  H=7.00   Exposed     U=0.45
  Gable Wall 2   L=4.00  H=0.00               U=0.00   ← lodged but H=0
  Common Wall 1  L=5.00  H=1.50               U=0.45
  Common Wall 2  L=7.50  H=0.30               U=0.45

Spec equation for Gable Wall 2:
  A_gable_2 = 4 × (0.25 + 0) − (0 − 1.5)²/2 − (0 − 0.30)²/2
            = 1.0 − 1.125 − 0.045 = −0.17 m²

Worksheet (30) Ext3 residual = 17.35 m² back-solves exactly:
  A_RR_shell = 12.5 × √(32.0 / 1.5)                = 57.7350
  Σ walls (incl. -0.17 absent gable)               = 40.3850
  residual = shell − walls                         = 17.3500  ✓ 4 d.p.

Pre-slice the mapper had two clamps that together dropped the
spec-computed −0.17 m² adjustment:

  mapper.py:3350  `if length_m <= 0 or height_m <= 0: return None`
                  → filtered out any H=0 surface
  mapper.py:3443  `area_m2 = max(0.0, length_m * (0.25 + H) − correction)`
                  → clamped negative gable areas at 0

Combined the cascade computed residual = 17.18 m² (cascade UNDER
by 0.17). Plus a related secondary `if height_m > h` filter on the
correction sum that masked the all-common-walls-taller case.

3-layer fix:

1. `datatypes/epc/domain/mapper.py` `_map_elmhurst_rir_surface`:
   - Split the early-return filter: drop only when L<=0 (no wall),
     OR when H<=0 AND not (Simplified Type 2 with common walls).
   - Apply the spec gable-area formula to BOTH `gable_wall` (party
     default) and `gable_wall_external` kinds in Simplified Type 2
     (the U-value routing differs by kind, but the area equation
     is the same).
   - Remove `max(0.0, ...)` clamp so the signed result reaches the
     cascade.
   - Remove `if height_m > h` correction-sum filter (spec applies
     the full square unconditionally).

2. `domain/sap10_calculator/worksheet/heat_transmission.py` per-
   surface loop:
   - `gable_wall` branch: skip `party += 0.25 × area` when area < 0
     (wall doesn't exist physically) but still add the signed area
     to `rr_walls_in_a_rr_area` so the residual deduction in step (d)
     grows by |area|.
   - `gable_wall_external` branch: same skip pattern for `walls +=
     u × area` and `rr_detailed_area += area`.

Cohort safety: only cert 000565 Ext3 hits this in the corpus. All
other cohort certs are Type 1 RR (no common walls, formula gives
the same answer) or have all gables H > 0. The cascade's per-element
test pins (Ext1's Connected gable + Exposed gable, Ext4's Detailed
RR) unchanged.

Cert 000565 cascade snapshot (HEAD a461b70d → this):
  roof_w_per_k         51.3185 → 51.3768  ✓ EXACT (Δ -0.06 → -0.003)
  total_external_area 857.46  → 857.6323  ✓ EXACT (Δ -0.18 → -0.008)
  thermal_bridging    128.62  → 128.6448  ✓ EXACT (Δ -0.03 → -0.005)
  total_w_per_k       936.97  → 937.0563  ✓ EXACT (Δ -0.09 → -0.004)

  sap_score (int)         29 ✓ EXACT (preserved)
  sap_score_continuous 28.5027 → 28.5007 (Δ -0.0060 → -0.0080)
  ecf                   5.3877 →  5.3876
  total_fuel_cost_gbp  4681.01 → 4680.97
  co2_kg_per_yr        6448.59 → 6448.53
  space_heating_kwh   59019.21 → 59018.52
  main_heating_fuel   34715.31 → 34716.78

**Cert 000565 fabric cascade now essentially exact** (HTC −0.004 W/K
total residual across all 8 fabric components). The remaining
continuous SAP -0.0080 / cost +£0.71 / SH +10 kWh residuals come
from non-fabric upstream (likely ventilation or appliances) —
candidates for a future audit.

Pyright net-zero (57 → 57 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:23:12 +00:00
Khalim Conn-Kowlessar
a461b70d19 Slice S0380.112: per-BP rooflight allocation (RdSAP 10 §3.7 p.19)
RdSAP 10 §3.7 (PDF p.19) verbatim:

  "for each building part, software will deduct window/door areas
  contained in the relevant wall areas"

The same per-BP deduction applies to roof windows / rooflights
piercing each BP's roof. Pre-slice the cascade lumped every
rooflight's area onto BP[0] Main's `rw_area_part` (S0380.106-era
convention), leaving the actual host BP's gross roof un-deducted.

Cert 000565 §11 Openings lodges:
  Roof Windows 1(Ext2)  External roof Ext2, 1.20 m²
  Roof Windows 2(Ext4)  External roof Ext4, 0.50 m²

Worksheet (30) ground truth — each rooflight deducts from its
host BP's gross roof:
  Ext2: 25.00 − 1.20 = 23.80 net × 0.30 = 7.1400 W/K
  Ext4:  3.00 − 0.50 =  2.50 net × 0.00 = 0.0000 W/K

Pre-slice cascade:
  Ext2: 25.00 (un-deducted) × 0.30 = 7.5000 (+0.36 W/K over)
  Plus 1.70 m² of RW area lumped onto Main's external aggregate
  → +1.20 m² double-count (Ext2 gross + Main rw_area_part)

3-layer fix:
1. `datatypes/epc/domain/epc_property_data.py`: add `window_location:
   Union[int, str] = 0` to SapRoofWindow (mirror of
   `SapWindow.window_location` shape).
2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`:
   thread `w.building_part` through (mirror of
   `_map_elmhurst_window`'s pass-through).
3. `domain/sap10_calculator/worksheet/heat_transmission.py`: pre-loop
   compute `rw_area_by_bp[i]` from each `SapRoofWindow.window_location`
   via the existing `_window_bp_index` resolver; per-BP loop reads
   `rw_area_by_bp[i]` instead of allocating everything to BP[0].

Cohort safety: cert 000516's lone rooflight is on the Main BP
(Summary §11 row "Main, External wall"), so the per-BP allocation
returns Main = 0 = same as the prior lump-on-Main convention. The
000516 hand-built fixture's SapRoofWindow now sets
`window_location="Main"` to mirror the Elmhurst mapper string-form.

Cert 000565 cascade snapshot (HEAD 794ef7ed → this):
  roof_w_per_k          51.6773 → 51.3185 (Δ +0.30 → -0.06)
  total_external_area  858.66  → 857.46  (Δ +1.02 → -0.18)
  thermal_bridging_w/k 128.80  → 128.62  (Δ +0.15 → -0.03)
  sap_score (int)          28 → 29 ✓ EXACT (recovered)
  sap_score_continuous 28.4903 → 28.5027  (Δ -0.0184 → -0.0060)
  ecf                   5.3887 →  5.3877
  total_fuel_cost_gbp  4681.89 → 4681.01
  co2_kg_per_yr        6449.73 → 6448.59
  space_heating_kwh   59031.86 → 59019.21
  main_heating_fuel   34724.63 → 34715.31

Closes the +1.20 m² Ext2 rooflight double-count. Remaining
residuals (Ext3 -0.17 m² + -0.06 W/K) closed by S0380.113 (H=0
gable retention).

Pyright net-zero (58 → 58 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 19:15:16 +00:00
Khalim Conn-Kowlessar
794ef7ed8b Slice S0380.111: roof-window inclination adj via Table 6e Note 2 (SAP 10.2 p.180)
SAP 10.2 §3.2 "Roof windows" (PDF p.10) verbatim:

  "In the case of roof windows, unless the measurement or calculation
  has been done for the actual inclination of the roof window,
  adjustments as given in Notes 1 and 2 to Table 6e or from BR443
  (2019) should be applied."

SAP 10.2 Table 6e Note 2 (PDF p.180) — "For roof windows the
following adjustments should be applied to convert a known vertical
U-value into the U-value for the known inclined position":

   Inclination                    Twin skin or DG    Triple skin or TG
   70° or more (vertical)               +0.0              +0.0
   < 70° and > 60°                      +0.2              +0.1
   60° and > 40°                        +0.3              +0.2
   40° and > 30°                        +0.4              +0.2
   30° or less (horizontal)             +0.5              +0.3

SAP 10.2 §3.2 formula (2):

    U_w,effective = 1 / (1/U_w + 0.04)                          (2)

The +0.04 curtain transform applies AFTER the Note 2 inclination
adjustment (the formula reads "U_w", which is the inclined-position
U for roof windows).

Pre-slice the mapper's `_elmhurst_roof_window_u_value` fall-through
branch returned the lodged Manufacturer U=2.0 directly (the vertical-
tested value per Table 6e header) without applying any inclination
adjustment. The cascade then applied formula (2) → U_eff = 1/(1/2.0 +
0.04) = 1.852 for both cert 000565 rooflights, totalling 1.7 × 1.852
= 3.1484 W/K vs the worksheet's (27a) Σ A × 2.1062 = 3.5806 W/K
(residual -0.43 W/K).

Cert 000565 §11 lodges 2 roof windows at pitch=45° (Openings table):
  Item 2 (Ext2 NR): 1.2 m², "Triple between 2002 and 2021",
    Manufacturer U=2.0, g=0.72, PVC FF=0.70
  Item 5 (Ext4 A):  0.5 m², "Double between 2002 and 2021",
    Manufacturer U=2.0, g=0.72, Wood FF=0.70

Both lodge at pitch=45° → Note 2 "60° and > 40°" row. The worksheet
applies +0.30 W/m²K uniformly to both (DG-column value), yielding
U_inclined = 2.30 → formula (2) → U_eff = 2.1062 in both cases.
Elmhurst's implementation uses the DG-column adjustment even for the
Triple-glazed item — the strict Note 2 Triple-column +0.20
alternative would yield 2.0222 for Item 2, contradicting the
worksheet's 2.1062.

Fix scope (mapper-side, single helper):

`datatypes/epc/domain/mapper.py` `_elmhurst_roof_window_u_value`:
  - New constant `_ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_
    M2K = 0.30` (Table 6e Note 2 DG @ 40-60°).
  - Fall-through branch now returns `w.u_value + 0.30` instead of
    `w.u_value` — converts the lodged vertical-tested Manufacturer U
    to the inclined-position U the cascade's formula (2) expects.
  - Lookup path (`_ELMHURST_ROOF_WINDOW_U_BY_GLAZING["Double pre 2002"]
    = 3.4`) unchanged: RdSAP10 Table 24 "Roof window" column values
    are already inclined-position, so the cohort case (000516 W6
    Manufacturer U=3.10 → Table 24 returns 3.40 → cascade formula
    (2) → 2.9930) stays bit-exact.

Cohort safety verified at 000516 worksheet (27a): U_eff = 2.9930
preserved (Table 24 lookup path unaffected).

Cert 000565 cascade snapshot (HEAD 9461e657 → this):
  roof_windows_w_per_k    3.1484  → 3.5806  ✓ EXACT (Δ -0.43 → +0.0001)
  total_w_per_k           937.09  → 937.51  (Δ +0.03 → +0.45 — closing
                                              roof_windows exposes
                                              previously-cancelling
                                              roof +0.30 + TB +0.15
                                              over-counts)
  sap_score (int)             29 → 28 (transiently — continuous
                                       crossed 28.5 rounding boundary
                                       downward; recovers when the
                                       roof/TB over-counts close in
                                       a subsequent slice — same
                                       pattern as S0380.107 → .108)
  sap_score_continuous   28.5002 → 28.4903 (Δ -0.0085 → -0.0184)
  ecf                     5.3877 → 5.3887   (Δ +0.0011 → +0.0021)
  total_fuel_cost_gbp    4681.01 → 4681.89  (+0.75 → +1.63)
  co2_kg_per_yr          6448.59 → 6449.73  (+0.96 → +2.10)
  space_heating_kwh     59019.18 → 59031.86 (+10.83 → +23.51)
  main_heating_fuel     34717.16 → 34724.63 (+6.37  → +13.83)
  lighting_kwh_per_yr         ✓ EXACT (preserved)

This is the [[feedback-spec-floor-skepticism]] pattern: a spec-correct
closure exposes previously-cancelling residuals elsewhere. Continuous
SAP magnitude widens (0.0085 → 0.0184) and integer SAP sign-flips
across the 28.5 boundary, but the spec-correct path is now in place.
The next slice would close the roof (+0.30) or TB (+0.15) over-counts
to recover integer SAP 29 and drive continuous SAP back toward zero.

Pyright net-zero (45 → 45 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:43:10 +00:00
Khalim Conn-Kowlessar
9461e657a5 Slice S0380.110: per-rooflight g_L in Appendix L L2a (SAP 10.2 p.88)
SAP 10.2 Appendix L §L2a (PDF p.88) verbatim:

    GL = 0.9 × Σ (Aw × gL × FF × ZL) / TFA                  (L2a)

    where
      FF is the frame factor (fraction of window that is glazed) for
          the actual window or from Table 6c
      Aw is the area of a window, m²
      gL is the light transmittance factor from Table 6b
      ZL is the light access factor from Table 6d

Table 6b gL (PDF p.178) — light transmittance column:
  Single glazed                     0.90
  Double glazed (any variant)       0.80
  Triple glazed (any variant)       0.70

Table 6d note 2 (PDF p.178): "A solar access factor of 1.0 and a light
access factor of 1.0 should be used for roof windows/rooflights."

Pre-slice `_daylight_factor_from_cert` collapsed every rooflight into
a single `rooflight_total_area_m2 × _G_LIGHT_DEFAULT (0.80) ×
_FRAME_FACTOR_DEFAULT (0.70)` product, overcounting any Triple-glazed
rooflight (gL=0.70) or any non-default frame factor.

Cert 000565 §11 lodges 2 rooflights (per S0380.107 routing):
  Item 2 (Ext2 NR rooflight): 1.2 m², "Triple between 2002 and 2021",
    PVC FF=0.70 → gL=0.70 (Table 6b Triple). Correct numerator
    contribution 1.2 × 0.70 × 0.70 = 0.588; pre-slice cascade used
    1.2 × 0.80 × 0.70 = 0.672 (+0.084 over).
  Item 5 (Ext4 A rooflight): 0.5 m², "Double between 2002 and 2021",
    Wood FF=0.70 → gL=0.80 (Table 6b Double). Already matched.

The +0.084 numerator delta lowered GL → lowered C_daylight → lowered
worksheet (232) by 2.17 kWh/yr.

3-layer fix:
1. `datatypes/epc/domain/epc_property_data.py`: add `glazing_type:
   int = 3` to SapRoofWindow (default = Double 2002-2021, the cohort
   modal).
2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`:
   populate `glazing_type` via `_elmhurst_glazing_type_code(w.
   glazing_type)` — mirror of `_map_elmhurst_window`.
3. `domain/sap10_calculator/worksheet/internal_gains.py`
   `_daylight_factor_from_cert`: iterate `epc.sap_roof_windows` for
   the rooflight g_L numerator, dispatching via existing
   `_G_LIGHT_BY_GLAZING_CODE` + `rw.frame_factor`. Z_L = 1.0 per
   Table 6d note 2.

Test coverage:
- AAA test `test_summary_000565_rooflight_per_window_g_l_routes_via_
  glazing_type_per_sap_10_2_appendix_l_l2a` pins both per-rooflight
  glazing codes (9 Triple / 3 Double) AND `inputs.lighting_kwh_per_
  yr` at 1384.8353 ±1e-4.
- 000516 hand-built fixture updated to explicitly set glazing_type=2
  ("Double pre 2002") matching the lodged label.

Cert 000565 cascade snapshot (HEAD 98a4b5b9 → this):
  sap_score (int)             29       ✓ EXACT (preserved)
  lighting_kwh_per_yr     1382.6657 → 1384.8353  ✓ EXACT (-2.17 → 0)
  sap_score_continuous     28.5028  →  28.5002   (Δ -0.0059 → -0.0085)
  ecf                       5.3874  →   5.3877   (Δ +0.0008 → +0.0011)
  total_fuel_cost_gbp    4680.78    → 4681.01    (+0.52 → +0.75)
  co2_kg_per_yr          6448.34    → 6448.59    (+0.72 → +0.96)
  space_heating_kwh     59020.02    → 59019.18   (+11.67 → +10.83)
  main_heating_fuel     34717.66    → 34717.16   (+6.87  → +6.37)

Lighting closure exposes a previously-cancelling residual elsewhere —
continuous SAP magnitude widens slightly (-0.0059 → -0.0085) but the
spec-correct path is now in place, per [[feedback-spec-floor-
skepticism]]. SH + main_heating_fuel improve (added lighting energy
contributes internal gains, reducing SH demand). Integer SAP 29 ✓
EXACT preserved.

Cohort safety: 6 cohort certs have at most 1 rooflight each
(000516 W6 only, lodged "Double pre 2002" → code 2). Their gL still
resolves to 0.80 via the existing `_G_LIGHT_BY_GLAZING_CODE` table,
so the per-rooflight dispatch produces the same numerator as the
old default branch.

Pyright net-zero (50 → 50 errors across touched files).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:31:35 +00:00
Khalim Conn-Kowlessar
efb203f7ad Slice S0380.109: Solid brick + insulation via §5.7 Table 13 + §5.8 Table 14 (RdSAP 10)
Closes the remaining cert 000565 BP[0] Main wall residual (-1.54 W/K
under ws) by routing solid-brick walls with documentary wall
thickness + lodged insulation through the RdSAP 10 §5.7 + §5.8
formula chain. Adds a Table-6 footnote (a) cap on the §5.6 stone
formula to handle thin uninsulated stone walls (Ext1 BP[1] Granite
W=50 mm).

RdSAP 10 §5.7 Table 13 (PDF p.41) verbatim:

  "Default U-values of brick walls
   Wall thickness, mm   U-value, W/m²K
   Up to 200 mm         2.5
   200 to 280 mm        1.7
   280 to 420 mm        1.4    ← cert 000565 Main W = 300 mm
   More than 420 mm     1.1"

RdSAP 10 §5.8 step 2 (PDF p.41-42) verbatim:

  "The U-value of the insulated wall is U = 1 / (1/U₀ + R_insulation)
   ...
   Where R_insulation comes from Table 14: Insulation thickness and
   corresponding resistance.
   ...
   R = 0.025 × T + 0.25 when λ = 0.04 W/m·K
   R = 0.0333 × T + 0.248 when λ = 0.03 W/m·K
   R = 0.040 × T + 0.25 when λ = 0.025 W/m·K
   Where T is thickness of insulation in mm"

Cert 000565 Main lodgement (Summary §7.0):
  Type SO Solid Brick (wall_construction = 3)
  Insulation E External (wall_insulation_type = 1)
  Insulation Thickness 75 mm
  Wall Thickness 300 mm (measured)
  Conductivity Known No  → λ defaults to 0.04 (§5.8 final note)
  Age band A

Formula chain:
  U₀ = 1.4 (§5.7 Table 13 row "280 to 420 mm")
  R  = 0.025 × 75 + 0.25 = 2.125 m²K/W
  U  = 1 / (1/1.4 + 2.125) = 1 / 2.8393 = 0.3522 → 0.35 (2 d.p.)

Pre-slice the cascade bucketed 75 mm into the Table-6 "100 mm
external/internal insulation" row → 0.32 for age A. The -0.03 U
delta on Main's 51.72 m² external wall is the entire -1.54 W/K
under-count driving the cohort's remaining fabric residual.

RdSAP 10 Table 6 footnote (a) (PDF p.34) verbatim:

  "Or from equations in 5.6 if the calculated U-value is less than
   1.7."

Applies only to the AS-BUILT (no insulation, no dry-line) Table 6
row. For thin walls where §5.6 gives U ≥ 1.7 the Table 6 row
default of 1.7 caps the result. Verified empirically against cert
000565 Main alt_wall_1 (granite W=120 mm dry-lined): raw §5.6 →
3.879 + dry-line → 2.34 matches worksheet, NOT capped 1.7 + dry-
line → 1.32. The cap therefore only fires when neither dry-lining
nor insulation is present (cert 000565 BP[1] Ext1: granite W=50 mm
"Insulation Unknown" → §5.6 = 6.09 → capped to 1.7, matches ws).

3-layer fix:
1. `domain/sap10_ml/rdsap_uvalues.py`:
   - Add `_u_brick_thin_wall_age_a_to_e(W_mm)` per §5.7 Table 13
   - Add `_r_insulation_table_14(T_mm, λ)` per §5.8 Table 14
     interpolation rule (handles all 3 λ columns)
   - Wire §5.7+§5.8 chain into `u_wall` for WALL_SOLID_BRICK + age
     A-E + lodged thickness + (External | Internal) insulation +
     thickness > 0
   - Add Table 6 footnote (a) cap to `_u_stone_thin_wall_age_a_to_e`
     (cap at 1.7 only when not dry-lined)
   - Round dry-lined §5.6 result to 2 d.p. (worksheet A×U precision)
2. `domain/sap10_calculator/worksheet/heat_transmission.py` passes
   `wall_thickness_mm=part.wall_thickness_mm` through to `u_wall`
   for the per-BP main wall U (previously passed only for alt walls).
3. AAA test pins cert 000565 walls_w_per_k = 604.07 within 1e-4.

Movement at HEAD `9159e91f` → post-slice (cert 000565):

Fabric (cascade vs ws):
  walls         602.53 → 604.08 (Δ -1.54 → +0.01 W/K — sub-spec
                                  alt-wall float rounding artifact)
  total W/K     935.54 → 937.09 (Δ -1.52 → +0.03 W/K — essentially
                                  zero net fabric HTC residual)

End-result pins:
  sap_score (int)    29 ✓ EXACT  (unchanged)
  sap_score_continuous 28.5380 → 28.5028  (Δ +0.0293 → -0.0059;
                                          80% magnitude reduction)
  ecf              5.3838 →  5.3874 (Δ -0.0028 → +0.0008)
  total_fuel_cost_gbp 4677.64 → 4680.78 (Δ -2.62 → +0.52)
  co2_kg_per_yr   6444.27  → 6448.34 (Δ -3.35 → +0.72)
  space_heating  58974.84  → 59020.02 (Δ -33.5 → +11.7)
  main_heating_fuel 34691.09 → 34717.66 (Δ -19.7 → +6.87)
  lighting_kwh    1382.67 (unchanged)
  pumps_fans_kwh ✓ EXACT (unchanged)

Continuous SAP magnitude improved 80% (0.0293 → 0.0059). All
SH-driven downstream residuals (cost, co2, SH kwh, main_heating
fuel) magnitude-reduced 65-80%. Integer SAP stays exact at 29.

Cohort safety verified: 6 cohort certs (000474-000516) lodge wc=4
(cavity) + wit=4 (as-built) — neither precondition for the new
§5.7+§5.8 path. §5.6 cap only fires when not dry-lined (cohort
certs don't trigger). All 11 cert→inputs and 6 sap_result_pin
cohort tests pass unchanged.

Golden cert 6035-7729-2309-0879-2296 (mid-terrace age A solid
brick) sees the §5.7+§5.8 chain fire on its Main wall:
  PE  +46.7562 → +46.0936 kWh/m² (cascade closer to actual EPC)
  CO2 +1.0652  → +1.0495 tonnes/yr (cascade closer to actual EPC)
Per [[feedback-golden-residuals-near-zero]] the expected pin is
updated to track the improvement (target → ~0 as mapper closes).

Test count: 608 pass + 7 expected 000565 fails → **608 pass + 7
expected 000565 fails** (new §5.7+§5.8 formula test green; golden
cert 6035 pin re-pinned; integer SAP stays at 29). Pyright net-zero
per touched file (27 baseline → 27 post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 18:10:33 +00:00
Khalim Conn-Kowlessar
9159e91fbc Slice S0380.108: Connected-to-heated-space RR gables deduct from A_RR (RdSAP 10 §3.9.2 + Table 4 row 4)
Closes the largest single localised fabric residual on cert 000565
(roof +1.59 W/K over, area +4.70 m² over) by routing
Connected-gable surfaces through a new `connected_wall` kind that
deducts area from the residual A_RR per the spec but contributes
0 W/K per RdSAP 10 Table 4 row 4.

RdSAP 10 §3.9.2 step (d) (PDF p.23) verbatim:

  "The areas of gable walls are deducted from the calculated total
   RR area, and the remaining area of RR, ARR_final is then
   calculated. This area is treated as roof structure.
       ARR_final = ARR_wall − (ΣARR_common_wall + ΣARR_gable +
                               ΣARR_party + ΣARR_sheltered +
                               ΣARR_connected)"

RdSAP 10 Table 4 row 4 (PDF p.22):

  "ARR_connected — Adjacent to heated space — U-value = 0"

The U=0 means no heat-loss contribution, but the area STILL appears
in the deduction equation as ΣARR_connected. Pre-slice the mapper's
`_map_elmhurst_rir_surface` returned None for Connected gables,
dropping them entirely from `detailed_surfaces` so the cascade
neither billed them nor deducted them. The residual A_RR was
therefore over by their lodged area.

Cert 000565 Ext1 §8.1 lodges (Simplified Type 2):
  Gable Wall 1   L=4.00  H=6.00  Connected  U=0
  Gable Wall 2   L=8.00  H=9.00  Exposed    U=1.70
  Common Wall 1  L=9.00  H=1.00  U=1.70
  Common Wall 2  L=5.00  H=1.80  U=1.70

Gable Wall 1 area via §3.9.2 quadratic:
  A_gable_1 = 4 × (0.25 + 6)
              − (6 − 1)²/2   ← subtract triangle above Common Wall 1
              − (6 − 1.8)²/2 ← subtract triangle above Common Wall 2
            = 25.0 − 12.5 − 8.82
            = 3.68 m²

Pre-slice:
  A_RR shell = 12.5 × √(34 / 1.5) = 59.51 m²
  Σ wall areas = 11.25 + 10.25 + 16.08 = 37.58 m²
  Residual    = 21.93 m² (worksheet: 18.25; over by +3.68)
  Roof W/K = 21.93 × 0.35 = 7.68 (worksheet: 6.39; over by +1.29)

3-layer fix:
1. Mapper `_map_elmhurst_rir_surface` (datatypes/epc/domain/mapper.py)
   now routes "Connected" gable_type to kind="connected_wall" with
   u_value=0 and area via the Simplified Type 2 quadratic correction.
2. Heat transmission `heat_transmission_from_cert` (domain/sap10_
   calculator/worksheet/heat_transmission.py) adds a connected_wall
   branch that deducts area from rr_walls_in_a_rr_area but skips
   walls/party W/K contribution.
3. AAA test pins Ext1 Connected gable area at 3.68 m² and U=0.

Movement at HEAD `b7fa5f74` → post-slice (cert 000565):

Fabric (cascade vs ws):
  walls           602.53 → 602.53 (Δ -1.54 W/K; unchanged)
  roof             52.97 →  51.68 (Δ +1.59 → +0.30 W/K; closes 81%)
  TB              129.35 → 128.80 (Δ +0.70 → +0.15 W/K; closes 79%)
  total area      862.34 → 858.66 (Δ +4.70 → +1.02 m²; closes 78%)
  total W/K       937.40 → 935.54 (Δ +0.33 → -1.52 W/K; sign flips)

End-result pins:
  **sap_score (int)   28 → 29 ✓ EXACT vs ws 29**  (RECOVERED from
                                                   S0380.107 transient
                                                   rounding flip)
  sap_score_continuous 28.4959 → 28.5380 (Δ -0.0128 → +0.0293)
  ecf                   5.3881 →  5.3838 (Δ +0.0015 → -0.0028)
  total_fuel_cost_gbp 4681.39  → 4677.64 (Δ +1.13 → -2.62)
  co2_kg_per_yr      6449.13  → 6444.27 (Δ +1.51 → -3.35)
  space_heating_kwh 59028.80  → 58974.84 (Δ +20.5 → -33.5)
  main_heating_fuel 34722.83  → 34691.09 (Δ +12.0 → -19.7)
  lighting_kwh       1382.67  → 1382.67 (unchanged)
  pumps_fans_kwh ✓ EXACT (unchanged)

Continuous SAP and downstream pins SIGN-FLIPPED again
(cascade was over post-.107, now under post-.108). Per user
direction: transient drift acceptable while closing a true
intermediate-value bug. The remaining net HTC -1.52 W/K is
mostly walls (-1.54 W/K) — closing the Detailed-RR walls
residual is the next leverage front.

Cohort safety: none of the 6 cohort certs lodge a Connected
gable (grep audit across all Summary fixtures). The new
`connected_wall` branch only fires for the cert 000565 Ext1 BP.

Test count: 606 pass + 8 expected 000565 fails → **608 pass +
7 expected 000565 fails** (sap_score back to exact + new
Connected-gable test green). Pyright net-zero per touched
file (57 baseline → 57 post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 17:40:42 +00:00
Khalim Conn-Kowlessar
b7fa5f74ec Slice S0380.107: window vs roof window routing via BP roof type (RdSAP 10 §3.7.1)
Replaces the U > 3.0 W/m²K heuristic with a 3-rule cascade
discriminator that uses the BP's lodged §8 roof type alongside the
glazing type. Closes cert 000565 windows misrouting where the
previous heuristic mis-classified 3 of 6 windows.

RdSAP 10 §3.7.1 (PDF p.21) verbatim:

  "Window data
   Window area is assessed by measuring all windows and roof windows
   throughout the dwelling. ...
   Additional information to be noted: ...
     • window or roof window;
     • orientation"

RdSAP 10 §8.2 (PDF p.50) verbatim (Glazed walls + glazed roof):

  "Glazed walls are taken as windows, glazed roof as rooflight, see
   window U-values in Table 24"

The source RdSAP data set carries the "Window (vertical) / Roof
window (inclined)" classification as a discrete assessor lodgement.
The Elmhurst Summary PDF §11.0 flattens that signal — every row's
Location column reads "External wall" regardless of physical
position. The mapper must therefore reconstruct the classification.

New heuristic, in priority order:

  1. "Single glazing" → never a rooflight. Approved Document L
     (2006+) disallows single-glazed rooflights on energy-efficiency
     grounds; SAP convention assumes Table 6c double-glazing minimum
     for any (27a) entry.

  2. BP roof type ∈ {"A Another dwelling above", "NR Non-residential
     space above"} → rooflight. These BPs have their own structural
     external roof distinct from a pitched dwelling roof — the
     worksheet (30) External roof + (27a) Roof Windows treatment
     follows this routing.

  3. U > 3.0 W/m²K → rooflight (cohort backstop, catches cohort cert
     000516 W6 Wood-frame Double pre-2002 U=3.10 on Main PA, the
     only U > 3 vertical-glazing reading the cohort lodges that the
     worksheet routes via (27a)).

  4. Otherwise vertical.

Cohort verification: all 6 cohort certs have BPs with only PA/PN
pitched roof types (no NR/A). Rule 2 doesn't fire on cohort certs;
rule 1 doesn't block any cohort rooflights (all cohort high-U
windows are Double glazed). Rule 3 catches cohort 000516 W6
unchanged. No cohort regressions on cert→inputs cascade pins.

Cert 000565 routing fix (Summary §11.0 6-window list):
  - Items 1, 6 (Main, Double, U=2.0) — vertical (unchanged)
  - Item 3 (Ext1, Double, U=1.74) — vertical (unchanged; Ext1 roof
    "S Same dwelling above" doesn't fire rule 2)
  - Item 4 (Main, Single, U=3.35) — vertical (rule 1; was wrongly
    classified as rooflight by U > 3 backstop)
  - Item 2 (Ext2 NR, Triple, U=2.0) — rooflight (rule 2)
  - Item 5 (Ext4 A, Double, U=2.0) — rooflight (rule 2)

Movement at HEAD `8effa2d0` → post-slice (cert 000565):

Fabric (cascade vs ws):
  walls         601.22 → 602.53 (Δ -2.85 → -1.54 W/K; closes 46%)
  windows         9.60 →  11.48 (Δ -1.87 →  0.00 W/K; ✓ EXACT vs ws)
  roof_windows    5.02 →   3.15 (Δ +1.44 → -0.43 W/K; cascade U
                                  formula gap exposed, see TODO below)
  net fabric    HTC Δ -0.99 → +0.33 W/K (magnitude improved 67%)

End-result pins:
  sap_score_continuous   28.5269 → 28.4959 (Δ +0.0182 → -0.0128;
                                            magnitude improved 30%)
  ecf                     5.3850 →  5.3881 (Δ -0.0016 → +0.0015)
  total_fuel_cost_gbp   4678.64  → 4681.39 (Δ -1.62 → +1.13)
  co2_kg_per_yr         6445.51  → 6449.13 (Δ -2.12 → +1.51)
  space_heating_kwh    58980.82  → 59028.80 (Δ -27.5 → +20.5)
  main_heating_fuel    34694.60  → 34722.83 (Δ -16.2 → +12.0)
  lighting_kwh          1387.02  → 1382.67 (Δ +2.19 → -2.17, sign
                                            flips: cascade DF now uses
                                            correct rooflight area;
                                            remaining gap is the
                                            rooflight g×FF default-vs-
                                            lodged drift, separate
                                            slice)
  pumps_fans_kwh ✓ EXACT (unchanged)

**Transient sap_score (integer) regression**: continuous SAP crossed
the 28.5 rounding boundary downward (28.5269 → 28.4959), so the
integer rounds to 28 instead of 29. This is a rounding artifact —
the continuous metric IS closer to ws (Δ magnitude 0.0182 → 0.0128).
Per user direction (NEXT_AGENT_PROMPT): primary metric is continuous,
transient drift OK while closing a true intermediate-value bug.
The integer pin returns to 29 once continuous SAP closes above the
ws value 28.5087.

S0380.103 cost test reframed: previously asserted total_fuel_cost
delta < +£0.05 over ws — a snapshot threshold that the SH-cascade
sign flip naturally breaks. The MEV cost split rate (12.4467
p/kWh kWh-weighted blend) is what S0380.103 specifically closes;
the test now pins that rate directly via `inputs.pumps_fans_
fuel_cost_gbp_per_kwh`, decoupled from downstream SH cascade
effects.

3-layer fix:
1. Mapper `_is_elmhurst_roof_window` predicate now takes the survey
   for BP roof type lookup; new `_elmhurst_bp_roof_type` helper.
2. Two call sites at lines 327, 331 pass `survey` through.
3. New AAA test `test_summary_000565_window_routing_uses_bp_roof_
   type_per_rdsap_10_section_3_7_1` pins the 4-vertical + 2-roof
   classification.

Test count: 605 pass + 7 expected 000565 fails → **606 pass + 8
000565 fails** (new window-routing test + S0380.103 test reframe
both GREEN; sap_score added to work queue as a rounding-boundary
artifact). Pyright net-zero per touched file (45 baseline →
45 post-change).

Open work (in decreasing leverage on continuous SAP):
  - Roof BP[1] Ext1 RR area formula refinement (+1.59 W/K over,
    deferred to a separate slice per the original handover)
  - Walls -1.54 W/K residual (Detailed-RR per-element investigation)
  - Roof window U formula gap (-0.43 W/K; cascade formula 1/(1/U +
    0.04) gives 1.852 for U_raw=2.0 but ws shows 2.1062)
  - Lighting rooflight g×FF default-vs-lodged drift (-2.17 kWh)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 17:25:38 +00:00
Khalim Conn-Kowlessar
8effa2d00d Slice S0380.106: MEV fans PE split via Table 12a Grid 2 + Table 12e (SAP 10.2 §10a / §10c)
PE-side mirror of S0380.103 (cost) + S0380.105 (CO2). Completes the
MEV cascade trifecta for off-peak tariff certs. Cert 000565
worksheet line (281):

  Pumps, fans and electric keep-hot  252.5159  1.5239  383.3796 (281)

The displayed factor (1.5239) is the ALL_OTHER_USES Table 12e Σ
days-weighted blend; the displayed product (383.3796) is the kWh-
weighted blend across the two Grid 2 categories:

  F_FANS  = 0.58 × F_code34 + 0.42 × F_code33 = 1.51268 kWh/kWh
  F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 1.52391 kWh/kWh
  F_eff   = (127.5159 × 1.51268 + 125.0 × 1.52391) / 252.5159
          = 1.51824 kWh/kWh
  PE      = 252.5159 × 1.51824 = 383.3796 kWh/yr ✓

Pre-slice the cascade applied 1.52391 to ALL 252.5159 kWh →
384.81 → +1.43 over ws.

SAP 10.2 Table 12a Grid 2 (PDF p.191) — same dispatch as Slice
S0380.105 — splits the off-peak high-rate fraction by end-use
between `FANS_FOR_MECH_VENT` and `ALL_OTHER_USES`.

SAP 10.2 Table 12e (PDF p.195) verbatim header:

  "Where electricity is the fuel used, the relevant set of factors
   in the table below should be used to calculate the monthly
   primary energy instead the annual average factor given in
   Table 12."

The Grid 2 high-rate fraction blends Table 12e high-rate × low-
rate codes per `F_blended = high_frac × F_high + (1 − high_frac)
× F_low`. MEV fans bill at the lower 0.58 high_frac → lower PE
factor on the higher-PE high-rate code 34. Identical structural
fix as the .105 CO2 slice; the only delta is the underlying Table
12 column.

2-layer fix:
1. New helper `_pumps_fans_primary_factor` in cert_to_inputs.py
   — mirror of `_pumps_fans_co2_factor_kg_per_kwh`. Returns kWh-
   weighted blend of FANS_FOR_MECH_VENT + ALL_OTHER_USES factors.
   Falls back to ALL_OTHER_USES rate on STANDARD / no-MEV certs.
2. Call site at line 4640 wires `mev_kwh_for_cost_split` +
   `pumps_fans_kwh` through the helper.

Movement at HEAD `8a3aaf7a` → post-slice (cert 000565):

| Pin                            | Pre        | Post       |
|--------------------------------|-----------:|-----------:|
| pumps_fans_primary_factor       |    1.52391 |    1.51824 |
| pumps_fans_pe_kwh_per_yr        |   384.8122 |   383.3797 |  ✓ EXACT vs ws (281)
| primary_energy_kwh_per_yr       | 62228.4896 | 62227.0570 |
| primary_energy_kwh_per_m2       |   194.5187 |   194.5143 |

No effect on sap_score_continuous (ECF is cost-based, not PE-based),
ecf, or any of the 7 currently-failing 000565 pins. The total PE
residual remains dominated by an unrelated SH cascade PE factor
gap (cascade 170 kWh/m² vs ws 135.6 — separate slice).

Cohort safety: STANDARD-tariff and no-MEV certs return the existing
ALL_OTHER_USES rate (helper falls through). No-MEV certs return
the same rate (mev_kwh_per_yr=0 short-circuit). Pyright net-zero
per touched file (45 baseline → 45 post-change).

Test count: 605 pass + 7 expected 000565 fails → **606 pass + 7
expected 000565 fails** (new
test_summary_000565_mev_fans_pe_factor_uses_table_12a_grid_2_
fans_for_mech_vent_split GREEN; 7 known 000565 fails set unchanged).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:59:56 +00:00
Khalim Conn-Kowlessar
8a3aaf7ae6 Slice S0380.105: MEV fans CO2 split via Table 12a Grid 2 + Table 12d (SAP 10.2 §10a / §10b)
Mirror of S0380.103 for the CO2 cascade. Cert 000565 worksheet line
(267):

  Pumps, fans and electric keep-hot  252.5159  0.1412  35.3349 (267)

The displayed factor (0.1412) is the ALL_OTHER_USES Table 12d Σ
days-weighted blend; the displayed product (35.3349) is the kWh-
weighted blend across the two Grid 2 categories:

  F_FANS  = 0.58 × F_code34 + 0.42 × F_code33 = 0.13872 kg/kWh
  F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 0.14116 kg/kWh
  F_eff   = (127.5159 × 0.13872 + 125.0 × 0.14116) / 252.5159
          = 0.13993 kg/kWh
  CO2     = 252.5159 × 0.13993 = 35.3349 kg/yr ✓

Pre-slice the cascade applied 0.14116 to ALL 252.5159 kWh →
35.6457 → +0.31 over ws.

SAP 10.2 Table 12a Grid 2 (PDF p.191) verbatim header:

  "Fractions of electricity used at the higher rate, for use in
   off-peak tariff calculations
   ...
   Fans for mechanical ventilation systems   10-hour: 0.58
   All other uses, and locally generated     10-hour: 0.80
     electricity"

SAP 10.2 Table 12d (PDF p.194) verbatim header:

  "Where electricity is the fuel used, the relevant set of factors
   in the table below should be used to calculate the monthly CO2
   emissions INSTEAD of the annual average factor given in Table
   12."

The Grid 2 high-rate fraction blends Table 12d high-rate × low-
rate codes per `F_blended = high_frac × F_high + (1 − high_frac)
× F_low`. MEV fans bill at the lower 0.58 high_frac → lower CO2
factor on the higher-carbon high-rate code 34. Cost-side S0380.103
landed the same split for tariff prices; this slice mirrors it
for the CO2 factor.

3-layer fix:
1. New helper `_pumps_fans_co2_factor_kg_per_kwh` returns the
   kWh-weighted blend across `FANS_FOR_MECH_VENT` + `ALL_OTHER_USES`
   factors. Falls back to the existing `ALL_OTHER_USES` rate on
   STANDARD tariff and no-MEV certs (cohort-safe).
2. cert_to_inputs.py wires `mev_kwh_for_cost_split` +
   `pumps_fans_kwh` through to the new helper.
3. Field `CalculatorInputs.pumps_fans_co2_factor_kg_per_kwh`
   already exists from S0380.65; calculator legacy path unchanged.

Movement at HEAD `7df3fef8` → post-slice (cert 000565):

| Pin                          | Pre        | Post       | Δ vs ws  |
|------------------------------|-----------:|-----------:|---------:|
| pumps_fans_co2_kg_per_yr     |    35.6457 |    35.3349 |   ✓ 0    |
| co2_kg_per_yr (TOTAL)        |  6445.8198 |  6445.5090 |  −2.1173 |

The total CO2 residual moves -1.81 → -2.12 (sign-flip pattern of
S0380.103): the previously-cancelling pumps_fans CO2 over-count
masked the main-heating-fuel CO2 under-count (downstream of the
§3-§8 SH cascade -16 kWh fuel residual). Per user direction
(NEXT_AGENT_PROMPT) transient continuous-SAP / TOTAL drift is OK
while closing a true spec-correct intermediate-value bug; the SH
cascade closure is a separate slice.

Cohort safety: STANDARD-tariff certs return the existing
ALL_OTHER_USES rate (helper falls through). No-MEV certs return
the same rate (mev_kwh_per_yr=0 short-circuit).

Test count: 604 pass + 7 expected 000565 fails → **605 pass + 7
expected 000565 fails** (new
test_summary_000565_mev_fans_co2_factor_uses_table_12a_grid_2_
fans_for_mech_vent_split GREEN). Pyright net-zero per touched
file (45 baseline → 45 post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:48:53 +00:00
Khalim Conn-Kowlessar
e3abe9b2b5 Slice S0380.103: MEV fans cost split via Table 12a Grid 2 FANS_FOR_MECH_VENT rate (SAP 10.2 Table 12a)
SAP 10.2 Table 12a Grid 2 (PDF p.191) splits off-peak electricity
costs into two categories:

  Other electricity uses                       Tariff    Fraction at
                                                          high rate
  Fans for mechanical ventilation systems      7-hour    0.71
                                              10-hour    0.58
  All other uses, and locally generated        7-hour    0.90
  electricity                                 10-hour    0.80

Cert 000565 (Dual meter, 10-hour off-peak, MEV decentralised) lodges
127.5159 kWh of MEV-fan electricity (line 230a) that bills at the
`FANS_FOR_MECH_VENT` blend (0.58 × 14.68 + 0.42 × 7.50 = 11.6644
p/kWh), distinct from the 125 kWh of other pumps_fans (45 kWh gas-
boiler flue fan + 80 kWh solar HW pump) which bills at the
`ALL_OTHER_USES` blend (0.80 × 14.68 + 0.20 × 7.50 = 13.2440 p/kWh).

Pre-slice the cascade applied `ALL_OTHER_USES` to ALL 252.5159 kWh,
over-counting MEV cost by 127.5159 × (0.13244 - 0.11664) = +£2.01/yr.

Worksheet pin verification (line (249)):
  "Pumps, fans and electric keep-hot ... 172.5159  13.2440  20.8338"
  127.5159 × 0.11664 + 45 × 0.13244 = £14.8753 + £5.9598 = £20.8351
  ≈ ws £20.8338 ✓
  Pump for solar water heating 80.0 × 0.13244 = £10.5952 ✓

Implementation (3-layer):
1. `calculator.py:CalculatorInputs` — new optional
   `pumps_fans_fuel_cost_gbp_per_kwh: Optional[float] = None`.
2. `calculator.py` legacy cost path — `pumps_fans_cost` resolves
   via the new field with fallback to `other_fuel_cost_gbp_per_kwh`.
3. `cert_to_inputs.py:_pumps_fans_fuel_cost_gbp_per_kwh` — computes
   the kWh-weighted blended rate when off-peak + MEV is lodged.
   Reuses `_mev_decentralised_kwh_per_yr_from_cert` (S0380.102) to
   recover the MEV portion.

Cohort safety: STANDARD-tariff certs (the entire cohort except cert
000565) get None back → existing `other_fuel_cost_gbp_per_kwh`
fallback unchanged. Certs without MEV (zero MEV kWh) also get None
→ no behavioural change.

Movement at HEAD (cert 000565):
- pumps_fans_kwh_per_yr ✓ EXACT (unchanged)
- total_fuel_cost_gbp: 4680.6514 → 4678.6372  (Δ +£0.39 → -£1.62)
- ecf: 5.3873 → 5.3850 (Δ +0.0007 → -0.0016)
- sap_score_continuous: 28.5043 → 28.5269 (Δ -0.0044 → +0.0182)

Continuous-SAP residual drifted from -0.0044 to +0.0182 in absolute
value: closing the MEV cost over-count exposes a pre-existing
space-heating cascade under-count (main_heating_fuel_kwh is -16 kWh
under ws). Per user direction [[feedback-spec-floor-skepticism]]:
shipping spec-correct intermediate-value fixes even when they
transiently drift continuous SAP. The remaining residual is now
SH-cascade driven; a separate slice.

Test count: 597 pass + 7 expected 000565 fails unchanged.

Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:07:55 +00:00
Khalim Conn-Kowlessar
a0413155ae Slice S0380.102: Wire MEV decentralised cascade into pumps_fans (SAP 10.2 §2.6.4 + Table 4f line 230a)
SAP 10.2 Table 4f line (230a) annual electricity for mechanical
ventilation fans, decentralised MEV branch:

    E_fans_kwh = SFPav × 1.22 × V

where SFPav is the §2.6.4 equation (1) flow-weighted average SFP
across every fan in the installation, with PCDB Table 322 supplying
per-configuration (flow, SFP) and PCDB Table 329 supplying the
ducting-type IUF.

This slice composes the foundation slices S0380.98 (Table 322),
S0380.99 (Table 329), S0380.100 (SFPav helper) into a cert-driven
cascade — `_mev_decentralised_kwh_per_yr_from_cert(epc)` reads:

    MV PCDF Reference Number  → PCDB Table 322 record (per-config SFP)
    Duct Type (Flexible/Rigid) → PCDB Table 329 in-use factor
    Wet Rooms count           → per-fan-type count distribution

Three coupled changes:

1. Elmhurst extractor + schema — `_extract_ventilation` parses §12.1
   "MV PCDF Reference Number", "Wet Rooms", "Duct Type", "Approved
   Installation". New fields on `VentilationAndCooling`.
2. Mapper — plumbs the lodgements through to
   `EpcPropertyData.mechanical_ventilation_index_number`,
   `.wet_rooms_count`, `.mechanical_vent_duct_type`. New
   `_elmhurst_mv_duct_type_int` helper (Flexible→1, Rigid→2 per PCDF
   Spec §A.20 field 12 convention) with strict-raise on unknown
   labels per [[unmapped-elmhurst-label]].
3. Cascade — `_table_4f_additive_components` calls the new
   `_mev_decentralised_kwh_per_yr_from_cert(epc)` to add the (230a)
   contribution alongside the existing flue-fan + solar-HW pump
   additions.

Per-fan count convention (reverse-engineered from cert 000565):
- Each PCDB-defined configuration (1..6) contributes 1 baseline fan.
- Through-wall configurations scale with wet-rooms count:
    through-wall kitchen (5):   wet_rooms_count fans
    through-wall other wet (6): wet_rooms_count + 1 fans
- Configurations with blank SFP (e.g. record 500755 in-duct codes 3,
  4) contribute 0 to the numerator but their flow rate to the
  denominator per SAP §2.6.4 "summation is over all the fans".

For cert 000565 (wet_rooms=2) this yields the worksheet's observed
fan distribution (1, 1, 1, 1, 2, 3) → SFPav = 11.7205 / 92.0 =
0.12740 W/(l/s), and (230a) = 0.12740 × 1.22 × 820.4385 = 127.5159
kWh/year ✓ matches worksheet line (230a) at 1e-4.

TODO: validate the count convention against a second MEV
decentralised fixture; the rule above fits cert 000565 alone.

Cert 000565 closure state at HEAD:
- pumps_fans_kwh_per_yr: 125.0 → 252.5159 ✓ EXACT (was 255.0 pre-arc;
  the MEV +127.5 contribution closes the residual)
- sap_score (int): 29 ✓ EXACT preserved
- sap_score_continuous: 28.69 (S0380.101 transient) → 28.5043 vs
  ws 28.5087 (Δ -0.0044). Was -0.0001 pre-arc — the MEV fix revealed
  a pre-existing residual elsewhere in the cost cascade (likely
  Table 12a HP-on-E7 high-rate split per the original TODO at
  mapper.py:4039-4040; deferred to a separate slice).

Test count: 603 pass + 7 expected 000565 fails (was 8 —
pumps_fans_kwh_per_yr flipped FAIL→PASS, removed from work queue).

Cohort safety: only cert 000565 lodges a non-None MV PCDF Reference
Number across the Summary fixture set; cohort certs return 0 from
`_mev_decentralised_kwh_per_yr_from_cert` (no MEV system).

Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:45:55 +00:00
Khalim Conn-Kowlessar
1b183f9c86 Slice S0380.101: HP SAP code 211-227/521-527 → main_heating_category=4 (SAP 10.2 Table 4a)
SAP 10.2 Table 4a (PDF p.165) lists "Heat pumps" as category 4 for
SAP main-heating codes:

    211-217 — ground/water source heat pumps
    221-227 — air source heat pumps (224 = ASHP 2013+, COP 1.70)
    521-527 — warm-air heat pumps

Cert 000565 Main 1 lodges `Main Heating SAP Code = 224` (ASHP 2013+)
with `PCDF boiler Reference = 0` — i.e. no PCDB Table 362 lookup is
possible. Pre-slice `_elmhurst_main_heating_category` returned None
on this path (the existing PCDB-Table-362-membership check failed),
falling through to the cascade's `_DEFAULT_PUMPS_FANS_KWH_PER_YR =
130` (incorrect — HP circulation pump's electricity is inside the
system COP per SAP 10.2 Table 4f line "Heat pumps", so the cascade
row is 0 kWh/year for category 4).

Single-line fix: after the existing PCDB-resolution branches, check
`mh.main_heating_sap_code in _HEAT_PUMP_SAP_MAIN_HEATING_CODES` and
return category 4 if so. New frozenset of HP codes (subset of the
existing `_ELECTRIC_SAP_MAIN_HEATING_CODES`).

Transient state at HEAD (cert 000565):
- main_heating_category: None → 4 ✓
- pumps_fans cascade: 255.0 → 125.0 kWh/yr (HP base 0 + flue 45 +
  solar HW 80; MEV +127.5 kWh still missing — wiring lands in
  S0380.102)
- sap_score (int): 29 ✓ EXACT preserved
- sap_score_continuous: 28.31 → 28.69 (transient drift +0.39 vs ws;
  the previously-cancelling +130 over-count is gone, restoring the
  MEV-under net negative — closes when S0380.102 lands)

Cohort safety: cohort certs 000474..000516 are gas-combi with
`sap_main_heating_code=None` (PCDB Table 105 boiler identified via
the index instead). No cohort cert affected. Cert 0380 + other
golden HP fixtures lodge category=4 via the API mapper, also
unaffected.

Per the spec citation in [[feedback-spec-citation-in-commits]] +
the standing TODO at mapper.py:4037-4043, this slice is the
category half of the coupled cert 000565 closure arc.

Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:32:51 +00:00
Khalim Conn-Kowlessar
7121a86b86 Slice S0380.97: Floor "Insulation Thickness" extractor + mapper (RdSAP 10 §5.13 Table 20)
RdSAP 10 Specification §5.13 "U-values of exposed and semi-exposed
upper floors" (PDF p.47) + Table 20:

  "Otherwise, to simplify data collection no distinction is made in
   terms of U-value between an exposed floor (to outside air below)
   and a semi-exposed floor (to an enclosed but unheated space
   below) and the U-values in Table 20 are used."

  Table 20 (excerpt, age bands A-G | H or I):
    Age band     Unknown/as built   50mm   100mm   150mm
    A to G            1.20           0.50   0.30    0.22
    H or I            0.51           0.50   0.30    0.22

Cert 000565 Summary §9 2nd Extension lodges:
  Location:               U Above unheated space
  Type:                   N Suspended, not timber
  Insulation:             R Retro-fitted
  Insulation Thickness:   200 mm
  Default U-value:        0.22

Pre-slice the extractor's `_floor_details_from_lines` did NOT read
the "Insulation Thickness" cell (only the §8 roof extractor had the
field). FloorDetails carried no thickness → mapper plumbed
`SapBuildingPart.floor_insulation_thickness=None` → cascade
`u_exposed_floor(age=H, ins=None)` returned U=0.51 (Table 20 row[0]
unknown/as-built) vs worksheet 0.22 (Table 20 150 mm column for
age H) — over-counting BP[2] floor by (0.51-0.22) × 30 m² = +8.70
W/K.

Three-layer fix:

1. Schema (`elmhurst_site_notes.py:FloorDetails`) — add
   `insulation_thickness_mm: Optional[int] = None` (mirror of
   `RoofDetails`).
2. Extractor (`elmhurst_extractor.py:_floor_details_from_lines`) —
   parse "Insulation Thickness" via existing `_local_val` (mirror of
   `_roof_details_from_lines` pattern at line 333).
3. Mapper (`mapper.py:_map_elmhurst_building_part`) — translate
   `floor.insulation_thickness_mm` to `SapBuildingPart.floor_
   insulation_thickness=f"{n}mm"` (digit-prefix string convention
   matching the API mapper + the wall pattern at line 3125-3129).

Cascade no-op: existing `_parse_thickness_mm` accepts "200mm" → 200;
`u_exposed_floor(age=H, ins=200)` returns 0.22 (clamps thickness ≥
125 mm to Table 20 row[3]) ✓.

Movement at HEAD (cert 000565):
- BP[2] Ext2 floor cascade U: 0.51 → 0.22 ✓ EXACT vs ws 0.22
- floor_w_per_k: 70.37 → 61.67 ✓ EXACT vs ws 61.67 (closed +8.70)
- sap_score (int): 28 → 29 ✓ EXACT vs ws 29
- sap_score_continuous: 28.31 → 28.5086 vs ws 28.5087 (Δ -0.20 →
  -0.0001 — within 1e-4 strict floor!)
- SH: -38 kWh vs ws (was +218 → essentially closed)

Test count: 587 → 590 pass (+2 new AAA tests + sap_score integer
pin flipped from FAIL to PASS) + 8 expected 000565 fails (sap_score
integer pin removed from the work queue).

Cohort safety: only cert 000565 §9 lodges "Insulation Thickness"
(grep audit across Summary fixtures); cohort certs lodge "As built"
or omit the line. Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:50:39 +00:00
Khalim Conn-Kowlessar
32a4cf2080 Slice S0380.96: RIR insulation "Unknown" thickness extractor + mapper (RdSAP 10 §3.10.1)
RdSAP 10 Specification §3.10.1 (PDF p.24) "Default U-values of the
roof rooms":

  "Where the details of insulation are not available, the default
   U-values are those for the appropriate age band for the
   construction of the roof rooms (see Table 18 : Assumed roof
   U-values when Table 16 or Table 17 do not apply). The default
   U-values apply when the roof room insulation is 'as built' or
   'unknown'."

Cert 000565 Summary §8.1 BP[4] Ext4 lodges:
  Flat Ceiling 1   5.00   1.00   Unknown   PUR or PIR   0.15   No
Worksheet line (30): `Roof room Ext4 Flat Ceiling 1: 5 × 0.15 =
0.75 W/K` (U985-0001-000565 line 333).

Pre-slice the extractor allow-list `_RIR_INSULATION_THICKNESS_RE
| ("As Built", "None")` did NOT include the "Unknown" thickness
token, so the cell was dropped (`insulation = ""`). The mapper
translated `""` to `insulation_thickness_mm=0`, and the cascade
hit Table 17 row 0 → U=2.30 vs worksheet 0.15 (over-counting
BP[4] FC1 by +10.75 W/K on a 5 m² ceiling).

Two-layer fix:

1. Extractor (`elmhurst_extractor.py:_parse_rir_surface_row`) — add
   "Unknown" as the third spec-valid thickness token alongside
   "As Built" and "None".
2. Mapper (`mapper.py:_elmhurst_rir_insulation_thickness_mm`) —
   return `Optional[int]`; "Unknown" → None. The cascade's existing
   `_u_rr_table_17` already falls back to `u_rr_default_all_elements`
   (Table 18 col 4) when thickness is None — for cert 000565 BP[4]
   age band M, returns 0.15 W/m²K ✓.

Cascade no-op: the existing None → Table 18 col 4 fallback IS the
spec-correct path per §3.10.1; no calculator changes needed.

Movement at HEAD (cert 000565):
- BP[4] FC1 cascade U: 2.30 → 0.15 ✓ EXACT vs ws 0.15
- roof_w_per_k: 63.72 → 52.97 (Δ +12.34 → +1.59, closed -10.75)
- sap_score_continuous: 28.07 → 28.31 (Δ -0.44 → -0.20)
- sap_score (int): 28 (continuous still below 28.5 threshold;
  remaining residual + BP[1] residual + BP[2] floor)
- SH: +533 → +218 kWh

Test count: 585 → 587 pass (+2 new AAA tests) + 9 expected 000565
fails unchanged.

Cohort safety: "Unknown" RIR insulation appears only in cert 000565
across the Summary fixture set (grep audit); cohort certs lodge
concrete thickness or "None"/"As Built". Pyright net-zero per
touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:40:59 +00:00
Khalim Conn-Kowlessar
fa6974bdd9 Slice S0380.95: Detailed-RR residual area cascade per RdSAP 10 §3.10.1
RdSAP 10 §3.10.1 (PDF p.24) "Default U-values of the roof rooms":

> "The residual area (area of roof less the floor area of room(s)-in-
>  roof) has a U-value from Table 16 : Roof U-values when loft
>  insulation thickness is known according to its insulation thickness
>  if at least half the area concerned is accessible, otherwise it is
>  the default for the age band of the original property or extension."

Plus RdSAP 10 §3.9.1 step (d-e) (PDF p.21-22) — the Simplified A_RR
formula `12.5 × √(A_RR_floor / 1.5)` is the empirical estimator for
the total RR exposed shell; residual = A_RR − Σ lodged walls. The
worksheet applies this same formula to Detailed mode when the lodged
surface set has no roof-going entries (cert 000565 BP[0]:
12.5 × √(45/1.5) − (9.8 + 14.7) = 43.96 ≈ ws 43.97).

Pre-slice the cascade computed residual area ONLY in the Simplified
RR branch (via `_part_geometry`'s `rr_simplified_a_rr_m2` − rr_common
− rr_gable subtractions). The Detailed-RR branch in
`heat_transmission` iterated `rir.detailed_surfaces` and missed the
residual entirely. Cert 000565 routes all 5 BPs through Detailed mode
(the Elmhurst mapper translates Summary "Simplified" lodgements to
`SapRoomInRoofSurface` records when per-surface L×H is present), so
cascade total_external_element_area_m2 was 779.27 m² vs worksheet
(31) = 857.64 m² (Δ −78.37 m² → thermal_bridging cascade −11.76 W/K
under).

Slice span (1 file):
- `heat_transmission.py`: Detailed-RR branch adds residual area via
  the §3.9.1 A_RR formula minus wall-going lodgements (gable_wall,
  gable_wall_external, common_wall). Residual area contributes to
  `rr_detailed_area` (→ part_external_area → (31) → thermal_bridging
  multiplier) and to `roof` at `u_rr_default_all_elements`.
- Discriminator: residual fires only when no roof-going surface kinds
  (slope, flat_ceiling, stud_wall) are lodged — true Detailed-mode
  lodgements (cohort fixture 000516) lodge the entire roof shell
  explicitly and have no residual.

Cert 000565 movement (HEAD `78c57c0d` → this slice):
  - thermal_bridging_w_per_k:    116.89 → 129.35 ✓ vs ws 128.65 (Δ +0.70)
  - total_external_area_m2:      779.27 → 862.34 ✓ vs ws 857.64 (Δ +4.70)
  - roof_w_per_k:                34.64  → 63.72 (Δ −16.74 → +12.34)
  - sap_score_continuous:        29.02  → 28.07 (Δ +0.51 → −0.44)
  - sap_score (integer):         29 → 28        (temp regression
                                                  past 28.5 threshold)
  - space_heating_kwh:           −685   → +533
  - main_heating_fuel:           −403   → +321
  - hot_water_kwh:               ✓ 0 EXACT unchanged

Per user direction temporary continuous-SAP drift is acceptable when
fixing real spec-correct sub-component bugs; the absolute continuous-
SAP residual is now −0.44 (was +0.51) — slightly closer to zero
overall. The roof overshoot localises to:
  - BP[4] Flat Ceiling 1 "Unknown PUR or PIR" lodgement (cascade 2.30
    vs ws 0.15, over by +10.75 W/K) — Elmhurst-specific "Unknown +
    known material" convention not yet wired
  - BP[1] residual formula gives +3.68 m² over worksheet (Δ +1.29 W/K)
    — Detailed-mode residual is spec-ambiguous for extensions with
    non-2.45 m RR height; future slice may add a height-aware formula

Cohort safety: discriminator `has_roof_lodgement` filters out true
Detailed-mode lodgements (cohort fixtures 000474/000477/000480/
000487/000490/000516 all lodge slope/flat_ceiling/stud_wall surfaces).
Initial implementation broke 41 cohort pins; the discriminator
restores cohort behaviour exactly. Test baseline: 585 pass + 9
expected `000565` fails (was 585 + 8 — sap_score moved from passing
to failing during the slice's transient overshoot; expected per
user direction).

Pyright net-zero per touched file (test_summary_pdf_mapper_chain.py
13 → 13 preserved; heat_transmission.py 13 → 12 improved by −1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:21:59 +00:00
Khalim Conn-Kowlessar
78c57c0dc7 Slice S0380.94: RIR insulation "400+ mm PUR or PIR" extractor + mapper + cascade (RdSAP 10 Table 17 col 3b)
RdSAP 10 §5.11.3 + Table 17 (PDF p.42-43) "Roof room U-values when
insulation thickness is known". Column (3b) "Stud wall — PUR or PIR
optional" 400 mm row → 0.10 W/m²K. Cert 000565 Summary §8.1 BP[2] Ext2
(Detailed) lodges:

  Stud Wall 2  2.00 × 2.00   400+ mm   PUR or PIR   Default U=0.10

Pre-slice three coupled bugs silently dropped the lodgement, routing
the cascade through the uninsulated Table 17 row 0 (U=2.30) — over-
counting Stud Wall 2 by (2.30 − 0.10) × 4 m² = +8.80 W/K on roof:

1. **Extractor regex** `_RIR_INSULATION_THICKNESS_RE = ^\d+\s*mm$`
   failed to match the "400+ mm" bucket-cap form (Table 17's largest
   tabulated row is annotated with a trailing "+" in the Summary).
2. **Extractor insulation_type allow-list** `("Mineral or EPS",
   "PUR", "PIR")` failed to match the disjunction "PUR or PIR" — the
   actual Summary form when the assessor doesn't distinguish PUR from
   PIR. (Both columns Table 17 column (b) anyway.)
3. **Mapper thickness parser** `_elmhurst_rir_insulation_thickness_mm`
   used the same `^\d+\s*mm$` regex — also failed on "400+ mm".

Plus a fourth coupled fix: the cascade's `_is_rigid_foam` checked a
frozenset `{"pur", "pir", "rigid"}` that didn't include the canonical
mapper-side code "rigid_foam" — even if the mapper translated "PUR or
PIR" → "rigid_foam", the cascade would route to column (a) mineral-
wool instead of column (b) rigid-foam.

Slice span (4 layers):
1. **Extractor regex** — `^\d+\+?\s*mm$` matches both "100 mm" and
   "400+ mm".
2. **Extractor allow-list** — add "PUR or PIR" alongside individual
   "PUR" / "PIR" + "Mineral or EPS".
3. **Mapper** — `_RIR_INSULATION_TYPE_TO_SAP10` canonicalises all
   rigid-foam strings to "rigid_foam"; thickness parser regex matches
   "400+ mm" → 400 mm int.
4. **Cascade** — `_RR_RIGID_FOAM_INSULATION_TYPES` adds "rigid_foam"
   alongside the legacy "pur"/"pir"/"rigid" aliases.

Cert 000565 movement (HEAD `23aaa4fa` → this slice):
  - cascade BP[2] Ext2 Stud Wall 2 U:  2.30 → 0.10 ✓ EXACT vs ws 0.10
  - cascade roof_w_per_k:              43.44 → 34.64 (Δ−7.94 → Δ−16.74)
  - sap_score:                         29 ✓ EXACT unchanged
  - sap_score_continuous:              28.81 → 29.02 (Δ+0.26 → Δ+0.51)
  - space_heating_kwh:                 −427 → −685
  - main_heating_fuel:                 −251 → −403
  - hot_water_kwh:                     ✓ 0 EXACT unchanged

Closing one spec-correct sub-component while others remain non-spec-
correct drifts continuous SAP further; per user direction temporary
drift is acceptable as long as we're fixing true intermediate-value
problems — once every sub-component is spec-correct, the continuous
SAP error closes to zero by construction. The remaining −16.74 W/K
roof gap localises to:
  - BP[0/1/3] missing RR residual area for Detailed-RR mode (§3.10.1
    spec — cascade only handles Simplified mode today); +27.85 W/K
    closure when wired.
  - BP[4] Flat Ceiling 1 lodges "Unknown thickness, PUR or PIR" → ws
    U=0.15; cascade over-counts at 2.30 (uninsulated). Elmhurst's
    "Unknown PUR or PIR" → 200 mm convention is non-spec; the spec-
    correct path falls back to Table 18 col 4 default (`u_rr_default
    _all_elements`). Separate diagnostic slice.

Cohort safety: 21 other Elmhurst Summary fixtures lodge no RIR detailed
surfaces with "400+ mm" or "PUR or PIR" (modal cohort uses As Built /
None / no detailed surfaces). Existing "Mineral or EPS" tests at
`test_u_rr_stud_wall_table17_col3a_mineral_wool_100mm_returns_0_36`
remain green — the new aliases extend rather than replace.

Test baseline: 585 pass + 8 expected `000565` fails (was 583 + 8; +2
new tests). Pyright net-zero per touched file (0/32/1/65/13 preserved).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:08:05 +00:00
Khalim Conn-Kowlessar
23aaa4fa66 Slice S0380.93: floor above partially-heated space U=0.7 (RdSAP 10 §5.14)
RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a partially heated
space":

> "The U-value of a floor above partially heated premises is taken as
>  0.7 W/m²K. This applies typically for a flat above non-domestic
>  premises that are not heated to the same extent or duration as the
>  flat."

Cert 000565 Ext1 lodges Summary §9 "Location: P Above partially
heated space" + "Default U-value: 0.70". Worksheet line (28b) confirms
"Exposed floor Ext1 ... 34.0000 0.7000 23.8000".

Pre-slice the cascade routed BP[1] floor through the BS EN ISO 13370
ground-floor formula (the "else" branch of the floor U-value dispatch
in `heat_transmission.py`) — producing cascade U=0.76 vs spec 0.70.
Over-counted floor heat loss by (0.76 − 0.70) × 34 m² = +2.04 W/K on
the part subtotal and on the total HTC.

Slice span (4 layers):
1. **Helper** — `u_floor_above_partially_heated_space()` in
   `domain/sap10_ml/rdsap_uvalues.py`, verbatim spec constant 0.7
   (no age-band / insulation-thickness inputs). Lives in `sap10_ml`
   per [[project-sap10_ml-deprecation]] (edit existing file fine).
2. **Schema** — `SapFloorDimension.is_above_partially_heated_space:
   bool = False` (parallel to existing `is_exposed_floor`). Mutually
   exclusive with the exposed-floor / basement-floor branches.
3. **Mapper** — new `_is_floor_above_partially_heated_space(location)`
   helper detecting "above partially heated" in the Elmhurst §9 floor
   location string. Plumbed into `_map_elmhurst_building_part` floor-
   dim construction; only applies to the ground floor (i==0).
4. **Cascade** — `heat_transmission.py` adds a new branch between
   the exposed-floor and ground-floor branches: `is_above_partial →
   u_floor_above_partially_heated_space()`.

Cert 000565 movement (HEAD `a7894b11` → this slice):
  - cascade floor_w_per_k:    72.41 → 70.37 (Δ +10.74 → Δ +8.70)
  - cascade BP[1] floor U:    0.76  → 0.70  (✓ EXACT vs ws 0.70)
  - sap_score (integer):      29 ✓ EXACT (unchanged — at goal)
  - sap_score_continuous:     28.7663 → 28.8131 (+0.0468 drift)
  - space_heating_kwh:        −367 → −427 (small drift further under)
  - main_heating_fuel:        −216 → −251 (downstream of SH)
  - co2_kg_per_yr:            −32   → −37
  - total_fuel_cost_gbp:      −23   → −27
  - hot_water_kwh:            ✓ 0 EXACT unchanged

The small continuous-SAP drift is the expected arithmetic of closing
a single component when adjacent components remain unclosed (floor
+10.74 was cancelling thermal_bridging −11.76 + roof −7.94 at the
net-HTC level). Per [[feedback-zero-error-strict]] + [[feedback-
spec-citation-in-commits]] the spec-correct slice ships regardless
of transient continuous-SAP drift; remaining residual components
(floor +8.70 from BP[2] Ext2 lodged 200 mm insulation thickness;
roof −7.94; thermal_bridging −11.76; walls −1.67) each get their own
spec-cited slice.

Cohort safety: only cert 000565 Ext1 in the cohort lodges "Above
partially heated space". All other Elmhurst cohort fixtures + 9
golden + 38 cohort-2 API certs default to `is_above_partially_
heated_space=False` so cascade behaviour is unchanged.

Test baseline: 583 pass + 8 expected `000565` fails (was 582 + 8;
+1 new mapper-chain test). Pyright net-zero per touched file
(1/65/1/32/13/13 preserved).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:53:28 +00:00
Khalim Conn-Kowlessar
a7894b1185 Slice S0380.92: AP4 + MEV decentralised plumbing (SAP 10.2 §2 (17a)/(18)/(23a)/(24c))
SAP 10.2 §2 lines (17a)/(18) "Air permeability value, AP4 (m³/h/m²)"
(PDF p.12-13):

> "The air permeability at 4 Pa (AP4) measured with the low-pressure
>  pulse technique [...] is used in the following formula to estimate
>  of the air infiltration rate at typical pressure differences.
>  In this case (9) to (16) of the worksheet are not used."
>
>   Air infiltration rate (ach) = 0.263 × AP4^0.924
>
>   If based on air permeability value at 4 Pa,
>   then (18) = [0.263 × (17a)^0.924] + (8)

SAP 10.2 §2 lines (23a)/(24c)/(25) "MEV" + "Whole-house extract
ventilation" (PDF p.13/133):

> "The SAP calculation is based on a throughput of 0.5 air changes per
>  hour through the mechanical system."  (23a) = 0.5
>
>   If whole house extract ventilation or positive input ventilation
>   from outside:
>     if (22b)m < 0.5 × (23b), then (24c) = (23b)
>     otherwise (24c) = (22b)m + 0.5 × (23b)

Cert 000565 lodges:
- Summary §12.1 "Mechanical Ventilation Type: Mechanical extract,
  decentralised (MEV dc)" (PCDF 500755)
- Summary §12.2 "Test Method: Pulse" + "Pressure Test Result (AP4): 2.00"

Pre-slice both lodgements were silently dropped by the Elmhurst
extractor / mapper / `cert_to_inputs` cascade:

- AP4 had no schema field on `VentilationAndCooling` or `SapVentilation`
  even though `ventilation.py:ventilation_from_inputs(air_permeability_
  ap4=...)` already implemented the spec formula.
- Mechanical Ventilation Type had no schema field; `cert_to_inputs.
  ventilation_from_cert` hardcoded `mv_kind=MechanicalVentilationKind.
  NATURAL` regardless of the lodgement, routing cert 000565 through
  the (24d) natural-vent formula instead of (24c).

These bugs are coupled: AP4 alone would close (18) but the cascade's
(25) NATURAL pass-through would then *under*-count the effective ach
by 0.25 (the missing MEV contribution). MEV alone would over-count
because the (18) over-count remains. Per [[feedback-bigger-slices-
for-uniform-work]] + handover precedent on coupling-aware reverts,
these land together.

Slice span (5 layers):
1. **Schema** — `VentilationAndCooling.air_permeability_ap4_m3_h_m2` +
   `VentilationAndCooling.mechanical_ventilation_type` (site-notes);
   `SapVentilation.air_permeability_ap4_m3_h_m2` +
   `SapVentilation.mechanical_ventilation_kind` (domain).
2. **Extractor** — `_extract_ventilation` parses "Pressure Test Result
   (AP4)" scoped to §12.2 and "Mechanical Ventilation Type" scoped to
   §12.1. Both default to None when the cert lodges no MV / no Pulse
   test (cohort modal case).
3. **Mapper** — `_map_elmhurst_ventilation` plumbs AP4 through; new
   `_ELMHURST_MV_TYPE_TO_KIND` dispatch with strict-raise on unmapped
   labels (per [[reference-unmapped-elmhurst-label]] mirror pattern).
4. **cert_to_inputs** — `ventilation_from_cert` reads AP4 and resolves
   `mechanical_ventilation_kind` name → `MechanicalVentilationKind`
   enum. MEV/MV/MVHR kinds set `mv_system_ach=0.5` per spec (23a).
5. **Tests** — 4 in test_summary_pdf_mapper_chain.py (extractor + mapper
   for both AP4 and MEV kind), 2 in test_cert_to_inputs.py (cascade
   AP4 formula + MEV kind dispatch). All AAA-structured.

Cert 000565 movement (HEAD `83218630` → this slice):
  - cascade (18) pressure_test_ach:  2.4037 → 2.0287 ✓ EXACT vs ws 2.0287
  - cascade (21) shelter-adj:        2.0431 → 1.7244 ✓ EXACT vs ws 1.7244
  - cascade mean (25)m:              2.2347 → 2.1360 vs ws 2.086 (+0.05)
  - **sap_score (integer):           28     → 29 ✓ EXACT vs ws 29** (Δ−1 → Δ 0)
  - sap_score_continuous:            27.99  → 28.77 (Δ−0.52 → +0.26)
  - ecf:                             5.44   → 5.36  (Δ+0.05 → −0.03)
  - total_fuel_cost_gbp:             4726.75 → 4657.37 (Δ+46 → Δ−23)
  - co2_kg_per_yr:                   6506.48 → 6415.56 (Δ+59 → Δ−32)
  - **space_heating_kwh:             +631   → −367**   (~75% closed)
  - main_heating_fuel:               +371   → −216    (~58% closed)
  - hot_water_kwh:                   ✓ 0 EXACT unchanged
  - lighting / pumps_fans:           sub-spec residuals unchanged

The residual cascade-over-by-0.05 ach on (25)m is the cascade using
the cert-agnostic Table U2 wind tuple instead of the cert's regional
wind lookup; future ventilation_from_cert wires a `postcode_climate`
arg through which `cert_to_demand_inputs` already does for the demand
cascade, but the SAP-rating cascade keeps the Table U2 default.

Cohort safety:
- All 21 other Elmhurst cohort fixtures lodge `pressure_test_method=
  "Not available"` and `mechanical_ventilation=False` → both new
  fields default to None → cascade behaviour unchanged.
- 9 golden + 38 cohort-2 API certs route through `_map_sap_ventilation`
  (the API mapper variant), which leaves both new SapVentilation
  fields at their None default → cascade behaviour unchanged.

Test baseline: 582 pass + 8 expected `000565` fails (was 575 + 9; +6
new tests + sap_score reclassified from fail to pass). 1763 pass in
broader sap10_ml + worksheet + epc.domain suites + 3 pre-existing
fails unchanged. Pyright net-zero per touched file (1/0/0/32/34→32/13/
11 → 1/0/0/32/32/13/11, cert_to_inputs.py improved −2).

Per [[project-sap10_ml-deprecation]] the new fields live on the
existing `SapVentilation` domain type; no new modules under sap10_ml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:29:50 +00:00
Khalim Conn-Kowlessar
8321863015 Slice S0380.91: party-wall Cavity-masonry-filled U=0.2 (RdSAP 10 Table 15 row 3)
RdSAP 10 §5.10 Table 15 (PDF p.42) "U-values of party walls":

  Party wall type                                     U
  ---------------------------------------------       ----
  Solid masonry / timber frame / system built         0.0
  Cavity masonry unfilled                             0.5
  Cavity masonry filled                               0.2
  Unable to determine, house or bungalow              0.25
  Unable to determine, flat or maisonette*            0.0

Pre-slice the cascade collapsed CF (Cavity masonry filled) into the same
SAP10 wall_construction code 4 as CU (Cavity masonry unfilled), so the
filled-cavity row's spec U=0.2 was silently rounded up to the unfilled
U=0.5. The mapper at `_ELMHURST_PARTY_WALL_CODE_TO_SAP10["CF"]: 4` and
`_API_PARTY_WALL_CONSTRUCTION_TO_SAP10[3]: 4` both flagged this as a
known approximation since S0380.64; today's slice closes it.

Introduces a party-wall-only synthetic SAP10 code
`WALL_CAVITY_FILLED_PARTY = 11` (distinct from the main wall_construction
codes 1-10 since Table 15 treats filled vs unfilled cavity as separate
party-wall types). `u_wall` doesn't consume code 11 so main-wall U-value
cascades are unaffected. Cohort + golden audit: only cert 000565 Ext1
lodges CF on the Elmhurst side; zero golden certs lodge API code 3, so
flipping the dispatch is scoped to one BP.

Cert 000565 movement (HEAD edb1e6b8 → this slice):
  - cascade party_walls_w_per_k:  93.255 → 65.13 ✓ EXACT vs worksheet 65.13
  - sap_score (integer):          27 → 28           (Δ−2 → Δ−1)
  - sap_score_continuous:         27.3534 → 27.9893 (Δ−1.16 → Δ−0.52)
  - space_heating_kwh:            60468.18 → 59639.74 (Δ+1460 → Δ+631; 57% closed)
  - main_heating_fuel_kwh:        35569.52 → 35082.20 (Δ+859 → Δ+371; 57% closed)
  - co2_kg_per_yr:                6581.12 → 6506.48   (Δ+133 → Δ+59)
  - total_fuel_cost_gbp:          4784.29 → 4726.75   (Δ+104 → Δ+46)
  - hot_water_kwh:                3755.03 ✓ EXACT unchanged
  - lighting / pumps_fans:        sub-spec residuals unchanged

Continuous SAP at 27.9893 is 0.51 below the 28.5 rounding-up threshold;
the remaining +631 SH residual (ventilation +27 W/K + doors missing +21
W/K + downstream) pushes integer score to 29 once those land.

Cohort + 9 golden API + 38 cohort-2 API + 6 U985 Elmhurst certs all
unaffected (no CF lodgements; party_wall_construction=4 still routes to
0.5 for CU). Existing `test_u_party_wall_unfilled_cavity_returns_table15
_value` regression-guards code 4 stays at U=0.5.

Test baseline: 575 pass + 9 expected `000565` fails (was 574 + 9, +1 net
new cascade pin test). 105/105 pass in `test_rdsap_uvalues.py` including
new CF unit test. Pyright net-zero per touched file (baseline 1/65/32/13
preserved). 3 pre-existing failures in adjacent test files (test_heat_
transmission roof + basement, test_from_rdsap_schema floor_area) unchanged.

Per [[project-sap10_ml-deprecation]] the synthetic code constant lives
alongside its consumer `u_party_wall` in `domain/sap10_ml/rdsap_uvalues.py`
(editing the existing file). When the deprecation migration moves
`rdsap_uvalues.py` to `domain/sap10_calculator/`, `WALL_CAVITY_FILLED_
PARTY` moves with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 10:08:53 +00:00
Khalim Conn-Kowlessar
6c8bbbc9e2 Slice S0380.86: §5.6 thin-wall stone + §5.8 dry-line closes BP[0] alt1 cascade gap
RdSAP 10 §5.6 (PDF p.40) "U-values of uninsulated stone walls, age
bands A to E":

  Table 12 — Default U-values of stone walls
    Sandstone or limestone:    U = 54.876 × W^(-0.561)
    Granite or whinstone:      U = 45.315 × W^(-0.513)
  Where W is wall thickness in mm.

  "Apply the adjustment according to Table 14: Insulation thickness
   and corresponding resistance if wall is insulated or dry-lined
   including lath and plaster."

Combined with §5.8 (PDF p.40) + Table 14 (PDF p.41) dry-line R = 0.17
m²K/W: U = 1 / (1/U₀ + 0.17).

Cert 000565 BP[0] Main alt1 is the cohort fixture: Stone Granite, age
band A (inherited from Main), 120 mm wall thickness, dry-lined.
§5.6 formula: U₀ = 45.315 × 120^(-0.513) ≈ 3.8871.
§5.8 + Table 14 dry-line: U = 1/(1/3.8871 + 0.17) ≈ **2.3405**.
→ matches worksheet U985-0001-000565 line (29a) "External walls Main
alt.1 ... SolidWallDensePlasterInsul, Solid, 0.0, 2.34" EXACT.

Pre-S0380.86 two coupled bugs blocked this path:

  1. Mapper mis-name per [[feedback-no-misleading-insulation-type]]:
     `_map_elmhurst_alternative_wall` routed the Elmhurst Summary §7
     "Alternative Wall N Thickness" lodging (the WALL thickness)
     onto `SapAlternativeWall.wall_insulation_thickness="120"`. The
     cascade then mis-bucketed it as 100 mm insulation (bucket=100
     → _BRICK_INS_100 row at age A → U=0.32). The Elmhurst Summary
     schema has no "Alternative Wall N Insulation Thickness" line at
     all — `wall_insulation_thickness` on alts was always
     semantically the wall thickness, never insulation.

  2. `u_wall` had no §5.6 thin-wall stone branch. Stone constructions
     fell through to Table 6 row values (designed for typical-
     thickness ~300mm+ walls), which dramatically under-state heat
     loss for sub-200mm stone.

Fix span:

  - datatypes/epc/domain/epc_property_data.py:SapAlternativeWall:
      new `wall_thickness_mm: Optional[int] = None` field, mirroring
      `SapBuildingPart.wall_thickness_mm`.
  - datatypes/epc/domain/mapper.py:_map_elmhurst_alternative_wall:
      routes Elmhurst `a.thickness_mm` (Wall thickness) onto
      `wall_thickness_mm`; leaves `wall_insulation_thickness=None`
      on this path (no Elmhurst Summary alt-wall insulation-thickness
      line exists).
  - domain/sap10_ml/rdsap_uvalues.py:
      new `_u_stone_thin_wall_age_a_to_e(construction, W)` helper
      implements §5.6 Table 12 formulas. `u_wall` accepts a new
      `wall_thickness_mm: Optional[int] = None` param; dispatches
      §5.6 formula when (a) wall thickness lodged, (b) age band ∈
      A-E, (c) construction ∈ {STONE_GRANITE, STONE_SANDSTONE}.
      §5.8 + Table 14 R=0.17 applied on top when dry_lined=True.
  - domain/sap10_calculator/worksheet/heat_transmission.py:
      `_alt_wall_contribution_w_per_k` passes
      `wall_thickness_mm=alt_wall.wall_thickness_mm` to `u_wall`.

Tests (7 new, AAA-structure):

  - 5 in domain/sap10_ml/tests/test_rdsap_uvalues.py — granite at
    120 mm with dry-line (U=2.34); granite raw formula (U=3.89);
    sandstone (U=3.74); age-G gate (Table 6 row, NOT formula); no
    wall_thickness fallback (Table 6 row 1.7).
  - 2 in backend/documents_parser/tests/test_summary_pdf_mapper_chain
    .py — mapper pin (wall_thickness_mm=120 on BP[0] alt1;
    wall_insulation_thickness=None) and cascade pin (walls_w_per_k
    ≥ 595, post-S0380.85 was 555.93).

**Cert 000565 cascade walls: 555.93 → 602.40 W/K (worksheet 604.07;
0.27% residual).** BP[0] alt1 cascade U: 0.32 → 2.34. Cascade walls
within 2 W/K of worksheet target across S0380.85+.86 closure cycle.

Test baseline: 560 pass (was 558 + 7 new − 5 already passing pins
that moved) + 9 expected `test_sap_result_pin[000565-*]` fails
unchanged. Cohort + golden + cert 9501 unaffected: of the 6 cohort
fixtures only cert 000565 alt1 lodged a `wall_insulation_thickness`
value on `SapAlternativeWall` (audit confirmed) — and that value was
always semantically the wall thickness, so the rename is a fix not
a behaviour change. The API mapper path defaults `wall_thickness_mm`
to None (API schema doesn't yet surface alt-wall thickness; safe
forward-compat).

Per [[feedback-verify-handover-claims]]: the post-S0380.84 handover
predicted SH residual would close after the wall fixes. Empirically
SH grew +2591 → +6348 → +7924 across S0380.84/.85/.86 — confirming a
SEPARATE SH-channel over-count that's independent of fabric (each
+1 W/K of spec-correct walls adds ~33.5 kWh of cascade SH, vs the
worksheet's ~38.96 kWh/W/K rate). The walls fixes are spec-correct;
the SH over-count is now a single isolated open work-item for the
next slice (~+8 k kWh structural).

Pyright net-zero per touched file (test_rdsap_uvalues.py error count
actually decreased by 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:37:46 +00:00