_heat_pump_apm_efficiencies applied the Table N8 in-use factor to the PCDB
water-heating efficiency but omitted the spec's "subject to a minimum
efficiency of 100%" clause (SAP 10.2 Appendix N3.7, PDF p.109). An oversized
heat pump whose PSR-extended water,3 x the 0.60 in-use factor fell below
100% therefore billed water heating at that sub-100% efficiency, over-
counting hot-water fuel.
Apply max(in_use x eta_water, 100%). Accredited Elmhurst worksheet for cert
100110101713 (golden fixture case 56, PCDB 100061): water (216) = 100.0000,
which we now match (was 77.13%). Combined with the space-heating PSR-
extension fix the cert lands 72.51 vs lodged 73 (|err| 18.32 -> 0.49).
Data-driven: only the single oversized-PSR cert moves (in-range heat pumps
keep their > 100% water COP, e.g. case 54 112.5%, case 55 179.6%). Corpus
(RdSAP-21.0.1 n=1000) MAE 0.726 -> 0.721, within-0.5 74.1% -> 74.2%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gate PV generation/credit in cert_to_inputs on gov-API pv_connection:
credit only when ==2 ('connected'); ==1 ('present but not connected to the
dwelling's meter') contributes zero to the dwelling's cost/CO2/PE per
RdSAP 10 §11.1 / SAP 10.2 Appendix M. Non-int (None / site-notes str) keeps
the credit-if-array behaviour, so the Elmhurst/Summary + synthetic paths are
unchanged (no regression).
Corpus: all 5 pv_connection=1 PV certs move inside ±0.5 (e.g. 100051118081
+6.5→+0.5); MAE 0.760→0.740, within-0.5 73.8→74.3%, no regression
(pv_connection=2 certs keep their credit).
Also corrects a now-load-bearing latent bug: the solar-recommendation
overlay tagged recommended arrays pv_connection=1 ('not connected') — which
the new gate would zero. A new install connects to the dwelling's meter, so
it must be 2; pinned by the overlay test.
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>
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>
Extends the dMEV intermittent-fan fix (4db05e84) to MVHR. A balanced
whole-house MVHR system IS the dwelling's ventilation, so the lodged (7a)
intermittent-extract-fan count is explicit — a lodged 0 means 0, not the
RdSAP 10 Table 5 age-band "unknown" default. The cascade was substituting
the default (here 20 m³/h) into worksheet line (8) openings, inflating
(16/18) infiltration → (21) → (22b) → (25) effective ach → (38)
ventilation heat loss → the space-heating demand.
Worksheet-proven on simulated case 49 (000565 + Vent Axia 500140 MVHR,
lodged (7a)=0): our (8) openings 0.0723 -> 0.0000, (18) 0.7223 -> 0.6500,
(25)m Jan 0.9423 -> 0.8571, all now matching Elmhurst exactly; space-
heating demand 7857 -> 7528 kWh (worksheet 7546). SAP 70.90 -> 71.43
continuous. (The residual to the worksheet's 72 is its own continuous SAP
71.69 rounding up, driven by a separate gas-combi water-heating-loss gap,
not ventilation.)
Scoped to EXTRACT_OR_PIV_OUTSIDE + MVHR only — MV-without-HR
(mechanical_ventilation=1) stays on the default-substitution path
(forcing its lodged 0 regressed 47 Howsman / 18 Jutland and is not
worksheet-validated). Corpus within-0.5 holds 72.7%, MAE 0.782 -> 0.781.
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>
Supersedes the previous "verified non-fix" doc (3548f1f3): the spec DOES make
this a fix — Khalim was right that the Unknown-meter branch is driven by the
heating/water system, not a blanket STANDARD.
RdSAP 10 §12 (PDF p.62): "If the electricity meter is unknown, treat as single
meter EXCEPT where main heating OR WATER HEATING are intended to run off an
off-peak tariff (per systems listed in the text box above) ... If that results
in a dual meter, assign tariff per rules 1 to 4." The text-box off-peak systems
include DUAL ELECTRIC IMMERSION. Our `rdsap_tariff_for_cert` only triggered the
Unknown→off-peak exception on a storage/CPSU MAIN — it ignored the
dual-electric-immersion WATER-heating trigger, so an Unknown-meter dwelling
with a non-storage main (e.g. room heaters) + dual immersion was billed
STANDARD (13.19p flat) when it should be dual → Rules 1-4 on the main.
Fix: thread `water_is_off_peak_dual_immersion` (whc 903 + immersion lodged dual
via `_immersion_is_single is False`) into the Unknown-meter branch; when any
text-box trigger is present, resolve via the same Rules 1-4 dispatch (room
heaters → Rule 3 → 10-hour). Single-immersion / instantaneous (whc 909) certs
correctly stay STANDARD (no text-box system).
Worksheet-validated on "simulated case 48" (main 691 + Unknown meter + 903 dual
immersion): Elmhurst 10-Hour Off Peak, SAP 57; ours 45 → 55 (7-hour gives 45,
confirming 10-hour). Flips exactly ONE corpus cert — Apartment 241 (the genuine
-5.38 under-rater, main 691 + dual immersion) -5.38 → -1.05; every other
Unknown+dual-immersion cert already has a storage main (Rule 2). Corpus
within-0.5 holds 72.5%, MAE 0.793 → 0.789 (improved). CO2/PE unchanged.
GSHP/WSHP-main trigger (the other §12 Unknown exception bullet) is a separate
follow-up. Gates green: corpus 72.5%/0.789, batch worksheet 0 raised/0 diverge,
000565 e2e 11/11, suite 2987 passed (2 known pre-existing fails). 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>
SAP 10.2 §2 (17)-(18): a measured/design air permeability at 50 Pa from a
Blower Door test routes infiltration via `(18) = AP50/20 + (8)`, in
preference to the components-based (16) estimate. The Elmhurst extractor
read only the AP4 ("Pulse") column of §12.2, so a Blower Door result
(§12.2 "Pressure Test Result (AP50)") fell through to the structural-
infiltration default — over-counting ventilation heat loss.
Surfaced by simulated case 44 (AP50 4.50): effective air change rate was
0.81 vs the worksheet's 0.58 (+38% ventilation loss). The cascade already
supports `air_permeability_ap50` (preferred over AP4); this wires the read
end to end (extractor → ElmhurstSiteNotes → SapVentilation → cert_to_inputs).
Pinned against the case-44 P960 §2 at abs=1e-4: (18) infiltration 0.3417
(= 4.5/20 + 0.1167) and (25) Jan effective ach 0.5812. Worksheet harness
stays 47/47 0-raised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gov API lodges a manufacturer's declared cylinder loss factor
(kWh/day) in `sap_heating.cylinder_heat_loss`, in which case it leaves
the cylinder volume / insulation type / thickness None. That field was
undeclared on the 21.0.x schemas, so `from_dict` dropped it — then
`_cylinder_storage_loss_override` hit its insulation-None / volume-None
guards and returned None, dropping the §4 storage loss ENTIRELY. The
dwelling over-rated (the declared loss is typically ~1.5 kWh/day ≈
550 kWh/yr).
SAP 10.2 §4 branch a) (PDF p.136): when the declared loss factor is
known, storage loss (50) = (48) declared loss × (49) Table-2b
temperature factor — replacing the Table 2 V×L×VF computation.
- declare `cylinder_heat_loss` on RdSapSchema21_0_0/21_0_1.SapHeating +
EpcPropertyData.SapHeating; thread through the 21.0.x mappers.
- `cylinder_storage_loss_monthly_kwh` gains `declared_loss_kwh_per_day`:
when set, combined_55 = declared × TF (volume/insulation unused).
- `_cylinder_storage_loss_override` resolves the declared loss BEFORE the
insulation/volume guards (the gov omits those when the loss is lodged).
12 /tmp certs carry it (mean |err| 3.00 -> 2.51; the clean ones close
hard, e.g. 2360 2.65 -> 0.30, 0245 2.25 -> 0.53). Corpus within-0.5
67.0% -> 67.3% (MAE 1.025 -> 1.020); /tmp 71.2% -> 71.4% (0.889 ->
0.882). Worksheet harness 47/47; regression = only the 3 pre-existing
fails; pyright net-zero.
Co-Authored-By: Claude Opus 4.8 <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 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>