Sizing selects the aroTHERM plus rung nearest PSR ~0.8 (0.8 x design heat
loss) — the Appendix-N efficiency peak, reproducing the pump a real
installer fits — rather than the MCS PSR>=1.0 capacity target, which
oversizes. Validated against the relodged Elmhurst ASHP cert (delta 0
held at the 5 kW Vaillant); the two self-snapshot pins re-pin to their
correctly-sized larger pumps, and the orchestrator/harness thread the
calculator's design heat loss so production sizes to the dwelling. Manual
SapResult test stubs carry the new design_heat_loss_kw field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_ashp_option threads its sized design heat loss into ashp_cost_inputs so
the cost band and the pump's efficiency record share one figure — a leaky
dwelling's larger pump is priced on the matching band.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_ashp_option now selects the aroTHERM plus PCDB record matching the
threaded design heat loss (floor-area proxy fallback), so the overlay's
efficiency anchor is sized to the dwelling instead of a fixed 5 kW unit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
design_heat_loss_kw = annual-average HLC x 24.2 K (SAP design temperature
difference) / 1000, the quantity Appendix N's PSR divides a heat pump's
rated output by — surfaced so the ASHP recommendation can size the pump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sizing ladder (Vaillant aroTHERM plus 3.5-12 kW PCDB records) picks
the smallest rung whose rated output meets the design heat loss, so SAP
10.2 Appendix N reads the heat-pump efficiency near its PSR peak instead
of collapsing on an undersized fixed unit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A system-replacing override whose category or control is not yet mapped
now logs an error naming the archetype and code, so the incomplete
companion set (which otherwise silently inherits the replaced system's
value) is visible for review — log-and-continue, matching flag_fuel_mismatch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Control 2601 (SAP Table 4e Group 6 room-heater, +0.3 C conservative
default) now overlaid for electric room heaters, matching the gas/oil/
solid siblings, so a replaced storage heater's manual charge control
2401 (+0.7 C) no longer survives and over-warms the modelled dwelling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_has_suspended_timber_floor_per_spec only read the Main bp's per-part
floor_construction_type lodgement; the gov-API mapper frequently leaves
that None even when the global epc.floors[].description carries an
explicit "Suspended, ..." observation. _main_floor_u_value already fell
back to this joined description for the U-value calc — the infiltration
rule did not, so a genuinely suspended-timber floor silently entered
(12)=0 instead of 0.2 unsealed. Extracted the shared
_effective_floor_description helper and added a tri-state resolver
(explicit lodgement -> description, excluding "not timber" -> Table 19
footnote-1 age-A/B default) so both paths agree.
Cert 100061275133 (Elmhurst-validated build, PR #1439 handoff): engine
77.24 -> 76.33, now an exact match to lodged (76). Corpus gauge 77.7% ->
77.8% within-0.5, MAE 0.637 -> 0.636; floors ratcheted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Category 10 (SAP Table 4a Room heaters) now overlaid for electric room
heaters (691-701), so a replaced storage heater's category 7 no longer
survives onto the effective cert and mis-bills via the Table 12a resolver.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field-level bias scan (scripts/corpus_1000/field_bias.py) flagged solid-brick +
internal-insulation certs (wall_con+ins=(3,3)) systematically under-rating
(median -0.40, n=18). Root cause: _insulation_bucket routed insulation that is
KNOWN PRESENT but with a thin lodged thickness (5-24mm, below the smallest
tabulated 50mm column) to bucket 0 (uninsulated, U 1.7) - WORSE than the
present-but-unknown case, which the RdSAP Table 6 footnote correctly routes to
the 50mm row. A known 10mm shouldn't read as higher-U than not knowing at all.
Now present insulation never falls to bucket 0; thin/unknown thickness both
round up to the 50mm row. Only genuinely absent insulation maps to 0.
Corpus cert 5117280 (10mm internal, no wall thickness -> bucket path): Δ -4.44
-> +1.08. Corpus MAE 0.641 -> 0.637, PE MAE 3.0 -> 2.9. Gauge 77.7% holds; 44
RealCert pins + 342 fabric tests green. TDD: 2 new unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A FLAT roof lodges its insulation thickness in the DEDICATED gov-EPC API
`flat_roof_insulation_thickness` field (e.g. "75mm"), leaving
`roof_insulation_thickness` None (that field is for pitched-loft joists).
`heat_transmission_from_cert` read only the latter, so a measured
flat-roof thickness was ignored and the roof billed at the uninsulated
age-band flat default (age E = 1.5) instead of its Table-16 insulated U.
Fixed by preferring `flat_roof_insulation_thickness` when the part is a
flat roof — the exact mirror of the existing rafter-thickness branch.
An "AB"/"NI" (as-built/unknown) value parses to None and keeps the
age-band default, unchanged; only measured thicknesses move.
PER-CERT ELMHURST VALIDATION (cert 47084930, top-floor flat, flat roof
75 mm): built on the lodged inputs in accredited Elmhurst RdSAP10-Online
(evidence saved: elmhurst_summary.pdf / elmhurst_worksheet.pdf). The
worksheet bills "insulated flat roof" at U 0.5 (floor 0.70 + wall 0.25
also matching the engine). The fix takes the engine roof 96.4 -> 32.1 W/K
(= 64.26 x 0.5, Elmhurst-exact), PE +47 -> +0.2, SAP 69.51 -> 74.34 =
lodged 74.
Gauge: within 77.3% -> 77.7%, SAP MAE 0.648 -> 0.641, CO2 0.074 -> 0.072,
PE 3.1 -> 2.97. Unit-pinned in test_heat_transmission
(flat_roof_insulation_thickness -> U 0.5); RealCertExpectation 47084930
= 74 (Elmhurst-validated). Also adds the build script build_47084930.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A hot-water cylinder lodging gov-API `cylinder_size=0` ("size not
determined", with has_hot_water_cylinder=true) resolved to a None volume
in `_cylinder_storage_loss_override` (which reads `_cylinder_volume_l_
from_code`, returning None for code 0). The subsequent `if volume_l is
None: return None` then DROPPED the cylinder's Table-2 storage loss
entirely — under-costing the DHW and over-rating the dwelling. Meanwhile
the Table-13 high-rate-fraction path already used `_hot_water_cylinder_
volume_l` (which defaults size 0 to 110 L), so the two DHW paths
disagreed on the same cylinder.
RdSAP 10 §10.5 Table 28 ("if the actual size is not determined, the size
is taken as according to Table 28") makes an unsized present cylinder the
110 L "Normal" baseline, which STILL incurs the storage loss. Fixed by
defaulting the storage-loss volume to 110 L for the explicit size-0 case.
Gated on the EXPLICIT 0 (not None): a full-SAP cert whose RdSAP
cylinder_size is simply unlodged (None, e.g. pinned 10091568921) keeps
its own cylinder handling rather than a forced 110 L RdSAP default.
7 corpus certs lodge cylinder_size=0 + has_cylinder=true. Gauge: within
77.0% -> 77.3%, SAP MAE ~flat (0.648), CO2 0.074 -> 0.073, PE 3.2 -> 3.05
(the previously-dropped storage loss now correctly counted in the demand
cascade). Unit-pinned in test_cert_to_inputs (storage loss non-None for
size 0); RealCertExpectation 200004017091 = 71.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A SOLID-BRICK or STONE wall lodging External/Internal insulation
(wall_insulation_type 1/3) but no measured insulation thickness (gov-API
"NI" -> None) was billed at its UNINSULATED §5.6/Table-6 U-value: the §5.8
added-insulation R-value adjustment gates on `insulation_thickness_mm > 0`,
so an unknown thickness fell straight through to the raw solid-wall U
(~1.4-1.7). This over-counted wall heat loss and under-rated the dwelling.
RdSAP 10 §5.4 (PDF p.32): "the U-value with additional insulation is based
on the thickness of insulation of mineral wool type (assume 100 mm if
thickness is unknown)". Apply that 100 mm default so the §5.8 Table-14
R-value fires. Scoped to solid brick / stone (the only constructions with
a §5.8 R-value path in u_wall) so cavity (composite path) and timber /
system-built walls are untouched.
Localised by deep-diving corpus cert 200004296092 (end-terrace, solid
brick 360 mm "with external insulation", NI): walls 101.7 W/K (U~1.40,
HLP 3.46 = absurd for an insulated wall) -> ~0.29; PE +59.6 -> ~0; engine
64.35 (Δ -6.65) -> 71.74 = lodged 71. ~8 solid-brick/stone corpus certs
carry this NI-thickness-with-insulation shape.
Gauge: within-0.5 held at 77.0%, SAP MAE 0.658 -> 0.647, CO2 0.074 ->
0.073, PE 3.2 -> 3.1 (floor ratcheted). Unit-pinned in test_rdsap_uvalues
(brick 360 mm external NI -> 0.29) + RealCertExpectation 200004296092 = 72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An instantaneous point-of-use electric water heater (WHC 907/909, no
cylinder) on an off-peak tariff was billed 100% at the off-peak LOW rate
by the generic electric-off-peak else-branch. But SAP 10.2 §12 (PDF line
2680) computes the off-peak on-peak HW proportion via Table 13, "dependent
on the total floor area and the CYLINDER size" — it presumes a stored-water
cylinder charged overnight. An instantaneous heater has NO cylinder, heats
on demand, and cannot shift to the off-peak window, so 100% of its
consumption bills at the HIGH rate (Table 12a WH high-rate fraction 1.0).
Both the SAP-cost rate (`_hot_water_fuel_cost_gbp_per_kwh`) and the
ADR-0014 / CO2-PE fraction (`_hot_water_high_rate_fraction`) fixed
consistently (low-rate scalar -> 7-hour high rate; fraction 0.0 -> 1.0).
Localised by deep-diving corpus cert 74061136 (HHR-storage mid-floor flat,
WHC 909): PE matched lodged (+1.6, roof+floor zero-loss) while SAP over-rated
+7.72 — the cost-only signature. Its DHW was ours £59 vs lodged £344 (5.8x);
the tariff was the whole gap. Fix: +7.72 -> -1.25 (residual is separate small
fabric). 7 corpus certs carry electric-instantaneous DHW on off-peak; the 3
outside 0.5 all move sharply inward (MAE the win, not within-0.5 crossings).
RdSAP 10 §12 tariff routing confirmed spec-correct (Unknown meter + storage
409 -> off-peak 7-hour, Rule 2) — the bug was the DHW rate, not the tariff.
Unit-pinned in test_cert_to_inputs; RealCertExpectation 74061136 = 72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gov-API `secondary_fuel_type` enums 5 (anthracite) / 9 (dual fuel) /
33 (coal) collide in VALUE with same-valued RdSAP-10 Table-32 codes for
OTHER fuels (5=bulk LPG, 9=LPG SC11F, 33=elec 10h-low). The main-fuel
boundary and the CO2/display `_secondary_fuel_cost_gbp_per_kwh` already
canonicalise these via `canonical_fuel_code`, but the SAP-DRIVING
`_fuel_cost` cascade had a SEPARATE inline secondary rate that passed the
raw enum straight to the price lookup — so the two paths diverged and the
cost path silently mis-priced.
Anthracite (enum 5) was billed at the bulk-LPG code-5 rate 12.19 p/kWh
instead of 3.64 — a 3x over-cost on the secondary that under-rated every
solid-fuel-secondary dwelling. Worst: corpus cert 100050355518 (semi,
2 extensions, anthracite room-heater secondary) lodged 36 / engine 20.8
-> 35.5. Its CO2/PE already matched lodged (the demand cascade was right)
— a pure cost-side gap, which is what localised it.
Fix: canonicalise `secondary_fuel` before the Table-32 lookup in
`_fuel_cost`, mirroring the main-fuel boundary. 55 corpus certs carry a
colliding secondary.
Gauge: within-0.5 75.5% -> 77.0%, SAP MAE 0.708 -> 0.668 (floors
ratcheted). The prior enum-9 test only guarded the display helper; new
test_fuel_cost_secondary_colliding_fuel_priced_at_canonical_rate pins the
_fuel_cost cost path directly. RealCertExpectation pinned for
100050355518 (36 = lodged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RdSAP 10 §5.11.4: a pitched roof lodging thickness "NI" with description
"...insulated (assumed)" was billed at the §5.11.4 observed-retrofit
50 mm row (U 0.68) while the SAME description lodged with "ND" fell
through to the Table 18 age-band default (0.40 for bands A-G). The
ND-vs-NI sentinel is lodging-software noise; "(assumed)" means the
insulation PRESENCE is an age-band assumption, not an observed
retrofit, so Table 18's "thickness cannot be determined" clause
governs both forms. The bare "insulated" description (observed
retrofit, no qualifier) keeps the 50 mm row.
Evidence: the 27-cert loc4+NI corpus cohort was systematically
under-rated (~-1 SAP; zero positive movers among "(assumed)" certs),
vs the 102-cert loc4+ND cohort already on Table 18 at ~0 bias.
Gauge: within-0.5 74.2% -> 75.5%, SAP MAE 0.721 -> 0.708, CO2 MAE
0.09 -> 0.074, PE MAE 3.5 -> 3.2 (floors ratcheted). Unit-pinned in
test_rdsap_uvalues (assumed -> Table 18; bare-insulated -> 0.68
regression guard); integration pin in test_heat_transmission updated;
RealCertExpectation pinned for 10094975827 (66 = lodged, was -1.87).
Ledger: C002 103001004 deferred pending Elmhurst arbitration
(single-immersion off-peak pricing + declared cylinder loss vs
Table-2); C006 quadruplet marked ⚠ (LIG-21.0 software honours lodged
0 extract fans literally vs the Elmhurst-validated 0=unknown->default
convention, worksheet case 46).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`_category_for` mapped room heaters / heat pumps / heat networks but
returned None for electric-storage-heater codes (401-409), so a storage
system override emitted `sap_main_heating_code=409` +
`main_heating_category=None` while `_control_for` right beside it correctly
set the storage charge control — an asymmetry. Map storage codes to
category 7 so the overlaid cert is internally consistent (code 401-409 <->
category 7), matching the control/fuel/meter companions the overlay drags.
Consistency follow-up to the modelling HHRSH off-peak-pricing fix (#1426):
that PR's Table 12a resolver already prices any storage-coded system
off-peak regardless of category, so this is NOT a live mispricing — it
removes a latent code<->category inconsistency that other category-keyed
logic (responsiveness, pumps/fans) could trip on.
Tests: storage/HHRSH overlay tests now also assert `main_heating_category==7`.
tests/domain/epc (347) + prediction/override/reclassify (118) green; source
pyright-clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The high-heat-retention storage-heater (HHRSH) recommendation was computed
as LOWERING SAP and wrongly rejected by the Optimiser, leaving electric-
room-heater flats/maisonettes stuck at band E with no sensible plan.
Root cause: `_HHR_STORAGE_OVERLAY` sets `sap_main_heating_code=409` (HHR
storage) + `meter_type="Dual"` but left `main_heating_category` at the
pre-existing value (10 = electric room heaters). The SAP 10.2 Table 12a
space-heating high-rate-fraction resolver classifies by category, so it
misread the off-peak storage heating as direct-acting electric (1.00
high-rate) and priced 100% at the 7-hour peak rate (15.29p) instead of the
storage off-peak split (0.00, ~5.5p).
Verified against accredited Elmhurst RdSAP-10 on UPRN 100020942571
(8 Booth Close, SE28 8BW): engine HHRSH SAP 48 (peak-priced, cost £1087)
vs Elmhurst 71 (cost ~£611); with the fix the engine gives 73 / £576.
Two-part fix (defence in depth, so no future overlay/mapper can
reintroduce it):
- `_HHR_STORAGE_OVERLAY` now sets `main_heating_category=7`, keeping the
overlaid EPC internally consistent.
- `_table_12a_system_for_main` now classifies electric storage heaters by
their Table 4a SAP code (401-409) as well as `category==7`, checked
before the room-heater branch, so a specific storage code is
authoritative over a stale/inconsistent category.
Tests: new regression `test_storage_heater_classified_by_sap_code_over_stale_category`;
updated the HHRSH overlay pin. Full accuracy corpus still passes (no regressions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses PR #1402 review: the guard only claimed dominant-single, leaving
the symmetric bug open — the LLM can flatten a dominant-double/triple split
onto its minority single (e.g. "96% Double glazing 2002 or later, 4% Single").
A dominant double/triple whose era is explicit ("pre-2002" / "2002 or later")
is just as fully determined as era-free single, so the guard now claims it via
_DOMINANT_MEMBER. Only a genuinely ambiguous era ("unknown age", unstated) still
defers to the LLM — the "96% double -> None" contract now holds solely for the
era-unknown case, not the era-stated one.
Backfill script reuses the same guard, so it now corrects any dominant split;
renamed reclassify_dominant_single_glazing.py -> reclassify_dominant_glazing.py
to match. Tests cover double/triple x pre-2002/2002-or-later and the still-
deferred unknown-age case; 14 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The landlord property-type description is a "<dwelling type>: <built form>: <floor>"
split whose leading token IS the dwelling type; the built-form tail is not. The
LLM occasionally over-read the tail and flipped the type — a handful of
"Bungalow: EndTerrace" / "Bungalow: MidTerrace" dwellings were stored as House.
Adds property_type_guard (claims the recognised leading token: House / Bungalow /
Flat / Maisonette / Park home; defers unrecognised phrasings to the LLM) and wires
property_type through a GuardedColumnClassifier, so the built-form tail can never
flip the type and the live path is deterministic.
Applied the scoped backfill to portfolio 796 (Hyde): 3 rows corrected from House
back to Bungalow. No enum migration needed — the targets are original members.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MainFuelType had no individual wood-logs member — only "biomass (community)" —
so the LLM classifier funnelled "Solid Fuel: Wood Logs" into the community fuel,
inventing a community heat network the dwelling isn't on (and mislabelling the
connection). main_fuel had no deterministic guard at all, so nothing caught it.
Verified against domain/sap10_calculator/docs/specs: RdSAP 10 Specification
Table 32 lists "wood logs" as a solid fuel (code 20, 0.028 kgCO2e/kWh); the
calculator's input scheme (the gov EPC API fuel enum) codes it 6 -> Table 32 20
(sap_efficiencies._API_TO_TABLE32), and water_heating_overlay already pins the
same fuel to 6. So _FUEL_CODES["wood logs"] = 6 is confirmed, not guessed.
Adds MainFuelType.WOOD_LOGS + the _FUEL_CODES entry, a main_fuel_guard mirroring
water_heating_guard (claims the "wood log" token; dual fuel keeps its own member
since it has no "wood log" substring), and wires main_fuel through a
GuardedColumnClassifier so the live path is deterministic.
Applied the scoped backfill to portfolio 796 (Hyde): 21 rows off
"biomass (community)" -> "wood logs". property_overrides (TEXT) only; the
classifier-cache pgEnum member is deferred to the FE Drizzle migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>