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>
"One scorer, two harnesses" (ADR-0030): the committed gate, the local script,
and the future battle-test must run the *same* scoring. Extract it:
- domain/epc_prediction/validation.py — `iter_predictions` (the single
leave-one-out orchestration: latest-per-address hold-out, SAP-10.2 target
filter, all-vintage source) + `evaluate_component_accuracy` (calculator-free
ComponentAccuracy aggregation, the primary signal). Unit-tested.
- harness/epc_prediction_corpus.py — `load_corpus(dir)` IO: corpus dir ->
Comparable cohorts (maps payloads, carries address + registration_date).
validate_epc_prediction.py now just loads + calls the scorer for the component
section and iterates iter_predictions for the calculator-floored end-to-end.
Identical numbers (181 targets, SAP MAE 6.34) — behaviour-preserving.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Heating is the dominant SAP lever (ablating it to actual cut the SAP error
~7 -> ~4.5) yet was entirely unscored. Add the heating group to
compare_prediction's categorical_hits: main fuel / category / control (off
the primary MainHeatingDetail), water-heating fuel / code, has-cylinder,
cylinder insulation, secondary heating (off SapHeating).
Template-copied baseline on the 40-postcode corpus (no predictor change
yet — this just makes the signal visible):
heating_main_fuel 93.4%
heating_main_category 92.7%
water_heating_fuel/code 91.7% / 92.4%
heating_main_control 62.1% <- weak
has_hot_water_cylinder 78.5%
cylinder_insulation_type 35.8% (n=120) <- weak
secondary_heating_type 16.8% (n=125) <- weak
Fuel/category predict well from the template; controls, cylinder, and
secondary heating are poor and now drive the next predictor slices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ADR-0030 commits Component Accuracy to ~19 categorical components (5 today
+ 8 heating + glazing/renewables). Flat *_correct dataclass fields don't
scale — each needs manual runner wiring. Collapse them into a single
`categorical_hits: dict[str, Optional[bool]]` keyed by component name, which
also matches the runner's name-keyed aggregation (now generic: it tallies
whatever components the comparison reports). No behaviour change; the
classification rates are identical (wall n 578->575 is the 3 certs whose
actual wall is None, now correctly counted as not-applicable via _classify).
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 register lists every historical lodgement, so a postcode cohort
contains the same physical address many times (LS61AA: 15 certs / 11
addresses; NG71AA: 15 / 9 — "FLAT 3" appears 3x in each). Two
consequences:
- Production: a re-lodged neighbour was counting up to 3x towards the
cohort mode. select_comparables now dedupes candidates to the latest
cert per address (one comparable per real neighbour) — Comparable
gains address + registration_date (the register metadata its docstring
already anticipated, read straight off the cached payload).
- Validation: leave-one-out leaked — predicting a flat from a near-
identical re-lodgement of itself. The harness now holds out a whole
address (excludes every sibling cert) and evaluates on the latest cert
per address (the best ground truth).
Removing the leak gives the honest numbers (19 distinct addresses):
wall_construction 93.1% -> 89.5%
construction_age_band 65.5% -> 52.6%
roof_construction 79.3% -> 68.4%
floor_area mean|.| 37.9 -> 52.6 m2
The earlier figures were inflated by self-leakage; these are the real
accuracy to beat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Only main wall_construction was set to the cohort mode; the other
homogeneous categoricals (wall insulation, construction age band, roof
construction, floor construction) were left as template-copied, so one
median-size template's quirks set them. Apply the same cohort-mode
mechanism to all of them per ADR-0029 decision 4 — the template still
supplies geometry, only the categorical codes move to the mode.
Verified mode beats (or ties) template-copy per categorical before
applying. Smoke corpus (29 leave-one-out) classification rates:
construction_age_band 55.2% -> 65.5%
roof_construction 72.4% -> 79.3%
floor_construction 46.2% -> 84.6%
wall_insulation_type 93.1% (tie — already template-strong)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The comparison only scored main wall_construction; everything else the
predictor produces (by template-copy) went unmeasured. Extend
compare_prediction to the rest of the ADR-0029 homogeneous categoricals —
wall insulation type, construction age band, roof construction, floor
construction — and aggregate per-categorical classification rates in the
runner. A categorical hit is "not applicable" (None, excluded from the
denominator) when the actual lodges no value, so absent-roof flats don't
score free wins.
Smoke corpus (29 leave-one-out, all but wall are template-copied today):
wall_construction 93.1%
wall_insulation_type 93.1%
construction_age_band 55.2% <- loud; candidate for cohort-mode
roof_construction 72.4%
floor_construction 46.2% (n=13)
These numbers drive the next slice (extend cohort-mode coverage).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Template (the comparable whose structure/geometry is copied wholesale)
was members[0] — an arbitrary draw from the API search order. With floor
area varying widely within a property_type cohort (NG71AA houses span
51-340 m2), this made the copied geometry noisy and systematically large.
Pick the member whose floor area is closest to the cohort median instead,
implementing ADR-0029 decision 4's unimplemented "closest on size"
criterion while keeping the structure coherent (it is still one real
property, so floor dims / windows / parts stay internally consistent for
the calculator).
Smoke corpus (29 leave-one-out predictions):
floor_area mean|.| 68.0 -> 37.9 m2 (bias +46.8 -> -3.9)
window_area mean|.| 11.1 -> 7.3 m2
parts mean|.| 1.00 -> 0.38
SAP |pred-calc - calc(actual)| MAE 7.19 -> 4.86
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure compare_prediction (TDD): wall-construction classification hit + signed
residuals on floor area, window count, total window area, building-parts count.
Plus validate_epc_prediction.py (IO plumbing): drops each cert from its postcode
cohort, predicts from the rest on guaranteed inputs only, aggregates the metrics,
and reports SAP three ways (pred-calc vs lodged / vs calc-on-actual / vs the
neighbour-mean baseline). Smoke run: wall 90.9%, floor-area mean|·| 42.6 m2 (a
real signal — template-copied floor area is noisy), SAP pred-calc edges baseline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
predict() copies a representative template comparable's structure (coherent for
the calculator), overrides the homogeneous categorical with the cohort mode
(robust to an atypical template), then applies known Landlord Overrides on top
(a known value wins over the estimate). Proven on wall construction; roof/floor/
insulation/age extend on the same mode+override mechanism, driven next by the
validation harness metrics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure-domain select_comparables: property type is an always-hard filter; built
form and known Landlord Overrides (e.g. solid brick) are conditioning filters on
the filter-then-relax ladder — applied while >= minimum_cohort survive, relaxed
otherwise (the mixed-street border case degrades gracefully). PredictionTarget
(known inputs) + Comparable (epc + register metadata) + ComparableProperties
(selected cohort). Weighting (recency x similarity) follows in the synthesis slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a committed integration test driving the full API path — raw gov-EPC
response → from_api_response → cert_to_inputs → calculate_sap_from_inputs —
across all 1000 certs in the in-repo RdSAP-21.0.1 corpus, and pins the
aggregate accuracy of our continuous SAP (plus CO2 and primary energy)
against each cert's lodged figures. Mirrors scripts/eval_api_sap_accuracy.py
but runs in CI off the committed corpus (~2s, no /tmp sample needed).
Scoped to RdSAP-21.0.1 — the SAP 10.2-era schema whose lodged rating uses the
same methodology we compute (a fair target). Pre-SAP10 schemas (17.x-20.0.0)
lodge SAP 2012 ratings and are out of scope (guarded for mapping only by
test_mapper_corpus.py).
Current: SAP within-0.5 = 65.0%, MAE = 1.174 (tight floor/ceiling — the
optimised gauge). CO2 MAE = 0.27 t/yr (bias +0.17) and PE MAE = 14.6
kWh/m2/yr (bias +8.9) are reported + loosely guarded: cost is well-calibrated
but CO2/PE both run ~+5-10% high (uniform across fuels — a systematic
CO2/PE-factor or scope gap, not yet investigated). Thresholds ratchet as
slices tighten each metric.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mixer-shower hot-water demand (worksheet 42a) divided N_shower by the
count of MIXER outlets only. But SAP 10.2 Appendix J step 1a is explicit:
"Establish how many shower outlets are present in the dwelling, Noutlets
(including in the count any instantaneous electric showers)" — and the
electric-shower step (64a) uses that same Noutlets from step 1a. So a
dwelling with both a mixer and an electric shower assigned the FULL N_shower
to the mixer system AND billed the electric shower on top of it, double-
counting shower demand → over-counted main HW → under-rated the dwelling.
Fix: thread the electric-shower count into the mixer demand so the
denominator is the total outlet count (mixer + electric), iterating the
warm-water draw over the mixer outlets only (per step 1e).
shower_types=1,2 cohort: -0.37 median -> +0.28 (crossed zero); API gauge
68.4% -> 69.0% within-0.5. Golden cert 0300-2747 (1 mixer + 1 electric)
re-pinned: PE +0.93 -> -0.10, CO2 +0.25 -> +0.15 (both toward zero,
confirming the double-count). Worksheet harness 47/47, 0 divergers (the
Elmhurst fixtures have no electric showers).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-PCDB MEV fan-electricity path fed the SAP 10.2 Table 4g default SFP
(0.8 W/(l/s)) directly as SFPav. But Table 4g note 3 (PDF p.176) is explicit:
the default SFP values "are to be multiplied by the appropriate in-use factor
for default data from the PCDB" — PCDB Table 329 system_type 10 ("default
data, used when SFP is taken from Table 4g rather than the PCDB"), IUF 2.5
(duct-agnostic per note 2). Table 4h, which previously held these factors, is
retired ("no longer used – data now stored in the PCDB").
Omitting the IUF under-billed the index-less MEV fan electricity by 2.5x
(SFPav 0.8 instead of 0.8 x 2.5 = 2.0), so cost was too low and the cohort
over-rated. This is distinct from the with-index path, which already applies
the tested-product system_type-2 "no scheme" IUF (~1.45) per fan.
Index-less gas-house MEV cohort: +1.37 median -> -0.18 (12% -> 92% within 0.5),
no overshoot — the missing IUF was exactly the over-rate. API gauge 67.7% ->
68.4% within-0.5 (mean|err| 0.992 -> 0.986, signed +0.031 -> +0.006).
Worksheet harness 47/47, 0 divergers (Summary-path MEV certs carry a PCDB
index or are natural, so unaffected).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Orchestrator runs recommend_secondary_heating_removal; report._triggers_for
explains it via the lodged secondary_heating_type; harness catalogue + ARA seed
price it. Re-pins the golden/integration plans it shifts: it is a cheap (\£250)
SAP lever, so on gas-main certs lodging an electric secondary (691) it displaces
the \£12k ASHP (0330, 0036) or joins the all-beneficial-measures package (000490,
where its marginal SAP is 0 under the category-4 ASHP but the heater is still
physically removed). Consistent with the optimiser's existing kitchen-sink
package behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two cascade tests on the worksheet-pinned 001431 build_epc() (the user's
before/after Summary PDFs trip the documented 001431 window-extraction bug, so
the repo's sanctioned 001431 baseline is used instead):
- electric-storage main (code 402) + secondary 691: removal reproduces the
secondary-removed cert at delta 0 — RdSAP §A.2.2 re-forces a default secondary,
matching the user's F35→F35 example;
- gas combi main (code 104) + secondary 691: removal strictly raises SAP
(74.22→77.61) — the Table 11 fraction reallocates to the cheaper main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gov-EPC API surfaces the assessor's RdSAP-assessed per-element U-values
as `roof_u_value` / `wall_u_value` / `floor_u_value` on each building part.
These were undeclared on the RdSAP 21.0.0/21.0.1 schemas, so `from_dict`
silently dropped them, and `heat_transmission` re-derived each U from the §5.6
/§5.7/§5.11 construction-default cascade. The gov OPEN data routinely redacts
the backing insulation thickness, so that re-derivation mis-bills an insulated
element as uninsulated.
RdSAP 10 §5.1: a known element U-value (documentary evidence / the lodged
RdSAP output) is used directly in place of the construction-default cascade.
Per [[project_per_cert_mapper_validation_state]] the gov API carries RdSAP
OUTPUT, so the lodged U reproduces the official's element heat loss exactly.
Worst case in the 2026 sample: cert 7921-0052-0940-5007-0663, an age-C
"Pitched, sloping ceiling" (rc=8) top-floor flat lodging roof_u_value=0.2 with
no thickness. The cascade returned the uninsulated 2.30 W/m²K → SAP 56.9 vs
lodged 80 (-23.09, the single largest error in the sample). The roof override
alone recovers ~15 SAP; the wall override (lodged 0.34 vs cascade) closes the
rest of this cohort.
Override applies to the MAIN wall only (alt-wall sub-areas keep their own
per-area U) and the part's floor=0. Fires only when the rare field is present
(9 of 909 computed certs), so the Summary path — which never lodges these
API fields — is untouched.
API gauge: 67.1% → 67.7% within-0.5, mean|err| 1.024 → 0.992.
Worksheet harness: 47/47, 0 divergers (unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flat per-dwelling decommission price (sample_catalogue \£250) + 0.25 contingency
(covers unknown heater count / hard-wired-vs-plugged / repaint extent). The JSON
repo joins the contingency from config, proven by the new repo test. No composite
Products machinery — a lodged secondary is one roughly-fixed job, not room-scaled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_secondary_heating_removal offers one standalone Option that clears the
lodged secondary system. Eligibility is purely physical (offer iff
sap_heating.secondary_heating_type is set) — no effectiveness gate, since a
lodged secondary is a fixed emitter per RdSAP (portables are ignored), and the
electric-storage §A.2.2 no-op is the Optimiser's call (ADR-0028 decisions 1-2).
Priced at a flat per-dwelling decommission cost, not room-scaled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first overlay surface that sets fields to *absent* rather than to a
target state: _fold_secondary_heating clears sap_heating.secondary_heating_type
+ secondary_fuel_type, so the calculator's Table 11 secondary-fraction split
(SAP 10.2 §9a) routes 100% of space heating to the main. On an electric-storage
main RdSAP §A.2.2 re-forces a default secondary, making removal a no-op there —
left to the Optimiser to de-select (ADR-0028 decisions 2-3).
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>
The main-wall `u_wall(...)` call dropped the `dry_lined` kwarg, so the RdSAP 10
§5.7/§5.8 (PDF p.40-41) Table 14 dry-lining adjustment — U_adj = 1/(1/U₀ +
0.17) for a dry-lined (incl. lath-and-plaster) uninsulated wall — was never
applied to any main wall, even when the cert lodged `wall_dry_lined=Y`. The
ALTERNATIVE-wall path already passes `dry_lined` (line 1367); this one-sided
omission billed every dry-lined main wall at the un-adjusted (too-high) U →
wall heat loss too high → SAP under-rated.
Per-cert: a solid-brick (construction 3) band-A 230 mm main wall computes
U₀=1.70; dry-lined it is 1/(1/1.70+0.17)=1.32 — we were 22% too high. Across
the API gov-EPC sample the dry-lined `wall_construction=3` (solid brick)
sub-cohort sat at 10% within-0.5 / signed -1.33.
Fix: pass `dry_lined=bool(part.wall_dry_lined)` to the main-wall `u_wall`
call, mirroring the alt-wall path. `part.wall_dry_lined` is already plumbed
(Optional[bool], None → False). The three dry-lining branches in `u_wall`
(stone §5.6, solid-brick-by-thickness §5.7, generic uninsulated bucket §5.8)
are all spec-correct and already worksheet-validated (the bucket-0 cavity
case against cert 7700 age-C → 1.20).
Worksheet harness UNAFFECTED (47/47, 0 divergers): the Elmhurst/Summary
extractor only captures dry-lining for ALTERNATIVE walls (Summary §7), never
the main wall, so `part.wall_dry_lined` stays None on that path — this is a
pure API-path improvement. API gauge: within-0.5 60.1% -> 64.4% (mean|err|
1.163 -> 1.085, signed -0.097 -> +0.049). Both affected buckets improved
with no overshoot: solid brick (wc=3) 50% -> 57% within-0.5; cavity (wc=4,
dry-lined via the §5.8 bucket-0 path) 68% -> 72%.
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>
Don't offer a like-for-like gas boiler swap to a dwelling whose existing gas
boiler is already at least as efficient as the new condensing boiler (SAP 10.2
Table 4b codes 102/104 = 84% winter) — it gains nothing, and the dwelling gets
the tune-up (cylinder + controls) instead. `_already_condensing` compares the
existing code's Table 4b winter efficiency to 84%; a non-Table-4b code (solid
fuel) has no comparable efficiency and is never treated as already-condensing.
The gate is GAS-ONLY: a non-gas boiler → gas is a fuel switch whose value (cost
/ carbon) is not captured by winter efficiency, so oil/LPG/coal → gas is never
suppressed on efficiency grounds (only gated on the mains-gas connection).
This correctly demotes the gas-with-cylinder example (cert lodges code 114
"Regular, condensing", 84% winter) to a tune-up case — confirming that 114→102
is ~0 boiler-efficiency gain in both our calc and Elmhurst (both Table 4b 84%);
Elmhurst's uplift there came from the cylinder + flue, not the boiler. The
boiler-with-cylinder overlay stays validated by the lpg pin (code 115, non-
condensing + cylinder) and by recasting the 114 fixtures' code to a pre-1998
non-condensing boiler (110) in the boiler tests — the overlay overwrites the
code to 102 regardless, so only eligibility changes, not the delta-0 result.
New tests: an already-condensing gas boiler yields no boiler upgrade (but a
tune-up); an oil condensing boiler is not gated (the fuel switch survives).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SAP 10.2 Table 12a (PDF p.191) is titled "High-rate fractions for systems
using 7-hour and 10-hour tariffs"; its "Immersion water heater" row lists
the tariff as "7-hour or 10-hour" only, routing to Table 13. An 18-hour or
24-hour tariff is OUTSIDE the table's scope — it provides at least 18
hours/day at the low rate, more than enough to heat any immersion cylinder
off-peak, so the high-rate fraction is 0 (all DHW billed at the low rate).
`electric_dhw_high_rate_fraction` previously mapped 18-/24-hour to the
10-hour equations (returning ~0.10 for a 110 L dual immersion) on an
over-literal reading of Table 13 Note 1 ("at least 10 hours"). The Elmhurst
dr87 worksheet for solid fuel 5 (cert 001431: 18-hour meter, 110 L dual
immersion, WHC 903) refutes that: HW (245) high-rate = 0.0 kWh, (246)
low-rate = 100%. Table 12a's title bounds the table to the two named
tariffs; 18-/24-hour fall outside it.
Resolves the Table-13 blocker on the immersion-extractor fix: once the
Summary extractor captures the dual immersion, the 18-hour solid-fuel
corpus certs stay at high_frac=0 (matching their worksheets) instead of
regressing to the 10-hour-column 0.10.
API SAP eval unchanged: 57.6% within 0.5, mean|err| 1.185, signed -0.165
(the cached sample has no 18-hour WHC-903 certs; one 24-hour cert shifts
sub-threshold). Regression gate green (3 pre-existing fails unrelated).
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>
Replace the flat placeholder scalars (boiler £3000; tune-up £500/£900) with a
per-dwelling composite cost, mirroring the ASHP architecture (ADR-0025): a
`HeatingRates` table (data, `heating_rates.json`), typed `BoilerCostInputs` /
`TuneUpCostInputs`, pure `Products.boiler_bundle_cost` / `tune_up_cost`, and
modelling-layer interpreters that read the dwelling into those inputs.
The cost mirrors the Simulation Overlay component-for-component, sharing the
controls + cylinder pricing across both options:
- tune-up (standard) = standard controls + cylinder fixes
- tune-up (zone) = zone controls + cylinder fixes
- boiler upgrade = £3200 all-in + standard controls (only when the upgrade
fired a controls change) + cylinder fixes
Standard controls are priced INCREMENTALLY — only the parts missing to reach
SAP 2106 (programmer £120 / room thermostat £150 / TRV £35×radiators), read
from a Table 4e Group-1 feature map so a dwelling that already has a room
thermostat + TRVs is only charged the programmer. Zone controls are a full
smart kit (hub £205 + smart TRV £50×radiators) — the smart TRV is itself the
room sensor, so there is no separate per-room sensor line. Cylinder fixes:
jacket £50 (when under-insulated) + thermostat £150 (when absent). The boiler
is a like-for-like wet swap (no radiators/flue/pipework — eligibility already
requires an existing wet boiler), so those dead-code extras are not modelled.
Figures are research-validated 2025/26 UK installed costs (legacy Costs.py
lineage); fully-loaded totals with one contingency on top (Model B, not the
legacy VAT/preliminaries engine). Contingency: boiler 0.26; tune-ups 0.10
(was a 0.15 placeholder). ADR-0027 records the design; CONTEXT.md's Heating
Eligibility entry updated to cover the partial boiler/tune-up family + composed
cost. Products cost pins (delta<=1e-9) + interpreter tests + generator
composite-cost assertions.
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>