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>
Drops the xfail now that Reduced-Field Synthesis (ADR-0027) maps every
20.0.0 cert; the corpus test holds the whole bucket to a strict 1000/1000.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ADR-0027 Reduced-Field Synthesis: certs with no per-window array now get total
glazing = 0.148 x TFA x band-multiplier (median + quartile multipliers fit from
the 1000 real 21.0.1 certs), split 4-way across N/E/S/W with width=area/4,
height=1.0; glazing_type routed through the verified 21.0.1 cascade. Also guard
optional PhotovoltaicSupply.none_or_no_details (a parse straggler). Corpus maps
983/1000, up from 974.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Required->optional defaults (kw_only + data-driven from corpus presence) so
993/1000 certs that omit sap_windows parse, and honest Union[str, DescriptionV1]
typing for description/dwelling_type which the corpus lodges as localised dicts
in ~half the certs. The never-run 20.0.0 mapper path now produces EpcPropertyData;
974/1000 corpus certs map (xpass), up from 7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sharpen the glossary to decouple deterministic old-schema re-mapping from
neighbour-prediction gap-fill (a separate, unimplemented ML path), and add
the Reduced-Field Synthesis term. ADR-0027 records the pre-SAP10 20.0.0
mapper's best-attempt synthesis (corpus-fit glazing 0.148xTFAxband, 4-way
orientation) and its trade-offs. Grill resume doc captures every resolved branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An API audit flagged the solid-fuel room-heater space efficiencies
(_SPACE_EFF_BY_CODE 631-636) as reading the "Water" column of SAP 10.2
Table 4a. That was a misread: the two room-heater columns are (A)
minimum-for-HETAS-approved and (B) other appliances — BOTH are space
efficiency, not space/water. RdSAP defaults to column (B) when HETAS
approval is not lodged, which is what these values already hold and what
the reference software produces (Elmhurst worksheet "solid fuel 9", SAP
code 636 → (206) space efficiency = 70 = column B; flipping to column A
75 broke that pin and three sibling solid-fuel corpus pins).
No value change — add a pin test + spec-cited comment so the column-(A)/
(B) distinction is explicit and this misread can't recur.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the system tune-up to the heating Recommendation: keep the existing wet
boiler but install better heating controls and fix the cylinder. Two competing
Options (the Optimiser picks <=1 across the whole heating rec) per the user's
two best control end-states:
- system_tune_up — standard controls (programmer + room thermostat +
TRVs, SAP 10.2 Table 4e code 2106)
- system_tune_up_zoned — time-and-temperature zone control (code 2110, type 3):
more SAP uplift for more cost
Both keep the boiler (no fuel / SAP code / flue change), set the control
ABSOLUTELY to their end-state, and apply the conditional cylinder fixes (an
80 mm jacket when under-insulated, a thermostat when absent — only when a
cylinder exists). Each control option is offered only when it genuinely improves
the existing control — standard is skipped when the control is already 2106 /
2110 / 2112, zone when already 2110 / 2112 — so neither is ever a downgrade or a
no-op.
Validated against the Elmhurst "system tune up" re-lodgements (cert 001431):
nine befores spanning controls 2101-2113 all converge to the two common afters,
proving the control overlay is absolute. The cascade pin is parametrised over
two starting controls (2101 "no control" + 2113 "room thermostat and TRVs") x
both afters, delta 0 (SAP/CO2/PE).
Wires the two MeasureTypes through contingencies (0.15), the offline catalogue
(500 / 900), the catalogue-coverage list, the report triggers, and the ARA
first-run seed.
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>
An LPG-boiler dwelling on the Summary → from_elmhurst_site_notes path
mapped to main_fuel_type=26 (mains gas), making it indistinguishable
from a mains-gas boiler downstream — wrong Table 12/32 cost / CO2 / PE
(bottled LPG is ~10.30 p/kWh vs mains gas 3.48), and it defeats any
"non-gas → gas only with a mains-gas connection" gate (an LPG dwelling
looks already-gas).
Root cause: the recommendation worksheets lodge the boiler carrier as
§15.0 "Water Heating Fuel Type: Bottled gas" (§14.0 carries only SAP
code 115, a Table 4b gas-family row, + "Main gas: Yes" in §14.2 — a
mains-gas CONNECTION, not the heating fuel). "Bottled gas" was absent
from `_ELMHURST_MAIN_FUEL_TO_SAP10`, so the §15.0 fuel resolved to None
and `_elmhurst_gas_boiler_main_fuel` fell through priority-1 to the
mains-gas meter flag → 26.
Map "Bottled gas" → 3 (bottled LPG MAIN heating): code 3 routes via
`API_FUEL_TO_TABLE_32`/`API_FUEL_TO_TABLE_12` → Table-code 3 (10.30 /
9.46 p/kWh). NOT the legacy "LPG bottled": 5 entry — API code 5 =
anthracite, and `canonical_fuel_code` resolves the same-valued Table-32
code 5 to anthracite (3.64 p/kWh), so a 5 here mis-prices the dwelling
as cheap solid fuel (verified: a 5 mapping moved SAP the WRONG way,
42.33 → 45.11; code 3 moves it to -6.40 vs the worksheet's -6.6499).
Also add 3 to `_GAS_LPG_MAIN_FUEL_CODES` so the §15.0-lodged bottled-LPG
water fuel is adopted as the boiler's space-heating carrier (priority 1)
instead of the meter flag.
Effect: main_fuel_type=3 (bottled LPG) and water_heating_fuel=3 (was
None). Mains-gas certs still → 26 (full regression suite green bar the 3
pre-existing unrelated fails); the MissingMainFuelType tripwire still
fires for genuinely-undeterminable carriers.
Spec: SAP 10.2 Table 12 / RdSAP 10 Table 32 (PDF p.95) — bottled LPG
main heating fuel code 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the `gas_boiler_upgrade` branch to `report._triggers_for`, mirroring the
generator's eligibility guard so a cohort report explains why the boiler upgrade
fired: the wet-boiler SAP code, the mains-gas connection that makes the gas
end-state installable, and the cylinder presence that shapes the bundle (combi
vs regular + cylinder fixes).
No golden API cert selects the boiler upgrade (it competes with — and on houses
loses to — the ASHP bundle within the one heating Recommendation), so the branch
is covered by a direct `_triggers_for` unit test, following the repo pattern for
testing internal helpers (cert_to_inputs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pin the coal-boiler-with-cylinder upgrade and add the `boiler_flue_type`
end-state field. A solid-fuel (coal) boiler (fuel 11, SAP code 153) on a
mains-gas street converts to a gas condensing boiler (fuel 11->26, code 102) —
the non-gas->gas path for a solid-fuel system, eligible because code 153 is in
the wet-boiler solid-fuel range 151-161 and mains gas is present.
New `boiler_flue_type` HeatingOverlay field, routed to main_heating_details[0]
and set to 2 (room-sealed/balanced) on both boiler shapes: every relodged after
lodges flue type 2, but coal's before lodged none. The field is SAP-inert (the
cascade score is unchanged by it), so it is written purely for end-state
fidelity — the overlay now represents the installed condensing boiler's flue.
Validated via the overlay-equality unit tests.
The coal after predates the user-locked "always add a cylinder thermostat when
absent" rule, so it stale-lodged thermostat 'N'; the pin corrects it to the
rule's end-state 'Y' in-test (the gas with-cylinder after got the same
correction by re-lodging). The cylinder is already 80 mm insulated, so the
jacket is skipped and only the thermostat is added; controls (2106) are
unchanged. Cascade-pinned delta 0 (SAP/CO2/PE).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`_elmhurst_dwelling_type` derived a flat's roof exposure from
`room_in_roof is not None`, so a top-floor flat whose roof is a plain
external "PS Pitched, sloping ceiling" (no room-in-roof) fell through to
"Mid-floor flat". The cascade's `_dwelling_exposure` then treats a
mid-floor flat's roof as a party ceiling (RdSAP 10 §5 / §3 — party
surfaces carry no heat loss) and drops the entire roof term: cert
001431's 105 m² roof at U=2.3 = 241.68 W/K (30) vanished, collapsing
(33) fabric heat loss 320.06 → 78.38 and over-rating SAP by ~5 points
(on top of the age-band roof-U bug — see prior commit).
Read the roof TYPE instead — the dual of the floor's "Another dwelling
below" signal. A flat's roof is a party ceiling only when its Elmhurst
code is S / A / NR (Same/Another dwelling or Non-residential space
above); F / PN / PA / PS are exposed external roofs, so the dwelling is
on the top storey. `has_exposed_roof = room_in_roof present OR
_elmhurst_roof_is_exposed(roof)` — which is exactly what the function's
own docstring already described as the intent ("RR present or external
roof"), now implemented.
With both upstream fixes the full chain (Summary PDF → extractor →
mapper → cert_to_inputs → calculator) reproduces the worksheet's §11a
unrounded SAP 56.3649 at abs < 1e-4, with (30)/(33)/(37) matching to
the decimal. Only flat fixture reclassified; 000784 (top-floor, RR) and
000910 (ground-floor) unchanged. Regression suite green bar the 3
pre-existing unrelated fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Elmhurst Summary §3.0 "Date Built" lodges the per-building-part age
bands; the Main row reads "Main Property" / "C 1930-1949". But "Main
Property" ALSO heads the §4.0 Dimensions table, so the global
`_str_val("Main Property")` collides with it: when pdftotext renders
"3.0 Date Built:" glued onto its "Main Property" row token on one
layout line (as the recommendation worksheets do), the first standalone
"Main Property" match is the §4 dimensions header — returning its next
token "Floor" as the "age band".
That garbage age propagated to `u_roof`: for a "Pitched, sloping
ceiling" (PS) roof with no lodged insulation thickness, `u_roof` returns
the spec uninsulated U=2.3 for the correct age C but U=0.4 for the
unparseable "Floor" — collapsing the roof heat-loss term and inflating
SAP by ~14 points on the affected cert.
Scope the read to the Date-Built block (between "3.0 Date Built" and
"4.0 Dimensions") and take the first age row — a line beginning with a
single A-M band letter + space ("C 1930-1949", "A before 1900",
"J 2003-2006"). Building-part name rows never start that way, and the
Main row precedes any extension / room-in-roof rows.
Regression: full sap10_calculator + documents_parser suite green bar the
3 pre-existing unrelated fails (2 stone-wall U tests, test_total_floor_
area); the multi-bp / "A before 1900" fixtures (000516, 001431_case*,
6035) keep their age bands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more boiler-upgrade cascade pins, validating the existing generator across
fuels and cylinder states (no source change):
- oil combi: an oil boiler (fuel 28, code 130) on a mains-gas street converts to
a gas condensing combi (fuel 28->26, code 104). Proves the non-gas -> gas
conversion gated on a mains-gas connection (ADR-0024 revised).
- already-insulated cylinder: a gas boiler heating a pre-jacketed cylinder
(type 2 / 80 mm, no thermostat) gets a new boiler + a thermostat, with the
jacket NOT re-applied. Proves the cylinder path's skip-jacket branch against a
real cert. (Sourced from an LPG re-lodgement whose fuel the Summary mapper
reads as mains gas 26 — a separate LPG fuel-mapping gap, noted in the test.)
Both pin delta 0 (SAP/CO2/PE) against the relodged after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the gas-boiler-upgrade Option to combi (no-cylinder) dwellings and add
the controls upgrade shared by both boiler shapes. A dwelling has a cylinder or
it does not, so the one `gas_boiler_upgrade` Option is shaped per dwelling:
- no cylinder -> a gas condensing combi (Table 4b code 104), no cylinder fields
touched;
- a cylinder -> a regular boiler (code 102) heating it, with the conditional
cylinder jacket/thermostat (slice 1).
Controls: bring an inadequate boiler control up to full programmer + room
thermostat + TRVs (SAP 10.2 Table 4e Group 1 code 2106). "Inadequate" = the
Group-1 codes with NO room thermostat (2101, 2102, 2107, 2108, 2109, 2111) —
these lack boiler interlock (Table 4c(2) / footnote c) p.171), so adding a room
thermostat genuinely improves SAP. Room-thermostatted (2103/2104/2105/2106/2113)
or better zone controls (2110/2112) are left unchanged — never downgraded, so
no phantom uplift. The with-cylinder cert (control 2106) is therefore untouched
and its pin still holds at delta 0.
Validated by the combi before/after re-lodgement (cert 001431, gas boiler
upgrade - no cylinder): control 2111 "TRVs and bypass" -> 2106, fan flue
False->True, SAP code 112 -> 104. Cascade-pinned delta 0 (SAP/CO2/PE). Removed
the slice-1 placeholder test asserting no boiler Option fires without a cylinder
(the combi Option now correctly fires there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the first boiler-upgrade option to the single "Heating & Hot Water"
Recommendation (ADR-0024 expansion): a dwelling whose existing wet boiler heats
a hot-water cylinder is offered a new gas condensing boiler, with the cylinder
jacketed when under-insulated and given a thermostat when absent. One competing
Option (the Optimiser picks <=1), folded into one composite Plan line.
The end-state is read from the Elmhurst before/after re-lodgements (cert 001431,
gas boiler upgrade - with cylinder), which REVISE ADR-0024:
- Target is always a gas condensing boiler, not fuel-preserving: every after
lodges fuel 26. Gas->gas always; a non-gas wet boiler ->gas only with a
mains-gas connection; electric boilers are left alone (electrification is the
upgrade path). Eligibility = wet-boiler SAP code (Table 4a/4b 101-141 /
151-161 / 191-196) + not an electric boiler + mains gas present.
- End-state is a Table 4b SAP code, not a PCDB index: code 102 (regular boiler
+ cylinder). The calculator derives the condensing seasonal efficiency from
the code, so no efficiency input exists or is needed.
- A modern condensing boiler has a fanned flue: the after flips
`fan_flue_present` False->True on every cert (SAP 10.2 Table 4f flue-fan +
the Table 4b condensing-efficiency basis). Added as a new HeatingOverlay
field, routed to main_heating_details[0].
- Cylinder thermostat is always added when absent (user-locked); the jacket is
the 80 mm `cylinder_insulation_type=2` end-state, applied only when the
cylinder is below 80 mm (never downgrading a better one). Both are conditional
per-dwelling components, not a frozen overlay.
Cascade-pinned delta-0 (SAP/CO2/PE) against the relodged after via
`_assert_overlay_reproduces_after`. NB the absolute SAP on this dwelling is
subject to a separate Summary-path mapper roof-fidelity gap (we read the roof
better-insulated than Elmhurst, scoring ~75 vs the printed 56); the gap is
identical on before+after (the boiler measure never touches the roof) so it
cancels and the pin still proves the exact heating field-delta. Tracked on the
calculator branch.
Wires the new `gas_boiler_upgrade` MeasureType through contingencies (0.26),
the offline sample catalogue, the catalogue-coverage list, and the ARA
first-run integration seed (the option fires on any mains-gas boiler+cylinder
dwelling).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session 9 ran five independent data-driven audits (profiler, dropped-field scan,
CO2/PE reconciliation, cross-provider LIG parity, HW-demand reconciliation) — all
converged on diffuse remaining gap — and shipped glazing Table-24 (+16 certs) +
HW-only heat-network DLF, taking 54.90% -> 56.8% within-0.5. The data-driven seam
is exhausted; session 10 switches to worksheet-level ground truth via the
summary-report-based per-cert audit. New agent prompt at HANDOVER_SUMMARY_AUDIT.md
with method, starter candidate certs, ruled-out list, and conventions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>