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 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>
A door opening to an unheated corridor/stairwell takes U=1.4 W/m²K (RdSAP 10
Table 26, p.51 — any age band) instead of the 3.0 external-door default, and
its area deducts from the SHELTERED wall, not the main wall (RdSAP §3.7,
p.18: "the door of a flat/maisonette to an unheated stairwell or corridor
... is deducted from the sheltered wall area"). The cascade previously
billed every door at the external U on the main wall.
Signal: a SHELTERED alternative wall (`is_sheltered`, the RdSAP §5.9
wall-to-unheated-corridor surface, already modelled) is the evidence that
the dwelling is accessed via an unheated corridor, so one lodged door opens
to it. `_corridor_door_count` returns 1 when a sheltered alt wall is present
and >=1 door is lodged, else 0 — so the door channel is unchanged for every
non-corridor dwelling (houses, exposed-gable flats). `heat_transmission_
from_cert` gains a `corridor_door_count` param (default 0): it splits the
door area into external (main wall, age-default U) + corridor (sheltered
alt wall, U=1.4), threading the corridor door's area into that wall's
opening deduction and billing it at 1.4.
Validated on TWO faithful worksheets: simulated case 34 (cert 001431
storage flat — doors 8.14 exact, fabric 207.47 ≈ ws 207.48) and the
long-standing worksheet-harness diverger cert 2474 (−0.87 → −0.32, the
"space-demand thread" was the dropped corridor door). The worksheet harness
is now 47/47 with ZERO divergers.
API SAP gauge: 57.6% → 60.0% within 0.5; mean|err| 1.185 → 1.167; signed
−0.165 → −0.115 — ~22 sheltered-corridor flats were a systematic gap.
Regression gate green (3 pre-existing fails unrelated); pyright net-zero.
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>
`_separately_timed_dhw` returned True for any boiler+cylinder+from-main
cert, applying the SAP 10.2 Table 2b note b) ×0.9 temperature-factor
reduction unconditionally. For the lpg-boiler "before" worksheet (pre-
1998 LPG boiler SAP code 115 + 210 L cylinder, NO cylinder thermostat,
control 2113 "Room thermostat and TRVs" — no programmer) this dropped
the (53) temperature factor to 0.702 (= 0.60 × 1.3 × 0.9) where the
worksheet lodges 0.78 (= 0.60 × 1.3), under-counting cylinder storage
loss (55) by ~119 kWh/yr and over-rating SAP by ~0.25.
RdSAP 10 §10.5 (PDF p.57) "Hot water separately timed":
No programmer, pre-1998 boiler → No
Programmer, pre-1998 boiler → Yes
Post-1998 boiler → Yes
DHW is therefore NOT separately timed only when a pre-1998 boiler is
paired with a no-programmer control. Add the two SAP 10.2 Table 4c(2) /
Table 4b lookups (controls without a programmer = {2101, 2103, 2111,
2113}; pre-1998 gas/LPG boilers 110-119 + oil 124/125/128) and return
False for that combination; every other boiler+cylinder cert keeps the
separately-timed default, so the change is confined to old low-control
stock and the heating corpus + goldens are unchanged.
Effect: the full chain (Summary PDF → extractor → mapper → cert_to_inputs
→ calculator) now reproduces the lpg-boiler worksheet's §11a unrounded
SAP -6.6499 at abs < 1e-4 (was -6.4013). Full regression suite green bar
the 3 pre-existing unrelated fails.
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>
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>
Pulls in 42 commits of calculator/mapper accuracy fixes from the per-cert
mapper-validation and floor/roof/heating fronts.
Conflict resolutions:
- mapper `_is_elmhurst_roof_window`: main dropped the branch's "wall location →
vertical" guard (it broke cert 000516's rooflight), but that re-broke cert
001431's two External-wall U>3.0 windows (which must stay vertical). The two
certs lodge a BYTE-IDENTICAL §11 row, so neither location nor U separates
them — the real discriminator is the room-in-roof context. Replaced the
unconditional U>3.0 backstop with one gated on the BP having a room-in-roof
(`_elmhurst_bp_has_room_in_roof`): 000516's Main BP has a "Room in roof type
1" (→ rooflight), 001431's does not (→ vertical). Validated against BOTH —
full Elmhurst worksheet suite 1038 pass + the 001431 window-extraction pin.
- property_postgres_repository: kept main's `ids_by_uprn` method + the branch's
`_restrictions_of` helper.
- sap_fuel.py: the branch relocated it to domain/billing/ (already carrying
main's to_table_32_code normalization), so kept the old path deleted.
Fallout from main's fabric fixes (validated by the boiler-3 real-cert pin which
still reproduces at delta 0):
- re-pinned the boiler-1 + boiler-instant-hw ASHP snapshot scores;
- main's §14.2 gas-boiler main-fuel derivation resolved the BGB/102 baseline
gap, so `test_gas_boiler_instant_hw_before_baselines` is now a passing test
(was an xfail tripwire).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3 of the Solar PV Recommendation Generator (ADR-0026). Per roof segment,
back-solve the effective overshading factor ZPV from Google's expected
generation against SAP's own unshaded annual output:
ZPV = (yearlyEnergyDcKwh × 0.955) / (0.8 × kWp × S)
reusing the calculator's Appendix U3.3 annual solar radiation S via a new
public seam `pv_annual_solar_radiation_kwh_per_m2`. Dividing Google's
generation by SAP's S cancels orientation/tilt and isolates shading; the
result snaps to the RdSAP bucket {1:1.0, 2:0.8, 3:0.5, 4:0.35} via the
ADR-0026 midpoint cutpoints (≥0.90→1, 0.65–0.90→2, 0.425–0.65→3, <0.425→4;
ZPV>1→1). The real London example's planes all back-solve to ZPV>1 → code 1.
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>
SAP 10.2 Table 3 (PDF p.160) names "Direct-acting electric boiler"
verbatim in the primary-loss zero list (alongside electric immersion,
combi, CPSU, integral-vessel heat pump). RdSAP 10 §12 (p.62) classifies
SAP code 191 as the direct-acting electric boiler. Its cylinder is
immersion-heated with no primary pipework, so no primary circuit loss
applies — but `_primary_loss_applies` had no 191 branch, so a 191 main
(main_heating_category 2, "Boiler and radiators, electric") fell through
to the cat-{1,2} boiler branch and accrued ~1177 kWh/yr of phantom
primary loss on the electric-flat segment.
Validated against the cert-2474 worksheet: §4 (59) primary loss = 0,
(64) HW output 1760 (cylinder) + (64a) shower 581. Cert 2474 HW kWh
3585 → 2408; SAP 64.66 → 70.35 (the residual to the lodged 78 is an
Unknown-meter data-fidelity artifact — the register recorded meter_type=3
"Unknown" but the lodged rating used an 18-hour off-peak meter, per RdSAP
§12 / the example worksheets).
Eval mean|err| 1.720 → 1.708 (headline 45.0%, flat ±1 cert — the
electric-flat segment is dominated by the meter data-fidelity artifact).
Regression green (2448 pass incl. golden 6035 + ASHP cohort 1e-4);
pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When DHW is heated by the main heat pump (WHC 901/902/914 = "from main
system") and the main carries a PCDB Table 362 record,
`_hot_water_fuel_cost_gbp_per_kwh` billed the electric HW at 100% off-peak
low rate (its long-standing TODO). SAP 10.2 Table 12a Grid 1 WH column
(PDF p.191) puts HP-DHW on the ASHP/GSHP-from-database row: 0.70
high-rate fraction at 7-hour and 10-hour → 0.70×14.68 + 0.30×7.50 =
12.526 p/kWh (10-hour), not 7.50 p. The low-rate collapse over-credited
the cat-4 HP-DHW cluster.
Fix: pass the cert WHC into the helper and, for HP-DHW (WHC ∈ {901,902,
914} + PCDB-HP main), bill at the ASHP_APP_N WH blended rate. Electric
IMMERSION (WHC 903) is a different Table 12a row (off-peak immersion 0.17
/ Table 13) and stays on the 100%-low-rate fallback until that slice
lands.
cat-4 cluster (20 certs): mean|err| 2.43→2.11, mean signed +0.06→-0.52
(now per-cert scatter, no systematic bias); cert 9472 +6.4→+3.2, 2789
+6.8→+4.0, 4135 +2.7→within 0.5. Headline mean|err| 1.727→1.720.
Regression green (2447 pass incl. golden 6035 + ASHP cohort at 1e-4);
pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A heat pump that resolves via its PCDB Table 362 index alone (API path,
data_source=1, no Table-4a SAP code) had sap_main_heating_code=None, so
`_table_12a_system_for_main` fell through the 211-227/521-524 code-range
gate to None → the "100% off-peak low-rate" fallback. On a Dual meter
(RdSAP §12 Rule 3 routes heat pumps to the 10-hour tariff) this billed
space heating at 7.50 p/kWh instead of the SAP 10.2 Table 12a Grid 1
(PDF p.191) ASHP/GSHP-from-database row: 0.80 high-rate fraction →
0.80×14.68 + 0.20×7.50 = 13.244 p/kWh. The collapse over-credited the
whole cat-4 heat-pump cluster.
Fix: route any main with a PCDB heat-pump record to ASHP_APP_N regardless
of SAP code (a Table 362 record IS an Appendix-N heat pump by
definition). ASHP_APP_N and GSHP_APP_N share the 0.80 SH fraction at
7h/10h, so ASHP_APP_N is the canonical Appendix-N row for the SH split.
cat-4 cluster (20 certs): within-0.5 45%→50%, mean signed +1.43→+0.06,
mean|err| 3.81→2.43; cert 9472 +15.0→+6.4, 2789 +13.4→+6.8. Headline
45.0%→45.1%, mean|err| 1.757→1.727. Regression green (only the
pre-existing test_total_floor_area fails); pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Electric storage heaters (and CPSU) charge overnight and cannot run
economically on a single rate, so their presence is physical evidence the
dwelling is on an off-peak tariff. RdSAP 10 §12 (PDF p.62) applied Rules
1-4 only for a Dual meter; an "Unknown" (code 3) meter returned STANDARD
without consulting the heating type, so a cat-7 storage main billed its
overnight charge at the standard 13.19 p/kWh instead of the 7-hour low
rate (5.50 p/kWh) — ~2.4x too high → large under-rate.
Two coupled fixes:
- `rdsap_tariff_for_cert`: for an Unknown meter, infer the off-peak tariff
from a Rule-1 CPSU (→10-hour) or Rule-2 storage (→7-hour) main; keep
STANDARD otherwise. Direct-acting/room heaters/heat pumps (Rule 3) are
NOT off-peak evidence (run on demand, exist on single-rate meters) so
they stay STANDARD — billing them 100% at the low rate over-credits.
- `_fuel_cost` now resolves its tariff via the §12-aware `_rdsap_tariff`
(not the raw `tariff_from_meter_type`), so the off-peak branch fires for
these storage certs and the legacy scalar fields bill the low rate.
Mirrors `_is_off_peak_meter`'s existing Unknown+electric heuristic (which
already routes HW/secondary off-peak), closing the main-space-heating gap.
Meter-3 electric cluster: mean |err| 11.18 → 6.52, within-1.0 3 → 5 (cert
7336 -26.1 → -0.16, 0380 -19.9 → +1.0). Eval headline 44.9% → 45.0%, mean
|err| 1.82 → 1.76, mean signed -0.08 → +0.02. A few storage certs overshoot
(other residuals the standard rate was masking).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Locks sim case 20 (storage heaters + Detailed RR + loose-jacket cylinder)
as a golden vector: _elmhurst_worksheet_001431_case20.build_epc() routes the
Summary PDF through extractor → mapper → calculator, registered in
test_e2e_elmhurst_sap_score with all 11 SapResult headline pins at 1e-4.
10 pinned exact off slices 1-2 (window extractor, RR stud walls); this slice
closes the last one, co2_kg_per_yr (was 3797.62 vs (272) 3815.4060).
Root cause: on a dual-rate (E7) meter the CO2 path ignored the tariff's
high/low Table-12 electricity codes that the cost path already uses:
- Secondary (direct-acting portable heaters, on-peak) keyed the monthly
Table 12d cascade on standard code 30 (0.15405) instead of the E7 HIGH
code 32 → (263) 0.1616. SAP 10.2 Table 12a Grid 1 direct-acting electric
is 100% high-rate; mirrors the cost side billing it at 15.29 p/kWh.
- Main storage heaters fell through `_table_12a_system_for_main`=None to
the FLAT annual factor (0.136) rather than the dual-rate LOW code: per
the Table 12a design intent ("storage … 100% low rate") they charge
off-peak → E7 LOW code 31 → (261) 0.1357.
case-20 co2 now EXACT. 2433 calculator + 112 golden + documents_parser tests
pass — no dual-meter/storage cohort regression; pyright strict net-zero (32=32).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RdSAP 10 §2 (Ventilation, "Walls" row): "Structural infiltration: 0.25
for steel or timber frame or 0.35 for masonry construction ... System
build: treated as masonry." `_is_timber_or_steel_frame` wrongly included
wall_construction code 6 (system build) alongside code 5 (timber frame),
handing system-build dwellings the 0.25 structural ACH instead of 0.35.
On the cat-10 room-heater fixture (ref 001431, walls SY System Build →
code 6) this under-stated the infiltration rate (18) by exactly 0.10
(0.45 vs worksheet 0.55), dropping the effective air change (25), the
ventilation heat loss (38)m = 0.33 × (25)m × (5), and the heat-transfer
coefficient (39) — so space-heating demand (98) came out 404 kWh low
((211) 11158.6 vs worksheet 11563.2). Restrict the 0.25 branch to code 5
only; code 6 (and everything else) is masonry at 0.35.
Pins the rating-block (38)m ventilation heat loss mean = 83.3613 W/K at
abs 1e-4 and asserts the classifier treats the system-build wall as
masonry. §4 suite green (2415 passed, 1 skipped); no existing fixture
relied on system-build → 0.25.
Residual after this slice: SAP +0.03 / cost -£0.95 — a small fabric (33)
gap (-0.15 W/K) plus lighting (232) +1.0 kWh remain as separate causes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Water heating SAP code 909 (electric instantaneous) and 907 (single-point
gas) heat water at the point of use, serving one outlet with no
distribution pipework. Per SAP 10.2 §4 (p.23, l.1416): "'Single-point'
heaters, which are located at the point of use and serve only one outlet,
do not have distribution losses either." So worksheet (46)m = 0 and the
heat-required line collapses to SAP 10.2 worksheet l.7704
(62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m
= 0.85 × (45)m (all loss terms zero for a no-cylinder system).
`distribution_loss_monthly_kwh` already supported the
`is_instantaneous_at_point_of_use` flag (and its docstring already named
codes 907/909), but `water_heating_from_cert` hard-coded it to False, so
the cascade applied (46)m = 0.15 × (45)m to single-point heaters. That
0.15 distribution loss exactly cancelled the 0.85 reduction, leaving
(62)m = (45)m. On the cat-10 room-heater fixture (ref 001431, code 909)
that over-stated the water fuel (219) as 2082.6250 instead of the
worksheet's 1770.2313, and inflated the (65)m heat gains (692.47 vs
worksheet 442.55) which in turn suppressed space-heating demand.
Thread the cert's existing instantaneous flag (`_INSTANTANEOUS_WATER_CODES`
= {907, 909}) through `_water_heating_worksheet_and_gains` into both the
demand-pass and final `water_heating_from_cert` calls.
Pins (219) water fuel = 1770.2313 at abs 1e-4 via the extractor → mapper →
rating cascade. §4 suite green (2414 passed, 1 skipped); no existing
fixture exercised the 907/909 path. The residual space-heating fuel gap
((211) 11158.59 vs worksheet 11563.17) this exposes is a separate cause —
next slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.
Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (2f039aeb):
main_heating_fuel_code RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
main_2_heating_fuel_code code column (the lodged fuel code, e.g.
secondary_heating_fuel_code mains gas 26). None when the corresponding
hot_water_fuel_code system is absent / fuel not resolvable.
pv_exported_kwh_per_yr SAP 10.2 Appendix M1 §3-4 annual export kWh
(0.0 when no PV).
cert_to_inputs.py populates the four fuel codes from the existing
resolvers the cost/CO2 cascade already uses — `_main_fuel_code`,
`_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented);
Main 2 is the second `main_heating_details` entry, guarded for length.
There is a single CalculatorInputs construction site (cert_to_demand_
inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already
existed on CalculatorInputs; SapResult collapses its Optional to 0.0.
HARD CONSTRAINT honoured — output-only, zero rating drift. These fields
do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_*
/ sap_score / any monthly value. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests),
4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in
domain/; no new errors in the touched test files).
New tests: a synthetic threading test (four fuel codes + PV export pass
unchanged through calculate_sap_from_inputs; None PV collapses to 0.0)
and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26,
no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult
fixtures updated for the new SapResult fields (defaults cover Inputs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:
SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss
(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
- (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
- export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
line 5501); the onsite dwelling portion EPV,m × βm is unchanged.
Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.
Three layers:
- extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
- `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
set in both the Elmhurst and API mapper paths;
- `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
`cert_to_inputs` recomputes (219) and the PV export.
On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): "apply the normal
import electricity price to PV energy used within the dwelling and the
'electricity sold to grid, PV' price from Table 12 to the energy
exported. In the case of the former, use a weighted average of high and
low rates (Table 12a)."
`_pv_dwelling_import_price_gbp_per_kwh` was returning the bare off-peak
LOW rate (5.50 p/kWh on a 7-hour tariff) for the PV-used-in-dwelling
credit. PV self-consumption displaces the dwelling's "all other uses"
electricity (lighting / appliances / pumps), which on an off-peak tariff
bills at the Table 12a Grid 2 ALL_OTHER_USES weighted blend, not the low
rate. On simulated case 19 the worksheet (252)/(269) credits
PV-used-in-dwelling at 14.3110 p/kWh = 0.90 × 15.29 + 0.10 × 5.50; we
credited it at 5.50, under-crediting onsite PV by ~£0.088/kWh on every
off-peak PV cert.
Fix delegates to `_other_fuel_cost_gbp_per_kwh(tariff, prices)` (the same
ALL_OTHER_USES rate): STANDARD tariff still returns the flat Table 32
code 30 13.19 p/kWh (golden cohort unchanged — all 2412 tests pass);
off-peak returns the weighted high/low blend. Call sites now pass the
resolved `_rdsap_tariff(epc)`. The now-unused
`_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic` (its only caller)
is removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix M1 §3a (PDF p.93, lines 5470-5476): "E_space,m =
(211)m + (213)m + (215)m, where (211), (213) and/or (215) should be
included only where the fuel code applied to them in Section 10a of the
SAP worksheet is 30, 32, 34, 35 or 38 (i.e. electricity not at the
low-rate)."
The PV-eligible demand D_PV,m was adding 100% of the main space-heating
fuel (211)m whenever the main's Table-12 code was in the eligible set
(30, …), ignoring the off-peak high/low split that §10a already bills
via `_space_heating_fuel_cost_gbp_per_kwh`. Electric STORAGE heaters on
a 7-hour tariff are charged wholly at the low rate (Table 12a Grid 1 SH
fraction 0.00; worksheet (240) high-rate cost = 0), so none of (211)
may enter D_PV — but the cascade counted it all, inflating R_PV,m =
E_PV,m / D_PV,m and therefore the β onsite-PV split in the heating
months.
Fix mirrors the cost-side rate split: `_main_space_heating_high_rate_
fraction(main, tariff)` returns the high-rate portion (1.0 for
non-electric / STANDARD, the published Grid 1 SH fraction otherwise,
0.0 when the Grid 1 SH row is unwired → 100% low rate), and
`_pv_eligible_demand_monthly_kwh` scales the (211)m contribution by it.
Backward-compatible: STANDARD-tariff electric mains and the gas-main /
electric-secondary PV cohort are unchanged (fraction 1.0).
On simulated case 19 (electric storage heaters, 7-hour, PV) this takes
β_Jan 0.894 → 0.792, matching the worksheet 0.791, and the summer months
(no main heating) already pinned exactly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 12a Grid 1 (PDF p.191): an electric room heater (RdSAP
main_heating_category 10, e.g. SAP code 691) is direct-acting electric,
so it sits on the "Other systems including direct-acting electric" row —
7-hour high-rate fraction 1.00, 10-hour 0.50. It runs on demand, mostly
at the HIGH rate; it does NOT earn the 100%-low-rate of overnight storage
charging (which is category 7).
`_table_12a_system_for_main` only mapped ASHP, so an electric room heater
fell through to the "100% low-rate" fallback (5.50 p, £0.0550), under-
charging space heating by ~9.79 p/kWh and systematically OVER-rating the
cluster. Now maps electric cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC
(gated on `_is_electric_main`, so gas/solid-fuel cat-10 room heaters are
excluded). The same Table 12a fraction flows through cost, CO2 (Table
12d) and PE (Table 12e) — all three callers already pre-gate on electric.
Mirror of S0380.228 (same fallback bug for electric SECONDARY heating).
1,000-cert 2026 API sample (no worksheet for this cluster — ±0.5-vs-lodged
fallback bar): cat-10 mean |err| 9.49 → 7.11, %<0.5 10.4% → 16.7%;
headline %<0.5 42.5% → 42.9%, overall mean |err| 2.29 → 2.16. cat-7
(storage) and cat-2 (gas) unchanged. Full §4 suite green (2405 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 3 (PDF p.160) row 1: 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 Table
4a hot-water-only codes (PDF p.166) 911 gas / 912 liquid / 913 solid
boiler-circulator + 921-931 range cooker with boiler are each a heat
generator feeding the cylinder through a primary loop.
`_primary_loss_applies` keyed only off the resolved DHW `main` — but for
these certs `_water_heating_main` returns the SPACE main (e.g. electric
storage heaters, SAP code 402, which has no primary loop), so every
boiler branch missed the gas water-boiler's primary circuit and (59)m
went to zero. New branch keys off `water_heating_code` ∈
`_WATER_HEATING_BOILER_CIRCULATOR_CODES`. 941 (electric HP for water
only) is excluded — HP DHW vessels follow the Table 3 integral-vessel
rules.
Simulated case 19 (electric storage main + WHS 911 + 210 L cylinder):
(62)m total HW demand 2493.30 → 3169.98 kWh/yr, matching the worksheet
(the missing 676.68 kWh/yr = the worksheet's (59) primary-loss annual
sum, h=5/p=0). The remaining (64)/(219) gap is the PV diverter (63b),
deferred to its own slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating is a direct-
acting electric room heater (RdSAP 10 §A.2.2 default), on the "Other
systems including direct-acting electric" row — 7-hour high-rate fraction
1.00, 10-hour 0.50. A room heater runs on demand, mostly at the high
rate; it does NOT earn the 100%-low-rate of overnight storage charging.
`_secondary_fuel_cost_gbp_per_kwh` previously returned the flat off-peak
LOW rate (5.50 p, £0.0550) for every off-peak electric secondary, under-
charging by 9.79 p/kWh. New `_secondary_off_peak_rate_gbp_per_kwh` mirrors
`_space_heating_fuel_cost_gbp_per_kwh`: it blends the Table 12a high-rate
fraction (OTHER_DIRECT_ACTING_ELECTRIC) against the Table 32 high/low
rates, with the 18-/24-hour fallback to the low rate.
Simulated case 19 (electric storage main + electric secondary, Dual/7-hour
meter) is the worksheet case (242): "Space heating - secondary
(1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. This was the primary
cat-7-cluster cost driver: total cost 1485.68 → 1835.53 (worksheet
1816.58), SAP cont 60.11 → 50.67 (worksheet ~51.22). Remaining +19 cost
is HW/space-heating kWh (next slices).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 2b note b (PDF p.159) applies the ×0.9 temperature-factor
reduction only when DHW is "separately timed" relative to space heating
on a SHARED heat generator ("boiler systems, warm air systems and heat
pump systems"). Per RdSAP 10 §10.5.1 (PDF p.55) a separate boiler/
circulator providing DHW only (water-heating code 911 = "Gas boiler/
circulator for water heating only") is NOT the main space-heating system
— so there is no shared timer to apply the ×0.9 against. `_separately_
timed_dhw` now returns False when water_heating_code is not "from main /
2nd-main system" ({901,902,914}), mirroring the existing WHC 903 electric-
immersion carve-out.
Simulated case 19 (electric storage main SAP 402 + WHS 911 + 210 L
loose-jacket cylinder) is the worksheet case. The single flag drives both:
- (53) Temperature factor: 0.54 → 0.6000 (worksheet base, no ×0.9)
- (55) storage loss/day: → 3.4531; (56)/(57)m Jan → 107.0456 (1e-4)
- (59)m primary loss: h=3 (43.31) → h=5 (Jan 64.5792), worksheet-exact
This also worksheet-pins S0380.224's loose-jacket storage loss magnitude
at 1e-4, previously only direction-validated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The §10.7 no-water-heating default cylinder raised UnmappedSapCode for
age bands A-F (2 certs in a 2026 sample, bands B + C) because Table 29's
"A to F: 12 mm loose jacket" row wasn't plumbed — the loose-jacket
storage-loss branch didn't exist. S0380.224 added it, so this slice
completes the Table 29 lookup.
Restructure _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE to carry
(cylinder_insulation_type, thickness_mm) per band — A-F → (loose jacket,
12), G/H → (factory, 25), I-M → (factory, 38) per RdSAP 10 Table 29
(PDF p.56) — and have the default read both, setting the loose-jacket
type for A-F instead of hardcoding factory. The strict-raise is retained
only for an absent / out-of-A-M age band (no Table 29 row).
Validated: certs 2211 (band B, SAP 49.8 vs lodged 52) and 3420 (band C,
11.2 vs 11) now compute. §4 + golden suite 2395 passed — the corpus
"no system" cert (age G, 25 mm factory) is unchanged. cert_to_inputs.py
pyright unchanged at 32; new test suppresses reportPrivateUsage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_cylinder_storage_loss_override` returned None for any cylinder whose
cylinder_insulation_type wasn't 1 (factory), so a loose-jacket cylinder
(code 2, RdSAP 10 field 7-11) fell to the cascade's zero-storage-loss
combi/instantaneous default — its real storage loss vanished. SAP 10.2
Table 2 Note 1 gives loose jacket a SEPARATE, ~2× higher loss factor
(L = 0.005 + 1.76/(t+12.8) vs factory 0.005 + 0.55/(t+4)); the
cylinder_storage_loss_factor_table_2 helper already implements it — only
the dispatch was missing.
Fix: a `_cylinder_storage_loss_insulation_label` resolver maps the lodged
code to the Table 2 branch (1 → factory_insulated, 2 → loose_jacket;
None/0/unknown → None, keeping the conservative no-loss default). The
override and the HW storage call now route through it instead of
hardcoding "factory_insulated".
Evidence + validation: a random 2026 register sample has 22 loose-jacket
certs that over-predicted SAP by +2.29 mean (18/22 too high, 1/22 within
0.5) — the exact signature of under-counted HW storage loss. After the
fix their mean error collapses to +0.45 and 11/22 land within 0.5, with
ZERO regression across the worksheet-validated cohort (§4 + golden suite
2394 passed — no validated cert lodges loose jacket, so none shifts).
Also unblocks the §10.7 A-F no-water-heating default (next slice) which
needs the loose-jacket branch. cert_to_inputs.py pyright unchanged at 32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cert 9390 (community mains-gas boiler, API main_fuel_type=20) drew £0
standing charge → fuel cost under-counted → SAP read +4 high (71 vs 67).
Root cause: the standing-charge logic (`additional_standing_charges_gbp`)
only knows the GAS branch (`_is_gas_code`) and the off-peak-electric branch.
A heat-network community fuel is not a Table-32 gas code — EPC 20 = "mains
gas (community)" normalises to Table-32 code 20 (biomass), so
`_is_gas_code(20)` is False and the standing came out £0. The Summary path
masks this because it lodges community gas as Table-32 code 1 (ordinary
mains gas), which IS gas-recognised and already draws the £120 gas standing
— so the CH1-6 corpus was unaffected while the API path lost the charge.
Spec basis (verified against SAP 10.2 spec PDF):
- Table 12 (p.191) "Heat networks" row standing charge = £120/yr, note (k).
- Note (l): "Include half this value if only DHW is provided by a heat
network."
- §C3.2 (p.58): the full charge applies when the space heating is also a
heat network.
Worksheet-validated: simulated case 14 (community boilers + mains gas,
space + water) → worksheet (351) Additional standing charges = £120.
Fix: new `_heat_network_standing_charge_gbp(epc, main)` returns the
heat-network standing (£120 full when the space main is a heat network;
£60 when only DHW is on the network) or None otherwise. Applied at both
fuel-cost call sites, REPLACING the fuel-based `additional_standing_charges
_gbp` for heat-network mains (NOT additive) so a Summary-path community-gas
main — already £120 via the gas branch — is not double-counted to £240. The
CH1-6 community corpus stays exactly £120 (59 corpus tests pass).
9390 SAP +4 → -2 (cont 65.39 vs lodged 67): the spec-correct £120 standing
EXPOSES a separate ~7% demand over-count (also visible as PE 220 vs lodged
205) — a heat-source-efficiency-default / fabric residual, follow-up scope.
9390 is unpinned (retired P2.2 per ADR-0010 §10); helper locked by 2 unit
tests. Full suite 2386 passed, 1 skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cert 9390-2722-3520 (community mains-gas boiler scheme, sap_main_heating_
code=301, main_fuel_type=20) emitted CO2 0.44 t vs lodged 2.8 t — 6.4x low.
Root cause: the EPC `main_fuel_type` enum and the SAP Table 12 / Table 32
fuel-code numbering COLLIDE in the 18-25 range. Per
`datatypes/epc/domain/epc_codes.csv` (RdSAP-Schema-17.0) EPC fuel
20 = "mains gas (community)", but Table 12/32 code 20 is a solid biomass
fuel (CO2 0.028, PE 1.046, wood-logs price). The factor lookups
(`co2_factor_kg_per_kwh` / `primary_energy_factor` / `unit_price_p_per_kwh`)
check the Table-12/32 dict FIRST, so the EPC community fuel 20 silently
returned the biomass factor instead of translating 20 -> Table 12 code 51
(community mains gas: CO2 0.210, PE 1.130, mains-gas price).
Fix: new `_heat_network_factor_fuel_code(main)` translates the EPC community
fuel to its Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for
heat-network mains (`_is_heat_network_main`) — a genuine biomass boiler
(non-community) keeps its raw Table-12 factor. Applied at the five
heat-network factor sites: space-heating CO2 / PE / unit-price and
water-heating (WHC 901) CO2 / PE. The Summary path is unaffected (it maps
"Mains gas - community" to code 1, no collision), so the community-heating
corpus (CH1-6) is untouched.
Worksheet-validated against simulated case 14 (community boilers + mains
gas, SAP code 301): worksheet (367) CO2 factor 0.2100, (467) PE factor
1.1300 — exactly the Table-12 code-51 values the translator now reaches.
9390 CO2 0.44 -> 3.03 t (lodged 2.8; spec-correct factors over the API-only
register residual per [[feedback-worksheet-not-api-reference]]), PE 204 ->
220 (the spec-correct 1.13 factor; the prior 204≈205 match was the
collision coinciding with the register residual). 9390 is unpinned (retired
at P2.2 per ADR-0010 §10); the translator is locked by two unit tests.
REMAINING (separate follow-up): 9390 SAP +4 is a cost-side gap — the
heat-network cost path does not apply the 1/heat_source_eff (1/0.80)
scaling that the CO2/PE paths do, so community fuel cost under-counts.
Suite: 2616 passed, 1 skipped (community corpus green); the 2
test_rdsap_uvalues stone-formula failures are pre-existing (HEAD 58ff7d88).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Appendix D §D2.1(2) Equation D1 blends the monthly water-heater
efficiency by the ratio of the boiler's space-heating load to its water
load. On a dual-main cert the DHW boiler does only its OWN share of space
heating ((204) for Main 1, (205) for Main 2), but the cascade fed Eq D1
the dwelling total ((202) = 1 − secondary). That over-weighted η_winter
and under-stated HW fuel — simulated case 6 (Main 1 serves DHW + 51% of
space heat) was HW −78 kWh vs the worksheet.
New `_water_heating_main_space_fraction` returns the DHW main's total-
space share via `_water_heating_main` (WHC-901 → Main 1 (204); WHC-914 →
Main 2 (205)); single-main / WHC-901 single systems get (202) = 1 −
(201), so they are unchanged. Case 6 (219) HW now 4902.8601 EXACT.
With S0380.205 (demand exact), case 6 now closes to 1e-4 on EVERY metric:
SAP cont 71.6597, ECF 2.0316, cost 1162.5374, (211)+(213) 14736.9564,
(219) 4902.8601, (231) 356, (232) 357.6571, CO2 5953.6679 (rating) /
4895.2137 (demand).
Re-pin: 0240 (dual combi, WHC 901, Main 1 51%) HW rises slightly → PE
+1.6893 → +1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). Single-main
certs unchanged (2360 pass + 0 fail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>