ADR-0048 records why a system-replacing heating override must emit a
complete companion set — category and control archetype-forced for
coherence, fuel and meter deferred where multiple values are valid, and
an unmapped companion logged rather than silently inherited. Adds the
Heating Companion Set glossary term to CONTEXT.md.
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>
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>
`_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>
Pins the engine's baseline SAP (54) for this electric-room-heater +
gas-multipoint end-terrace ground-floor maisonette (portfolio 814 pid
742121) against accredited Elmhurst RdSAP-10 (worksheet SAP 53, within
1 point), with the Elmhurst Input Summary + SAP Worksheet PDFs saved as
evidence alongside the cert's epc.json.
Companion to the HHRSH storage-heater off-peak-pricing fix PR: this
baseline pin confirms the calculator is faithful on the as-lodged
dwelling; that PR fixes the HHRSH recommendation overlay (engine 48->73
vs Elmhurst 71).
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>
39 Hyde-796 property_overrides rows ('100% secondary glazing (sap 9.94)')
plus the classifier-cache row; the cache phase self-gates on the
'Secondary glazing' pgEnum label landing (assessment-model#345).
Co-Authored-By: Claude Fable 5 <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>
`plan-stops-short-of-goal` fires on anything below the goal band — 322/338
on portfolio 814, mostly flats a single band short of B, which drowns the
real hits. Add a sharper, band-keyed check that isolates the genuinely-
stuck tail: a plan that leaves the dwelling still rated E/F/G with the band
UNCHANGED from baseline on an unlimited-budget scenario — the "E->E, no big
movement" worklist. On portfolio 814 / scenario 1271 it flags just 4 (pids
742121, 742210, 742265, 742347) vs 322. No SAP-points threshold; the
strictly-worse case is left to plan-below-baseline-band so the two
partition cleanly.
Also refresh the find-weird-recommendations skill's Phase 4 catalogue:
register the new check in Phase 1, correct the stale 742121 note (it is an
electric maisonette whose HHRSH IS offered but scores -6.3 SAP, so the
Optimiser correctly drops it — not the old mains-gas story), and add the
742265 community-flat findings (HHRSH/ASHP gated out by heat-network
topology; vaulted-ceiling roof with ND insulation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A pre-SAP10 RdSAP cert that omits per-window records lodges glazing as a
`multiple_glazing_type` code plus a `window.description` and a
`multiple_glazed_proportion`. When the numeric code is the "ND" (Not
Defined) sentinel, the reduced-field synthesis defaulted to the DG-modal
code 2 (double) — ignoring the two register signals that say otherwise.
`_normalize_sap_schema_16_x` already honours an explicit "Single glazed"
description for 16.x certs (rewriting multiple_glazing_type -> 5), but that
rule was never propagated to the 17.0 / 17.1 / 18.0 / 19.0 / 20.0.0
synthesis seams — they all blindly applied ND -> 2. So a genuinely
single-glazed dwelling was:
1. scored as double-glazed, overstating its baseline SAP, and
2. hidden from the glazing generator (which upgrades only single-glazed
windows), producing no glazing recommendation.
Reproduced on cert 2780-3922-3202-6042-9200 (uprn 10033526327), a
single-glazed community-heated flat lodging multiple_glazing_type="ND",
window "Single glazed", multiple_glazed_proportion 0 — mapped to four
double-glazed windows, no glazing measure offered.
Extend the 16.x rule to all five reduced-field seams via one shared
resolver: on the ND fallback, when the description says "single" or the
multiple-glazed proportion is 0, synthesise single glazing
(`_api_cascade_glazing_type(5) == 1`, SAP10 cascade single, Table 24
U~4.8) — the exact single code the glazing generator's {1, 15} set
matches — else keep each seam's DG-modal default. Refines ADR-0028's ND
handling.
Tests: all five seams synthesise single from a "Single glazed" + 0%
proportion cert; the double-glazed control keeps the DG-modal default;
plus unit pins on the resolver.
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>