SAP 10.2 Appendix N3.7 ("Thermal efficiency for water heating - heat pumps",
PDF p.109): "multiply the thermal efficiency for water heating by the in-use
factor in Table N8; subject to a minimum efficiency of 100%." Our
_heat_pump_apm_efficiencies applies the in-use factor but omits the floor.
Anchored to golden fixture case 56 (PCDB 100061, cert 100110101713): an
oversized HP (PSR 3.107) extends water,3 198.9% -> 128.55%, x 0.60 in-use =
77.13% < 100% -> the accredited Elmhurst worksheet (216) reads 100.0000, we
read 77.13%. In-range PSR keeps 0.60 x 198.9 = 119.34% (above the floor).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RdSAP 10 §11.1 / SAP 10.2 Appendix M: PV is included in a dwelling's
assessment only if connected to the dwelling's own electricity meter. The
gov-API pv_connection enum encodes this — 0=no PV, 1=present-but-not-
connected, 2=connected. Corpus-validated (57 PV certs: pv_connection=1 MAE
4.48->1.22 without credit, 0/5 need it; pv_connection=2 needs it, MAE 0.98
vs 10.29) and Elmhurst-proven (connected SAP 87 vs not-connected 74).
cert_to_inputs currently credits a pv_connection=1 array; the test pins that
it must contribute zero generation. Adds pv_connection to make_minimal_sap10_epc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gov-API flat can lodge dwelling_type="Mid-floor flat" while carrying its
own exposed roof — a top-floor flat mislabelled mid-floor. _dwelling_exposure
keyed roof exposure on the dwelling_type label alone, dropping the roof
heat-loss term: space-heating demand under-read ~32%, SAP over-read +7.
Fix: when the main building part lodges a *determined* roof_insulation_location
(an RdSAP integer code, not the "ND" Not-Defined party-ceiling sentinel),
expose the roof regardless of a contradictory label. Structured field, not a
description string and not roof_construction (which the gov-API lodges
building-wide on every unit, so it is not a per-unit signal).
On the RdSAP-21.0.1 corpus roof_insulation_location separates the classes with
zero disagreement: all 190 party-ceiling flats lodge "ND"; the 4 mid/ground
flats this exposes all move toward lodged, 0 away. within-0.5 73.3% -> 73.6%,
MAE 0.774 -> 0.761 (ratchets tightened). Verified end-to-end on the same
block: 715363 (location 6, RHI 2694) 81 -> 74 = lodged; genuine mid-floor
sibling 715395 (location ND, RHI 1024) stays party at 83 = lodged.
The override is additive (only ever exposes a label-dropped roof) and reads
the main part, so multi-part flats with a party main ceiling stay party.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gov-API cert can lodge dwelling_type="Mid-floor flat" while carrying a
real exposed roof element (roof_construction != 7 "dwelling above") over a
"(another dwelling below)" floor — i.e. a top-floor flat mislabelled
mid-floor. Property 715363 (uprn 6027561) + sibling 715395 (6027563) do
exactly this; the correctly-labelled top-floor sibling 715871 (6027574),
same block + same flat roof, already computes the lodged SAP 74.
_dwelling_exposure keys roof exposure on the dwelling_type label alone, so
it drops the roof heat-loss term, under-reading space-heating demand ~32%
(calc 1833 vs lodged RHI 2694) and over-reading SAP +7 (81 vs 74).
Pins the fix: a mid-floor label + lodged exposed roof must expose the roof
(floor stays party). Also corrects the existing mid-floor fixture to lodge
the party-ceiling code 7 (the default 4 is an exposed pitched roof).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the hot-water (Table 13 / HP-DHW), secondary (direct-acting),
main-2 and ALL_OTHER_USES High-Rate Fractions on CalculatorInputs from
the same Table 12a helpers the SAP cost path uses, so Bill Derivation's
day/night split matches the rating's exactly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SAP 10.2 Table 12d/12e: electric water heating on a 7-/10-hour tariff bills
CO2/PE at the high-rate code (32/34) and low-rate code (31/33), kWh-weighted
by the Table 13 high-rate fraction. The cost path already applied this split;
the CO2/PE factors did not — they used the flat annual Table 12 figure
(0.136 CO2 / 1.501 PE) for ALL dual-rate electric HW.
That flat-annual behaviour (slice S0380.163) was validated only against
HW-from-main "low-rate cost" certs (100% low, no high-rate split). It is NOT
how Elmhurst bills a whc-903 ELECTRIC IMMERSION: the hand-built case-50
worksheet (000565 + dual immersion, 7-hour) splits HW CO2/PE into "high rate
cost" (CO2 0.1475 / PE 1.5514) + "low rate cost" (CO2 0.1238 / PE 1.4429)
weighted by the Table 13 fraction 0.1009. So flat-0.136 for immersion HW was
a spec gap on our side, not an Elmhurst divergence.
Fix: `_electric_immersion_hw_high_rate_fraction` threads the Table 13 fraction
(scoped to whc-903, 7-/10-hour, cylinder data present) into the HW CO2 + PE
factor helpers, which then blend the Table 12d/12e high/low codes. The flat
rule is unchanged for HW-from-main and 18-/24-hour (no Table 12d split), so
the S0380.163 41-variant cases and the existing pin are untouched.
Case 50: rating CO2 2413.48 -> 2397.1237 = Elmhurst EXACT; demand CO2 2007.1384
EXACT; demand PE +111 -> +32.5 residual (within corpus PE noise). Corpus
unchanged 73.3% / MAE 0.774 / CO2 0.08 / PE 3.4 (62 whc-903 off-peak certs;
aggregate gauges hold). SAP unaffected (cost-based).
Pin: test_whc903_immersion_hw_co2_pe_factors_split_high_low_on_off_peak; doc
updated in SAP_CALCULATOR.md §8.1.
pyright strict gate not run locally (pyright not installed in this container).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SAP 10.2 Table 12a Grid 2 (PDF p.191) bills "Fans for mechanical ventilation
systems" at 0.71 (7-hour) / 0.58 (10-hour), distinct from "All other uses"
(0.90 / 0.80) which covers circulation pumps, flue fans and the solar HW pump.
The cost-split mech-vent kWh (`mev_kwh_for_cost_split`) only summed the
decentralised-MEV (230b) fans, not the (230a) MVHR fan electricity — even
though the total pumps/fans bucket adds both. So an MVHR dwelling on an
off-peak tariff billed its fan electricity at the 0.90/0.80 "all other uses"
rate instead of 0.71/0.58. The comment already said "MEV/MVHR-fan portion";
only the MEV term was wired when MVHR landed. Fixed to mirror both
mechanical-ventilation fan terms summed into the total.
Worksheet-proven on simulated case 50 (000565 semi + MVHR Vent Axia + dual
electric immersion, Unknown meter -> 7-hour via the §12 dual-immersion
trigger): the fan bucket (315.64 kWh, 100% MVHR per worksheet line 230a) was
billing at 14.311 p/kWh (0.90) vs Elmhurst's 12.451 p/kWh (0.71) — +£5.87/yr,
-0.23 SAP. After the fix our existing-dwelling rating reconciles to Elmhurst
EXACTLY: SAP value 38.8426 (=Elmhurst 38.8426 -> 39), total cost £1317.0116
(=Elmhurst £1317.0116 to the penny).
Same `mev_kwh_for_cost_split` feeds the CO2 + PE cascades, so all three split
consistently. 0 corpus impact (all 3 corpus MVHR certs are standard tariff);
gauge unchanged 73.3% / MAE 0.774 / CO2 0.08 / PE 3.4.
Pin: test_mvhr_fan_electricity_bills_at_grid2_fan_fraction_on_off_peak.
pyright strict gate not run locally (pyright not installed in this container).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SAP 10.2 Table 4a electric boilers (PDF p.170) split across three distinct
Table 12a Grid 1 SH rows (PDF p.191), not one "direct-acting" family as the
stale TODO in `_table_12a_system_for_main` implied:
- 191 Direct-acting electric boiler -> "Direct-acting electric boiler (a)"
row: 7-hour 0.90, 10-hour 0.50 (NOT the 1.00/0.50 "Other direct-acting
electric heating" room-heater row).
- 193/194/195/196 Electric dry core / water storage boiler -> "Electric dry
core or water storage boiler" row: 7-hour 0.00 (charged wholly off-peak =
100% low rate, identical to the None fallback).
- 192 Electric CPSU -> Appendix F; left falling through to None (off-peak
low) until the Appendix-F high-rate cascade is implemented.
The enum + fractions already existed in table_12a.py; only the code->enum
mapping was missing. Resolves the TODO and pins the spec-correct 0.00 for the
storage boilers so 195 can't be mis-"fixed" up to a direct-acting fraction.
Forward guard, 0 corpus impact: storage boilers already billed 100% low via
the None fallback, and all corpus 191 certs are on standard tariff (Table 12a
off-peak split never fires). Corpus gauge unchanged 73.3% / MAE 0.774.
Pin: test_electric_boilers_191_195_map_to_distinct_table_12a_grid1_rows.
pyright strict gate not run locally (pyright not installed in this container).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SAP 10.2 Table 3a (PDF p.160) additional combi loss (61)m. Two coupled
defects, both surfaced by simulated case 49 (000565 + gas combi, U985
"Combi keep hot type = None") sitting at SAP 71.43 vs the worksheet's 72:
1. The cascade defaulted EVERY non-PCDB combi to the flat keep-hot
time-clock row (600 × n/365). A combi WITHOUT a keep-hot facility uses
row 1 (600 × fu × n/365, fu = V_d/100 when daily HW < 100 L/day) —
over-counting (61)m for the no-keep-hot cohort. `water_heating_from_
cert` now defaults to the "without keep-hot" row.
2. `pcdb_combi_loss_override` returned None for keep_hot_facility=1/
timer=1, leaning on the OLD flat-600 default. So flipping the default
silently turned 190 corpus PCDB keep-hot-time-clock combis into
no-keep-hot. Fixed to return the flat keep-hot row EXPLICITLY.
Key insight (the Summary is the input echo; the U985 keep-hot line is a
computed OUTPUT, so it must be derivable): keep-hot rides on the PCDB
boiler record (Table 105 keep_hot_facility/timer), resolved by
`pcdb_combi_loss_override`. A generic SAP-code combi with no PCDB record
(case 49, PCDF ref 0) has no keep-hot by construction → row 1. So the
default is not a guess — it is the spec-correct value for non-PCDB combis.
Worksheet-proven: case 49 → cost £726.696, SAP 72 — matching the
accredited worksheet to the digit (continuous 71.6945 = the worksheet's
own 71.6945). 000516 (keep-hot None) also exact (£860.716, SAP 63);
000490 (PCDB 10328, keep_hot_facility=1/timer=1) keeps its flat-600 via
the PCDB path. Masked until now because every prior combi-loss worksheet
fixture was keep-hot (000490/000474/000480 time-clock) or had V_d >= 100
every month (001431, rows coincide); case 49 is the first no-keep-hot one.
Corpus within-0.5 72.7% -> 73.3%, MAE 0.781 -> 0.774, PE 3.5 -> 3.4;
ratcheted _MAX_SAP_MAE 0.785 -> 0.775, _MAX_PE_PER_M2_MAE 3.6 -> 3.5.
Note: pyright strict type gate not run locally (pyright not installed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MVHR (24a) heat-recovery support, part 2: the mapper + cascade wiring.
Both source paths now resolve balanced whole-house MV with heat recovery
to the MVHR kind:
- gov-API: `_API_MECHANICAL_VENTILATION_TO_KIND` 4 → "MVHR" (was None /
treated as natural — under-stated ventilation heat loss, over-rating).
- Elmhurst Summary: `_ELMHURST_MV_TYPE_TO_KIND` "Mechanical ventilation
with heat recovery (MVHR)" → "MVHR" (was UnmappedElmhurstLabel, which
blocked the whole Summary for MVHR dwellings).
cert_to_inputs resolves the in-use heat-recovery efficiency + SFP for an
MVHR cert (`_mvhr_system_values`): pick the PCDB Table 323 data point by
the lodged wet-room count (SAP 10.2 §2.6.4), multiply the raw efficiency
by the Table 329 ducts-inside-envelope in-use factor (0.90) and the raw
SFP by the per-duct-type factor (rigid 1.4), and feed:
- the §2.6.6 eq (2) effective-air-change credit (23c) → (24a)/(25)m;
- the (230a) fan electricity (in-use SFP × 1.22 × V), costed but NOT
added to the Table 5a gains (its effect is in the efficiency).
An MVHR lodged with no PCDF index falls back to the SAP 10.2 Table 4g
default (raw efficiency 66% × 0.70, raw SFP 2.0 × 2.5).
Worksheet-proven on simulated case 49 (000565 semi + Vent Axia Sentinel
Kinetic B 500140 + gas combi → Elmhurst Current SAP 72): every MVHR line
matches Elmhurst exactly — (33) fabric heat loss 100.5923, (23c) in-use
efficiency 81.9% = 91 × 0.90, (25)m Jan 0.8571, (230a) fan electricity
415.9325, (231) total pumps/fans 501.9325. The residual SAP 71 vs 72 is
the known 000565-family space-heating-demand artifact (same -1/-2 seen on
cases 47/48), not the MVHR logic.
Corpus: within-0.5 72.6% -> 72.7%, MAE 0.788 -> 0.782, PE 3.6 -> 3.5.
The 3 gov-API MVHR certs: Flat 1 +6 -> 0 (Table 4g default path) and
12a Princes Gate +3 -> +1 (heat-recovery credit); Apartment 707 -4 -> -6
is a separate baseline under-rate (it under-rated as natural too — the
MVHR credit correctly adds ventilation loss per Elmhurst's method).
Ratcheted _MAX_SAP_MAE 0.79 -> 0.785, _MAX_PE_PER_M2_MAE 3.7 -> 3.6.
Note: pyright strict type gate not run locally (pyright not installed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chasing the space-heating demand gap on "simulated case 48" (main 691 + Unknown
meter + 903 dual immersion): our SAP 55 vs Elmhurst 57. Every §10a cost line
already matched to the penny; the residual was demand — our space-heating
energy 3849.8 kWh vs Elmhurst 3513.8 (+9.6%). Traced through the worksheet: our
ventilation heat loss (38) ran ~35.5 W/K vs Elmhurst 27.76 — we were adding 20
m3/h of intermittent extract fans (the Table 5 age-band default) on a dwelling
with a decentralised mechanical extract (dMEV) system that lodges 0 fans.
SAP 10.2 §2 (PDF p.13): a whole-house mechanical EXTRACT system provides
extraction via the (23a) 0.5 system air-change rate; the lodged intermittent
extract-fan count (7a) is then explicit — a lodged 0 means 0 (the dMEV is the
ventilation), NOT "unknown". The Table 5 default is an unknown-fallback for
NATURALLY ventilated dwellings only, so it must not be substituted here.
Fix: for EXTRACT_OR_PIV_OUTSIDE, take vc.intermittent_fans as-is (no age-band
default). Worksheet-proven on two dMEV builds of cert 000565: "case 48" lodges
(7a)=0 -> our SAP 55 -> 57 EXACT; the original 000565 fixture lodges (7a)=2 and
keeps 2 (its e2e pins are unchanged). An earlier draft that forced fans=0 broke
000565 (which legitimately has 2) — corrected to "lodged as-is".
within-0.5 72.5% -> 72.6%, MAE 0.789 -> 0.788; CO2/PE unchanged. The fix also
reduces a systematic under-rating bias in the 21-cert dMEV cohort (median dSAP
-0.22 -> -0.08). Scoped to EXTRACT_OR_PIV_OUTSIDE; balanced MVHR/MV kinds left
untouched pending their own worksheet. SAP-schema regression
test_18_0_0 pin 80 -> 81 (closer to its lodged 84, same cause). Spec-pinned in
test_cert_to_inputs (dMEV-lodged-0 vs natural-default). pyright not installed
in this container -- strict type gate not run locally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two second-main fuel errors mis-cost a dual-main dwelling whose two main systems
burn different fuels (SAP 10.2 §10a worksheet (213) bills main 2 at its own fuel):
1. Off-peak/legacy scalar cost path (calculator.py + cert_to_inputs.py): main 2's
kWh was priced at main 1's `space_heating_fuel_cost_gbp_per_kwh` scalar. Split
main 1 vs main 2 and price main 2 at its OWN rate via the new
`_main_2_space_heating_fuel_cost_gbp_per_kwh` (+ CalculatorInputs field).
Scoped to a NON-electric second main (wood/oil/coal) — an electric second
main keeps main 1's scalar (its off-peak Table 12a split is the deferred §10a
slice; per-system splitting it regresses the off-peak electric cohort, certs
13 Parkers Hill / 34 Dunley Road). 0 corpus impact (no corpus cert has a
non-electric second main on an off-peak meter).
2. Elmhurst Summary mapper (mapper.py): when §14.1 omits the Fuel Type cell, a
fuel-fired second main (room-heater SAP code) inherited main 1's fuel. Derive
it from the SAP code's Table 4a category (solid 631-636 -> house coal, gas ->
mains gas, liquid -> oil) before the main-1 inherit, mirroring
`_elmhurst_secondary_fuel_from_sap_code` (same modal sub-fuel caveat). Boiler
codes (<601) still inherit main 1 (case 6 oil rads+UFH).
simulated case 47 (electric room heaters + solid room heaters 633): our SAP
37.81 -> 55.09 vs Elmhurst current 57 (residual is the wood-vs-coal sub-fuel the
Summary export does not carry). Corpus unchanged 72.5% / MAE 0.793; batch 0
raised / 0 diverge; 000565 e2e green. (mapper.py also carries an earlier,
behaviour-free roof-window doc comment.) Spec-cited unit pins added (AAA).
pyright not installed locally — strict type gate not run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Main heating system 2's space-heating fuel cost (worksheet (213)) was billed
at main system 1's Table 32 unit price (`main_2_high_rate_gbp_per_kwh` reused
`main_1_high_rate_gbp_per_kwh`). For a dual-FUEL pair this grossly mis-costs the
second main: cert 10032957680 "Copse Cottage" (main 1 electric room heaters
fuel 30, main 2 wood logs fuel 6) charged its 9481 kWh of wood at 13.19 p/kWh
instead of 4.23 p/kWh — +£850/yr → SAP 21.75 vs lodged 45.
Route main 2 through its own fuel code (`_main_fuel_code(details[1])`), mirroring
the existing secondary-fuel handling. Copse Cottage 21.75 -> 45.94. Corpus
within-0.5 holds 72.5%, SAP MAE 0.815 -> 0.793 (ratcheted ceiling 0.82 -> 0.80);
CO2/PE unchanged. Same-fuel dual mains (gas+gas) unaffected. Off-peak-tariff
dual-fuel mains still defer to the legacy scalar path (separate slice).
Spec-cited unit pin added (AAA). pyright not installed locally — strict type
gate not run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Table 5 reads "Number of extract fans if known; if number is unknown:
[age-band default]" — the default is an UNKNOWN-fallback, NOT a floor. The
cascade applied `max(lodged, table_5_default)`, flooring a genuinely-lodged
count up to the age-band minimum: e.g. an age H-M dwelling lodging 2 extract
fans was billed at the 6-8-room default of 3, over-counting ventilation line
(8) and the heat-loss coefficient. Fixed to `lodged if lodged > 0 else
default` (a lodged 0 is the Elmhurst/RdSAP "unknown" form → default; any
positive count is taken literally).
Surfaced by Khalim's Elmhurst stress worksheet (simulated case 46): this was
its last ventilation residual — our Jan effective ACH 9.14 -> 9.0748 (exact
match to the accredited worksheet), SAP 29 -> 30 = Elmhurst, cost £1496 vs
£1493. Corpus IMPROVED: within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815 (the
max-flooring over-counted ventilation on every cert lodging fans below its
age default). Floor ratcheted 0.71 -> 0.72. pyright not installed locally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the main heating system does not heat every habitable room (heated rooms
< habitable rooms), SAP 10.2 Appendix A.2.2 assumes the unheated rooms are
served by a portable-electric secondary heater, so the Table 11 secondary
fraction (0.10 for a boiler main) must be costed at the electricity tariff —
even when the cert lodges no explicit secondary system.
`_secondary_fraction` previously returned 0 unless a secondary was lodged or
the main was a forced-secondary electric-storage code, dropping the assumed
secondary and billing 100% of space heat to the (cheaper) main fuel — an
over-rate. Added an `unheated_habitable_rooms` trigger plus
`_has_unheated_habitable_rooms(epc)`, which prefers the lodged
`any_unheated_rooms` flag and guards the gov-API `heated_rooms_count == 0`
"not provided" sentinel. The secondary fuel/efficiency cascade already
defaults to portable electric (code 693) when no secondary code is lodged.
Worksheet-validated on simulated case 46 (heated 4 < habitable 7, no lodged
secondary): the assumed 10% electric secondary (2289 kWh, ~£260) lifted our
SAP 39 -> 29.35 vs accredited Elmhurst 30 (cost £1502 vs £1493, within 0.6%).
Corpus UNCHANGED (71.6% / MAE 0.819): all 17 corpus certs with heated <
habitable already lodge an explicit secondary description, so the gov-API
path was already costing it; this only adds the assumed secondary where none
is lodged (Elmhurst / reduced-field path). pyright not installed locally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gov-API lodges secondary fuel as an enum whose value can COLLIDE with a
different same-valued RdSAP 10 Table 32 / SAP 10.2 Table 12 fuel code:
- enum 9 = "dual fuel (mineral and wood)" vs Table code 9 = LPG SC11F
- enum 5 = "anthracite" vs Table code 5 = LPG (bulk)
The main-fuel boundary already canonicalises these (`_GOV_API_COLLISION_
FUELS`), but the SECONDARY-heating cost + CO2/PE paths never did — they took
the bare same-value lookup, so a dual-fuel room heater was priced as LPG
(3.48 vs dual-fuel 3.99 p/kWh) and emitted as LPG (CO2 0.241 vs 0.087),
and an anthracite secondary as bulk LPG (12.19 vs 3.64 p/kWh). The price
under-count over-rates SAP; the CO2 over-count inflates emissions.
Fix: add enum 9 to `_GOV_API_COLLISION_FUELS` (5 and 33 were already there)
and canonicalise the secondary fuel code on both the cost
(`_secondary_fuel_cost_gbp_per_kwh`) and factor (`_secondary_fuel_code`)
paths, mirroring the main-fuel boundary. canonical_fuel_code only touches
{5,9,33}, so genuinely Table-coded secondaries (House coal 11, wood logs 20,
community fuels 30-32) are left unchanged — confirmed by a full-map audit.
Corpus: within-0.5 69.7% -> 70.2% (MAE 0.854 -> 0.845; dual-fuel-secondary
cohort 42.9% -> 49.0%, signed +0.55 -> +0.41) and CO2 MAE 0.12 -> 0.08 t/yr
(bias +0.04 -> 0.00). Ratcheted the corpus floors (within 0.70, MAE 0.85,
CO2 0.09, PE 4.0). A prior session deferred enum 9 ("direction not
understood") while the EPC PE/CO2 lens was confounded by the climate-cascade
bug (fc7c4d2d); on the corrected lens the over-rate direction is clear.
pyright not installed in this codespace (strict gate not run locally).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a heat-pump cert lodges a PCDB Table 362 record, the APM override
set BOTH the space efficiency (N3.6) and the water efficiency (N3.7a)
from the heat pump unconditionally. But the PCDB η_water applies only
when the DHW is heated BY the heat pump (water-heating code "from main":
901/902/914). A separate electric immersion (WHC 903) heats the water at
100% regardless of the space system, so applying the HP's water SCOP
(187.5% × 0.6 in-use = 112.5%) under-counted the immersion's hot-water
fuel.
Gate the η_water override on the DHW-from-main codes; a separate immersion
keeps its own 100% efficiency. Space η_space still always uses the APM
value (the heat pump is the space main).
Worksheet-validated to 1e-4 on simulated case 45 (HP space + WHC-903
immersion): water fuel (62) 1893.57 -> 2130.2639, total cost (255)
619.7433, CO2 692.13 — all matching the P960 exactly; SAP 60.53 -> rounds
to the worksheet's 61. RdSAP-21.0.1 corpus unchanged (no HP+WHC903 certs
in it). Pinned in test_cert_to_inputs (immersion fuel is main-independent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A "No system present: electric heaters assumed" lodging carries SAP
Table 4a code 699 (electric room heaters) but RdSAP main_heating_category
1, NOT 10. `_table_12a_system_for_main` keyed the direct-acting-electric
routing on category==10 only, so the category-1 form fell through to None
and `_space_heating_fuel_cost_gbp_per_kwh` billed space heating 100% at
the off-peak LOW rate — as if direct-acting room heaters charged overnight
like storage.
Per RdSAP 10 §12 Rule 3 (PDF p.62) electric room heaters (691-694, 699)
route to the 10-hour tariff, and SAP 10.2 Table 12a Grid 1 (PDF p.191)
gives the "other direct-acting electric" row a 0.50 high-rate fraction at
10-hour (1.00 at 7-hour). Route those SAP codes — the same set §12 Rule 3
already uses — to OTHER_DIRECT_ACTING_ELECTRIC alongside the category-10
gate.
Found via the PE/CO2-vs-cost split on the worst over-rater in the /tmp
sample: cert 2958 PE +0% / CO2 -1% (energy correct) but SAP +32.2 — a
pure cost-side bug. Space rate 7.50 -> 11.09 p/kWh; cert 2958 +32.2 ->
+14.7. The committed corpus gauge is unchanged (its 3 non-category-10
code-699 certs are all on Single meters -> STANDARD tariff, so this split
never applies to them); the win is on the unbiased /tmp population's
single worst cert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix M1 (PDF p.94): "EPV,ex,m = 0 if the PV system is not
connected to an export-capable meter." The cascade computed the β-split
export stream regardless of `is_dwelling_export_capable`, so a non-export-
capable dwelling was credited the full PV export — in the §10a COST it
credits at the Table 32 import rate (13.19 p/kWh), which dominates the rating.
On 7 Wybourn Terrace S2 5BJ the PE (144 vs lodged 151) and CO2 (27 vs 29)
already matched, yet the phantom export cost credit pulled SAP from ~73 to
92.1 (+19). Zero `epv_exported_monthly_kwh` after the Appendix-G4 diverter
adjustment when not export-capable; the onsite (EPV,dw) consumption and the
diverter HW reduction are unchanged.
Not-export-capable PV cohort (corpus, 4 certs): 7 Wybourn +19.1 -> +6.5,
4 Lime Ave +11.1 -> +0.4, 8 Hatherleigh +7.6 -> -0.2, Flat 5 ~-0.4. Gauge
66.1% -> 66.9%, MAE 1.124 -> 1.039. Floor 0.64 -> 0.65 / ceiling 1.18 -> 1.08.
Worksheet harness 47/47 0 diverge (Summary certs carry export-capable meters).
1 AAA test, pyright net-zero. Found by auditing the worst over-rater without a
worksheet: PE/CO2-match + cost-miss localised it to the PV export credit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RdSAP 10 §10.5 (PDF p.55): "If the actual size is not determined, the size of
a hot-water cylinder is taken as according to Table 28." When a cylinder is
present (has_hot_water_cylinder) but no size descriptor resolves — the gov API
lodges cylinder_size=0, or Exact with no measured volume — `_hot_water_
cylinder_volume_l` returned None, silently dropping BOTH the cylinder's
storage loss and the Table 13 electric-DHW high-rate fraction, under-costing
and over-rating the dwelling. Default such cylinders to the Table 28 baseline
"Normal" 110 L (the value §10.7 also instantiates as the first-row default).
The context-dependent Inaccessible 210/160 values are deliberately NOT applied
here — they are tied to the explicit "Inaccessible" descriptor (code 5) the
assessor lodges, not to an unpopulated size field.
Scope: 7 of 301 cylinder certs in the corpus (2%). Correctness fix — closes a
real spec gap; marginal on the headline (within-0.5 66.1% unchanged, MAE
1.128 -> 1.124) because these certs' residual is dominated by a separate HW-
demand gap, not the cylinder. Worksheet harness 47/47 0 diverge (Summary certs
lodge a real size, so the fallback never fires). 1 AAA test, pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 12a Grid 1 (PDF p.191): electric storage heater SAP code 408
is an "Integrated (storage + direct-acting) system" with a 0.20 space-heating
high-rate fraction on a 7-hour tariff — NOT the 0.00 of "other storage
heaters". `_table_12a_system_for_main` returned None for all storage codes (an
explicit TODO), so code 408 fell back to the 100%-low-rate path and billed
space heating at the bare 7-hour low rate (5.50 p/kWh) — under-costing →
over-rating. Mapped cat-7 storage: 408 -> INTEGRATED_STORAGE_DIRECT (0.20),
others -> OTHER_STORAGE_HEATERS (0.00, unchanged behaviour). The enum +
fraction rows already existed; this only wires the dispatch, so the split
flows self-consistently to both the §10a cost and the Appendix-M1 D_PV
high-rate fraction.
Corpus: sap408 over-raters +14.6/+12.9/+12.7 -> +7.1/+5.1/+3.4 (two crossed
into within-0.5). Gauge 65.9% -> 66.1%, MAE 1.160 -> 1.128. Floor 0.63 -> 0.64
/ MAE ceiling 1.22 -> 1.18. Worksheet harness 47/47 0 diverge. The residual
+3..+7 is the "all other uses" 0.90 high-rate fraction (lighting/pumps/HW
still billed 100%-low on the off-peak legacy path) — the next slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 4c(3) (PDF p.169) "Factor for controls and charging method"
multiplies a heat network's heat requirement by 1.05-1.10 for FLAT-RATE
charging (note d: household pays a fixed amount regardless of heat used, so
no incentive to economise), and by 1.0 for charging linked to use. The
worksheet folds it into the heat-network requirement alongside the Table 12c
distribution loss factor:
(307) space = (98c) x (302) x (305) x (306)
(310) DHW = (64) x (305a) x (306)
Our cascade applied (306) DLF but never (305)/(305a), so every flat-rate
community-heating cert under-counted demand -> over-rated SAP.
Folded the factor into the 1/DLF efficiency override at the space-heating
(206) and DHW (water-inherits-from-main) sites. Space column adds +0.05 for
no thermostatic control (2301/2302); DHW column is 1.05 flat-rate / 1.0
linked-to-use.
Corpus (RdSAP-21.0.1, 1000 certs): community cluster median +0.32 -> -0.19,
within-0.5 38% -> 62% (control 2307 +0.83 -> -0.19; 2306 unchanged at factor
1.0 as spec requires). Overall gauge 65.0% -> 65.9%, MAE 1.174 -> 1.160.
Ratcheted the corpus-test floor 0.62 -> 0.63 / MAE ceiling 1.25 -> 1.22.
Also records (corpus-test comment + scripts/decompose_co2_pe_error.py) the
disproof of the prior "CO2/PE +5% is a factor/scope bug" lead: factors are
spec-exact, scope identical, and the bias is per-cert demand fidelity
(corr(SAP-err, PE-diff) = -0.54), not a one-slice factor fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mixer-shower hot-water demand (worksheet 42a) divided N_shower by the
count of MIXER outlets only. But SAP 10.2 Appendix J step 1a is explicit:
"Establish how many shower outlets are present in the dwelling, Noutlets
(including in the count any instantaneous electric showers)" — and the
electric-shower step (64a) uses that same Noutlets from step 1a. So a
dwelling with both a mixer and an electric shower assigned the FULL N_shower
to the mixer system AND billed the electric shower on top of it, double-
counting shower demand → over-counted main HW → under-rated the dwelling.
Fix: thread the electric-shower count into the mixer demand so the
denominator is the total outlet count (mixer + electric), iterating the
warm-water draw over the mixer outlets only (per step 1e).
shower_types=1,2 cohort: -0.37 median -> +0.28 (crossed zero); API gauge
68.4% -> 69.0% within-0.5. Golden cert 0300-2747 (1 mixer + 1 electric)
re-pinned: PE +0.93 -> -0.10, CO2 +0.25 -> +0.15 (both toward zero,
confirming the double-count). Worksheet harness 47/47, 0 divergers (the
Elmhurst fixtures have no electric showers).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-PCDB MEV fan-electricity path fed the SAP 10.2 Table 4g default SFP
(0.8 W/(l/s)) directly as SFPav. But Table 4g note 3 (PDF p.176) is explicit:
the default SFP values "are to be multiplied by the appropriate in-use factor
for default data from the PCDB" — PCDB Table 329 system_type 10 ("default
data, used when SFP is taken from Table 4g rather than the PCDB"), IUF 2.5
(duct-agnostic per note 2). Table 4h, which previously held these factors, is
retired ("no longer used – data now stored in the PCDB").
Omitting the IUF under-billed the index-less MEV fan electricity by 2.5x
(SFPav 0.8 instead of 0.8 x 2.5 = 2.0), so cost was too low and the cohort
over-rated. This is distinct from the with-index path, which already applies
the tested-product system_type-2 "no scheme" IUF (~1.45) per fan.
Index-less gas-house MEV cohort: +1.37 median -> -0.18 (12% -> 92% within 0.5),
no overshoot — the missing IUF was exactly the over-rate. API gauge 67.7% ->
68.4% within-0.5 (mean|err| 0.992 -> 0.986, signed +0.031 -> +0.006).
Worksheet harness 47/47, 0 divergers (Summary-path MEV certs carry a PCDB
index or are natural, so unaffected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The §5.16 Table 22 thermal-mass-parameter (TMP) "always low-mass" set was
{timber 5, cob 7, park home 8}. But wall_construction code 8 is OVERLOADED by
the same gov-API/calc code-space divergence as the wall-U fix: the Summary
path's "PH" mapping uses 8 for park home, while the gov-EPC API enum uses 8
for SYSTEM BUILD (Summary system build = code 6). So every API system-built
cert was mis-rated as low-mass 100 kJ/m²K instead of masonry 250 (Table 22
lists system build as masonry — PDF p.48, line "System build 250...").
A too-low TMP shortens the §7 time constant tau = Cm/(3.6·H), over-cutting
the temperature reduction so mean internal temperature is UNDER-stated →
space-heating demand under-stated → SAP over-rated. This was the cause of the
uninsulated system-built over-rate cluster (n=9 gas-boiler certs at signed
+2.39 vs cavity +0.43 / solid-brick +0.08 at the same bands — a system-built-
specific anomaly with a spec-correct wall U).
Fix: drop 8 from the always-low set and gate it on `property_type` — code 8 is
the low-mass park-home value only when the dwelling really is a park home,
otherwise it is gov-API system build and keeps masonry 250. Disambiguated by
the same `property_type == "park home"` signal used elsewhere in the cascade.
Worksheet harness UNAFFECTED (47/47, 0 divergers): the Summary path uses code
6 for system build and code 8 only for genuine park homes (which stay
low-mass via the property_type gate). API gauge 65.3% -> 67.1% within-0.5
(mean|err| 1.059 -> 1.024, signed +0.050 -> -0.002). The uninsulated
system-built cluster collapses +2.82 -> +0.28 signed (0/11 -> 7/11 within
0.5). 2 AAA tests (parametrised code-8 system-built -> 250; park-home
property -> 100). pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_CYLINDER_SIZE_CODE_TO_LITRES` held only codes 2/3/4 (Normal/Medium/Large →
110/160/210 L); codes 5 (Inaccessible) and 6 (Exact) fell through to None,
so the Table-13 high-rate fraction AND the cylinder storage loss were skipped
for those certs (20 code-6 certs in the API sample).
Per RdSAP 10 Specification (10-06-2025) §10.5 Table 28 (PDF p.55):
- Code 6 "Exact": use the lodged measured volume. The gov API carries it in
`cylinder_size_measured` (e.g. 150 L) — now plumbed through the 21.0.0/21.0.1
schema → mapper → `SapHeating.cylinder_volume_measured_l`.
- Code 5 "Inaccessible": 210 L if off-peak electric dual immersion, 160 L from
a solid-fuel boiler, otherwise 110 L (n=0 in the current sample, but
spec-complete).
New `_cylinder_volume_l_from_code` centralises Table 28 resolution and replaces
the three raw-dict call sites (`_hot_water_cylinder_volume_l`, the cylinder
storage-loss path, and the PCDB performance check) so all three honour codes
5/6 identically. `_cylinder_inaccessible_volume_l` applies the code-5 context
rule via the existing immersion/off-peak-meter/solid-fuel-boiler detectors.
Worksheet harness UNAFFECTED (47/47, 0 divergers): the Summary path lodges
neither code 5/6 nor a measured volume. API gauge: within-0.5 64.4% -> 65.1%
(mean|err| 1.085 -> 1.075) — the 20 code-6 certs now size their cylinder from
the measured volume. 4 AAA tests (code 6 measured; code 5 solid-fuel/default/
off-peak-dual-immersion). pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A flat accessed via an unheated corridor/stairwell assumes a draught lobby
is present, so SAP 10.2 §2 line (13) = 0.0 rather than the 0.05 no-lobby
infiltration penalty. Per RdSAP 10 Specification (10-06-2025, p.30, "Draught
lobby"): "add infiltration 0.05 if draught lobby is not present, or use 0.0
if present. ... Flat or maisonette: Assume draught lobby if entrance door is
facing corridor (heated or unheated) or stairwell."
Signal: a SHELTERED alternative wall (the RdSAP §5.9 wall-to-unheated-corridor
surface) is the evidence that the flat's entrance faces a corridor — the same
evidence the corridor door (Table 26 U=1.4) rides on. New helper
`_has_sheltered_corridor_wall` factors that check out of `_corridor_door_count`
and gates `_has_draught_lobby`. Houses and exposed-gable flats (no sheltered
alt wall) keep the lodged value / "assume no lobby if cannot be determined"
default, so the §2 cascade is unchanged for every non-corridor dwelling.
The cascade previously added the 0.05 penalty unconditionally, over-counting
(16)/(18)/(21) by 0.05 ACH. On simulated case 34 (cert 001431 storage flat)
this lifted effective air change (25)m from the worksheet's monthly 0.572-0.638
to 0.574-0.668, over-counting space-heating demand (98) by +46.3 kWh/yr
(+0.41%) -> SAP -0.18. Closing it lands (25)m exactly on the worksheet (avg
0.6024) and (98) at 11356.3 vs ws 11357.2:
case 34 SAP 35.1325 -> 35.3130 vs ws 35.3094 (Δ -0.1769 -> +0.0036)
Guard-rails held (both improved): worksheet harness 47/47, 0 divergers (the
other corridor flat, cert 2474, -0.32 -> -0.02); API gauge 60.0% -> 60.1%
within 0.5, mean|err| 1.167 -> 1.163.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 §9.4.9 (PDF p.32) verbatim: "A cylinder thermostat should be
assumed to be present when the domestic hot water is obtained from a heat
network, an immersion heater, a thermal store, a combi boiler or a CPSU."
RdSAP 10 Table 29 (p.56) points the no-access default at this rule.
The storage-loss Table 2b temperature factor previously read only the
lodged `cylinder_thermostat` ("Y") — so an unlodged thermostat always took
the ×1.3 absent-penalty, over-stating storage loss by 30%. New
`_cylinder_thermostat_present` assumes it present when DHW is from a heat
network, WHC 903 (immersion), or a direct-acting electric boiler (SAP code
191 — electric-resistance, immersion-equivalent).
Found via the worksheet-folder harness: cert 2474-3059-4202-4496-3200
(Summary path: WHC 901, main SAP 191, electric, no lodged cylinder stat)
diverged −1.86 from its dr87 worksheet. The worksheet lodges (53)
temperature factor 0.6000 (present) and "add cylinder thermostat (SAP
increase too small)" — already assumed present. Fix lands HW output (64)
2701.99 → 2323.88, EXACT to the worksheet; 2474 −1.86 → −0.87 (residual is
a separate space-demand fabric thread). No other worksheet in the 47-cert
harness moved.
API eval within-0.5 56.9% → 57.6%; mean|err| 1.197 → 1.185; signed
−0.202 → −0.165. Regression green (only pre-existing fails); goldens +
heating corpus unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) verbatim: "For heat networks apply the
formula above with p = 1.0 and h = 3 for all months." The primary
circulation hours for a heat-network main are fixed at h=3 winter and
summer, independent of the cylinder-thermostat / separate-timing
lodgement that selects the h=5/h=11 rows for boiler systems.
`primary_loss_monthly_kwh` / `primary_circuit_hours_per_day_table_3` gain
a `heat_network` flag (→ (3, 3)); `_primary_loss_override` passes
`_is_heat_network_main(main)`. p=1.0 was already pinned via
`_HEAT_NETWORK_PIPEWORK_INSULATION_FRACTION`; only the hours were wrong.
Before, cert 8536 routed through the h=5/3 row because its community
biomass DHW fuel (31) collides with electricity code 31, so
`_separately_timed_dhw` returned False. The Table 3 heat-network rule
overrides that path: 8536 primary loss (59) 335.81 → 273.90, EXACT to
the faithful case-32 worksheet (storage (56) 376.58 also matches 376.94).
API eval within-0.5 57.0% → 56.9% (one offsetting-error cert crosses
out; signed err −0.205 → −0.202). Applied spec-uniformly per the
determinism principle — the heat-network primary hours are unambiguous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A heat-network main with DHW from the network and no lodged cylinder was
billed the Table 3a keep-hot 600 kWh/yr combi loss (cat 6 sat in
`_TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES`). A heat network is not a
combi boiler — SAP 10.2 §4 line 7702 says combi loss is 0 for non-combi
systems.
SAP 10.2 p.24 "Heat networks" (c): when neither a PCDB Heat Interface
Unit nor a lodged cylinder applies, "a measured loss of 1.72 kWh/day
should be used, corrected using Table 2b. This is equivalent to a
cylinder of 110 litres and a factory insulation thickness of 50 mm".
RdSAP 10 Table 29 (p.56): a cylinder thermostat is assumed present when
DHW is from a heat network (Table 2b temperature factor 0.60).
New `_apply_heat_network_hiu_default_store` rebinds the 110 L / 50 mm-
factory store (thermostat present) onto a heat-network DHW cert with no
cylinder and no PCDB index, mirroring `_apply_rdsap_no_water_heating_
system_default`. The injected store routes storage loss (56) ≈ 376.7
kWh/yr (= 1.72 × 0.60 × 365) + primary loss (59) through the existing
machinery and zeroes the combi (61) loss via the has_hot_water_cylinder
gate. Verified against the user's faithful case-32 worksheet: storage
(56) 376.58 vs worksheet 376.94.
Cert 8536 storage 0→376.6, combi 600→0. API eval within-0.5 56.8% →
57.0%; signed err −0.218 → −0.205. Reworked
`test_heat_network_main_with_hw_from_main_dlf_scales_hot_water_kwh` to
assert the DLF scaling directly (fuel ÷ §4 output = 1.41) since the old
two-cert baseline premise (both combi-600) no longer holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GOV.UK API lodges a junk empty leading building part (all fields
None) ahead of the real Main Dwelling on some certs. Four sites in
cert_to_inputs.py read `sap_building_parts[0].construction_age_band` →
got None → silently dropped the dwelling age band. New `_dwelling_age_band`
helper takes the first part that lodges a band (a no-op for normal certs
where [0] is the Main part).
Closes two age-band-keyed defects on the 5 affected certs:
- SAP 10.2 Table 12c (p.193): the heat-network Distribution Loss Factor
defaulted to the K-or-newer 1.50 instead of the dwelling's true band
(cert 8536-0929-6500-0815-7206 is age A → 1.20), inflating distribution
loss by 30%.
- RdSAP 10 §4.1 Table 5 (p.28): the empty band ("") fell through the
age-band branches to the H–M habitable-rooms branch, defaulting in
phantom extract fans. The true band A correctly yields 0 fans
(bands A–E → 0).
Cert 8536: 31.76 → 41.12 vs lodged 39 (was −7.24, now +2.12). API eval
mean|err| 1.197 → 1.192, signed −0.229 → −0.218; headline within-0.5
holds at 56.8% (8536 lands at +2.1, a documented overshoot vs the
faithful case-31 worksheet — separate slice).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The heat-network HW distribution-loss override fired only when the MAIN was
a heat network AND whc inherited from main ({901,902,914}). Water-heating-only
heat networks (SAP 10.2 Table 4a HW codes 950 boilers / 951 CHP / 952 heat
pump) were missed entirely: their Table 4a plant efficiency applied with NO
distribution loss, so the HW fuel was under-counted by the Table 12c DLF
(1.33-1.48x) → under-cost → over-rate.
RdSAP 10 §10 (spec p.36): a water-heating-only heat network is calculated 'for
plant efficiency, distribution loss and pumping energy - see Table 12c'. Added
a whc-gated branch (independent of the main) applying water_eff = plant_eff /
DLF — the per-kWh-generated cost model (q_generated = q_useful x DLF). Fires on
the WHC alone so a HW-only heat network with a non-network main (cert 9093, whc
950 + warm-air main 502) is covered.
The 3 corpus whc=950 certs all improve in |err|: 2153 +2.62->-0.48 (now within
0.5), 7220 +1.27->-0.97, 9093 +6.04->+3.60 (residual is its warm-air main, a
separate cause). within-0.5 56.66->56.79%, within-1.0 71.9->72.2%, mean|err|
down; only those 3 certs change. New AAA test pins the DLF scaling fires on the
WHC independent of the main. Goldens + gate green, pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tier-1 finding of the silent-fallback audit. The fuel-type helpers fed the
SAP 10.2 Table 12/32 cost/CO2/PE lookups via a silent
`API_FUEL_TO_TABLE_12.get(fuel, fuel)` passthrough at 5 sites
(_heat_network_factor_fuel_code, HW CO2/PE, _secondary_fuel_code, PV). A fuel
code in NEITHER the API enum map NOR the Table-12 numbering passed straight
through to the mains-gas default baked into unit_price_p_per_kwh /
co2_factor_kg_per_kwh / primary_energy_factor (table_12.py:233/274/287,
table_32.py:190) — silently mis-pricing a novel/colliding fuel as grid gas.
This is the class that mis-priced cert 8536's community biomass as
electricity (-17 SAP) before a7761ea8.
New _table_12_factor_fuel_code mirrors .get(fuel, fuel) EXACTLY for every
recognised input (union of the CO2/PE/price/monthly table keys +
API_FUEL_TO_TABLE_12 values) and raises UnmappedSapCode only when the
resolved code is recognised by no table — surfacing the gap loudly per the
strict-raise principle (reference_unmapped_sap_code). Verified behaviour-
preserving: 0/909 corpus certs hit the new raise; eval unchanged at 54.9%
within-0.5 / 909 computed / 0 raises.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the MEV fan-electricity thread. The PCDB-index slice closed the
9 MEV certs carrying a Table 322 record; the other 11 (mostly gas houses)
lodge mechanical_ventilation=2 with NO PCDB index, so
`_mev_decentralised_kwh_per_yr_from_cert` returned 0 and billed no fan
running cost — a tight +2.2 SAP over-rate (signed +1.23, median +2.19).
SAP 10.2 §2.6.3 / Table 4g note 1 (PDF p.176) prescribes a DEFAULT
specific fan power of 0.8 W/(l/s) for an MEV system whose fans are not in
the PCDB, used directly as SFPav in the §5 Table 4f (230a) formula
(SFPav × 1.22 × V). Restructure the helper: when no Table 322 record
resolves, fall back to the default for a mechanical-extract system
(`mechanical_ventilation_kind == EXTRACT_OR_PIV_OUTSIDE`); natural /
balanced (MVHR / MV) systems still contribute nothing.
Index-less extract cohort closed +1.23 -> +0.18 signed (each gains
~1.1 SAP of fan electricity). This is a spec-correct fix that improves
the aggregate but is a HEADLINE TRADE-OFF: within-2.0 83.6% -> 84.6%,
within-1.0 70.08% -> 70.19%, mean|err| 1.232 -> 1.224, but within-0.5
55.12% -> 54.90% (-2) — the fan energy is only ~half each cert's
over-rate, so the cohort lands at ~+1.0 (still outside 0.5) while two
borderline certs with offsetting errors cross out. Applied uniformly per
the determinism principle ([[feedback_software_no_special_handling]]):
the unmasked residual (~+1.0 on gas-house MEV) is the next lead.
1 AAA test (default SFP 0.8 × 1.22 × V for index-less MEV, 0 for
natural). Goldens + full calc/epc regression green (000565 MEV uses its
resolvable PCDB record, unaffected); pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the §2 MV-kind slice. Once MEV dwellings stopped
under-stating their ventilation HEAT loss, a +0.9 SAP over-rate residual
remained — the MEV FAN ELECTRICITY (§5 Table 4f line (230a),
`SFPav × 1.22 × V`, PCDB Tables 322 decentralised-MEV + 329 in-use
factors). `_mev_decentralised_kwh_per_yr_from_cert` already composes it,
but reads `epc.mechanical_ventilation_index_number` +
`epc.mechanical_vent_duct_type`, and the API builder
(`from_rdsap_schema_21_0_1`) never set either — so `pcdf_id is None`
short-circuited the fan energy to 0 on every API cert (the Summary/
Elmhurst path set them, so cert 000565 already billed it).
Wire both schema fields through the 21.0.1 API construction (the corpus
schema). Eval: the 9 MEV certs carrying a PCDB index closed +0.90 ->
+0.13 signed (fan electricity now billed); headline within-0.5 55.01% ->
55.12%, mean|err| 1.233 -> 1.232, 909 computed / 0 raises. Only those 9
certs move (clean diff). The 11 index-less MEV certs still sit at +1.36 —
they need the SAP Table 4h DEFAULT specific fan power (no PCDB record), a
separate slice.
New end-to-end test + fixture (cert 1300, Titon-class dMEV index 500777,
Flexible duct): from_api_response preserves the index + duct type and
(230a) resolves to a positive fan-energy contribution. Goldens + full
calc/epc regression green; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The profiler flagged `mechanical_ventilation=2` as a clean systematic
over-rate: 20 certs, signed +1.90 SAP, only 5% within 0.5 (every one
positive). Root cause: the API path (`from_api_response`) dropped the
doc-level `mechanical_ventilation` field, so `sap_ventilation.
mechanical_ventilation_kind` was always None and the §2 cascade
defaulted to NATURAL — under-stating the ventilation air-change rate
(and hence heat loss) for every mechanical system. (Only the Elmhurst/
Summary path mapped it, via `_ELMHURST_MV_TYPE_TO_KIND`.)
RdSAP-Schema-21 `mechanical_ventilation` enum (epc_codes.csv) →
MechanicalVentilationKind picking the SAP 10.2 §2 (24a..d) effective-ach
formula:
0 natural -> NATURAL (24d)
1 MV (no heat recovery) -> MV (24b)
2 mechanical extract, dc (MEV) -> EXTRACT_OR_PIV_OUTSIDE (24c)
3 mechanical extract, c (MEV) -> EXTRACT_OR_PIV_OUTSIDE (24c)
5 positive input from loft -> NATURAL (loft-sourced PIV adds no
system air change per RdSAP 10 §2.6)
6 positive input from outside -> EXTRACT_OR_PIV_OUTSIDE (24c)
Code 4 (MVHR, 24a) is DEFERRED — its formula needs the lodged
heat-recovery efficiency (PCDB Table 326) the API→cascade path doesn't
yet plumb; mapping it to MVHR with a null efficiency would mis-model it
as MV, so it stays NATURAL (3 scattered certs, accurate at the median).
Unmapped integers raise `UnmappedApiCode` (mirror of `_api_sheltered_
sides` / `_api_type_1_gable_kind`).
Eval: the extract cohort (mech_vent 2/3/6) moved +1.90 -> +0.9 median
(within-0.5 5% -> 35%); 20 improved / 3 regressed (offsetting). Headline
within-0.5 54.24% -> 55.01%, within-1.0 69.64% -> 70.08%, mean|err|
1.248 -> 1.233, 909 computed / 0 raises. The +0.9 residual on MEV is the
fan electricity (§2.6.4 SFP, PCDB Table 322) — a separate follow-up.
2 AAA tests; goldens + full calc/epc/parser regression green; pyright
net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gov-API `main_fuel_type`/`water_heating_fuel` enum (epc_codes.csv)
codes 30="waste combustion (community)", 31="biomass (community)",
32="biogas (community)" collide in VALUE with the Table-32 electricity
codes 30 (standard rate), 31 (7-hour low) and 32 (7-hour high). All three
sit in `_ELECTRIC_FUEL_CODES`, so `is_electric_fuel_code` flagged a
community-scheme main as electric and `_is_electric_main` routed its cost
through the off-peak electricity branch — BYPASSING the heat-network rate
in `_heat_network_factor_fuel_code`. Cert 8536 (biomass community, SAP
code 301) was billing at 5.5 p/kWh grid electricity instead of the 4.24
p/kWh heat-network rate → -17.2 SAP.
Per RdSAP 10 §C / SAP 10.2 Table 12 (PDF p.191) the community
waste/biomass/biogas rows are codes 42/43/44 (the same rows the
backwards-compat enum codes 11/12/13 already map to). Add 30->42, 31->43,
32->44 to both API fuel-translation tables.
The remap CANNOT be global (`canonical_fuel_code`): the cascade uses the
bare Table-32 code 30 internally as `_STANDARD_ELECTRICITY_FUEL_CODE`
(the RdSAP no-water-heating immersion default writes
`water_heating_fuel=30`), so a blanket remap mis-prices genuine grid
electricity as community waste (cert 2211 regressed +16 SAP in a
prototype). Instead `_heat_network_community_fuel_code` translates only
when `_is_heat_network_main` is true, at the `_main_fuel_code` /
`_water_heating_fuel_code` fuel-TYPE boundary, where the community
meaning is unambiguous.
Per the strict-raise principle ([[reference-unmapped-sap-code]]), a
heat-network main lodging a colliding community fuel the table doesn't
cover raises `UnmappedSapCode` rather than silently falling through to
the same-numbered electricity code.
Eval (API SAP vs lodged): cert 8536 -17.25 -> -6.51, cert 5036 -6.29 ->
+1.36; mean|err| 1.329 -> 1.312, within-1.0 67.88% -> 67.99%,
within-2.0 81.74% -> 81.85%, within-0.5 held at 53.14%, 909 computed /
0 raises. No golden / calculator regressions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A coal main (gov-API main_fuel_type=33) was priced at the electricity
10-hour low rate (7.5 p) and anthracite (5) at the bulk-LPG rate
(12.19 p), because the shared price/CO2/PE lookups check Table-32/12-code
membership BEFORE translating the API enum — and codes 5/33 collide with
a different-fuel Table code. This drove the cohort's single worst cert
(2100 anthracite, -61 SAP). `is_electric_fuel_code(33)` also wrongly
classified the coal main as electric.
The gov-API fuel enum (confirmed by description-vs-code audit on
main_heating[].description): 5=anthracite, 33=coal, 9=dual-fuel,
20/25/31=community. The collision can't be resolved inside the shared
table functions — code 33 is ALSO the electricity-10h TARIFF code used by
the dual-rate CO2/PE split (golden 000565), so normalising there breaks
electricity certs. Instead `canonical_fuel_code` normalises the colliding
SOLID-fuel enums (5->15 anthracite, 33->11 house coal) at the fuel-TYPE
boundary in `_main_fuel_code` / `_water_heating_fuel_code`, where the code
is known to be a fuel type (never a tariff code).
Scoped to anthracite (5) + coal (33) — the unambiguous large mispricings.
Dual-fuel (9, 0.45 p delta) and community (20/25/31, heat-network path)
are deferred (noted in `_GOV_API_COLLISION_FUELS`).
API SAP eval: mean|err| 1.424 -> 1.329 (the -61 anthracite outlier 2100
-> -11, residual now fabric); within-0.5 53.1% (flat); 909 computed, 0
raises. Golden + Elmhurst regression green (the shared table functions
are unchanged, so the electricity-tariff CO2/PE path is untouched).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Electric immersion water heating (WHC 903) on an off-peak tariff billed
100% at the low rate, under-costing the dwelling and over-rating it
(median +0.98 SAP across the off-peak WHC-903 API cohort, n=57).
SAP 10.2 Table 12a "Immersion water heater" row (PDF p.191) routes the
water-heating column to Table 13 (PDF p.197): the high-rate fraction is
a function of cylinder volume V, assumed occupancy N (Appendix J Table
1b) and single-/dual-immersion. The remainder bills at the low rate.
Table 13 Note 2 supplies exact equations equivalent to the rounded grid;
`electric_dhw_high_rate_fraction` evaluates them (validated against the
published 110 L grid cells). Per Note 1 the 10-hour equations cover any
tariff with >=10 hours/day low-rate (so 18-/24-hour use that column).
Immersion code mapping CONFIRMED 1=dual, 2=single via RdSAP 10 §10.5
(PDF p.54 — an immersion is "assumed dual" on a dual/off-peak meter)
cross-checked against the API cohort (code 1 sits 3.6:1 on dual meters;
code 2 on single meters). This INVERTS an earlier handover's unverified
"1=single, 2=dual" note — the dual code carries Table 13's small
fraction, matching the cohort over-rating direction; the single mapping
overshot in a prototype.
API SAP eval: 47.6% -> 48.6% within 0.5; <1.0 62.6% -> 63.8%;
mean|err| 1.586 -> 1.561; 909 computed, 0 raises.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_secondary_fraction` keyed "has a secondary" off the integer
`secondary_heating_type` code. The gov-API path surfaces the secondary as a
DESCRIPTION instead (`secondary_heating.description`, e.g. "Portable electric
heaters (assumed)") and leaves the integer code None. So a gas/oil boiler
main (not in the §A.2.2 forced-secondary set) with an assumed portable-electric
secondary dropped the secondary entirely (sec_kWh=0), under-costing the
dwelling and over-rating its SAP.
Per RdSAP §A.2.2 / SAP 10.2 Table 11, a lodged secondary is costed at its
Table 11 fraction (cat-2 boiler = 0.10, billed at standard-rate electricity per
the §A.2.2 assumed portable-electric default). New
`_has_lodged_secondary_description` treats a real `secondary_heating.description`
as a lodged secondary; passed to `_secondary_fraction` at both call sites. The
description is authoritative — same lesson as floor_heat_loss / roof codes.
(Electric-storage mains were unaffected: they force the secondary already.)
Also adds the Table 11 fraction for main_heating_category=8 (electric underfloor,
"Integrated storage/direct-acting electric systems" = 0.10) — the strict-raise
surfaced this latent gap once cat-8 mains were routed through the lookup.
Eval: 909 computed, 0 raises, 46.9% -> 47.6% within 0.5 (+13 certs: 420 -> 433),
mean|err| 1.633 -> 1.586. 13 improved / 1 regressed (2610, a cat-10 room-heater
cert with an independent over-count). Bucket "Portable electric heaters"
median +2.73 -> ~0 on the gas/cat-2 subset (cat-7 storage was already correct).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES` held only {2101, 2102} — it was
keyed off the Table 4e "+0.6 °C" annotation rather than the actual interlock
criterion. SAP 10.2 §9.4.11 (PDF p.66): "A boiler system with no room
thermostat (or a device equivalent in this context, such as a flow switch or
boiler energy manager) ... must be considered as having no interlock", and
"TRVs alone ... do not perform the boiler interlock function". A fixed bypass
likewise provides no interlock (it keeps water circulating when TRVs close).
So control 2107 ("Programmer, TRVs and bypass") and 2111 ("TRVs and bypass")
lack interlock and must take the Table 4c(2) −5pp Space+DHW seasonal-efficiency
adjustment and the Table 4f footnote a) ×1.3 circulation-pump uplift — both of
which they previously missed. (2108 flow switch / 2109 boiler energy manager
carry interlock-equivalent devices → excluded; 2103-2106/2113 have a room
thermostat.) All affected certs are cat-2 gas boilers, where §9.4.11 applies.
Eval: 909 computed, 45.3% → 46.9% within 0.5 (+14 certs: 412 → 426), mean|err|
1.659 → 1.633. Bucket means corrected: control 2107 +1.50 → +0.32 (n=38),
2111 +1.48 → +0.16 (n=4). 32 improved / 10 regressed (all small; the six that
crossed out of ±0.5 were coincidentally-accurate offsetting-error certs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The API `floor_heat_loss` code is authoritative — confirmed by joining each
single-BP cert's code to its independent `floors[].description` (which the
gov register publishes alongside the code):
code 1 ↔ "To external air" (exposed, 9/9)
code 2 ↔ "To unheated space" (semi-exposed, 6/6)
code 3 ↔ "(other premises below)" (partially htd, 9/9)
code 6 ↔ "(another dwelling below)" (party, 176/176)
code 7 ↔ "Solid"/"Suspended …" (ground, all)
Code 3 was mis-mapped to "To unheated space" (semi-exposed) and, on
mid-/top-floor flats, had its floor area zeroed entirely by the
dwelling-level exposure heuristic. RdSAP 10 §3.12 (PDF p.25) classes a
flat's floor over non-domestic "other premises … heated, but at different
times" as "above a partially heated space" → the §5.14 (PDF p.47) constant
U=0.7 W/m²K — distinct from semi-exposed (Table 20) and party (no loss).
Fix: the mapper sets `is_above_partially_heated_space` on the floor=0
dimension for code 3 (string → "(other premises below)" for fidelity), and
the heat-transmission step lets that per-BP lodgement override the flat
suppression upward (mirroring the existing exposed / "another dwelling
below" overrides). The cascade already routes is_above_partial → U=0.7.
Re-pins golden cert 7536-3827: its Ext2 (bp3) lodges code 3, but the cert's
lossy `floors[]` summary dropped that description, so a prior agent guessed
"code 3 = ground" (U=1.12) and concluded the residual was an irreducible
"register-rounding" artifact. It was this bug: Ext2 floor U 1.12 → 0.70,
PE -6.1952 → -5.6414, CO2 -0.1639 → -0.1492 (both toward 0), SAP unchanged.
Eval: 909 computed, 45.1% → 45.3% within 0.5, mean|err| 1.702 → 1.659,
<1.0 59.5% → 60.2%. 13 code-3 certs improve (0380 +3.71 → -0.63, 0350
+7.82 → +0.83, 2610 +7.47 → -1.29); the few that overshoot were already
failing and carry independent fabric bugs (9763's walls = 8 W/K for 60 m²).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main_heating_category=9 (warm-air systems, NOT heat pump) had no entry
in _SECONDARY_HEATING_FRACTION_BY_CATEGORY, so a warm-air main with a
lodged secondary raised UnmappedSapCode in
_secondary_heating_fraction_for_category — the last calc_raise in the
API sample (cert 0380-2197-2590-2996-2715: warm air mains gas code 506 +
electric room-heater secondary).
SAP 10.2 Table 11 (p.188): a gas/oil warm-air unit falls under "All gas,
liquid and solid fuel systems" (0.10), and electric warm air under
"Other electric systems" (also 0.10) — so 0.10 regardless of fuel. The
warm-air efficiency (Table 4a code→eff: 506→0.70) and Table 4f fan
energy were already wired; this was the only missing dispatch entry.
0380 now computes: SAP 78.1 vs lodged 77 (+1.1; the residual is per-cert
fabric/PV, not the warm-air dispatch — a faithful 0380 worksheet isn't
available, sim case 28 diverges at SAP 57 / code 502 / condensing unit).
Eval: zero raises remain, computed 908→909; mean|err| 1.703→1.702.
Regression green (2448 pass incl. golden 6035 + cohort); pyright
net-zero (44=44).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>