Regression guard: category 8 + control 2701 keep the ADR-0048 coherence
invariant satisfied for SAP codes 421/422/424.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PropertyAudit gained three required cylinder fields; the pre-existing
divergence/goal-shortfall tests construct it via _make_audit, which
wasn't updated.
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>
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>
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>
A landlord glazing split like "4% Double glazing 2002 or later, 96% Single
Glazing" is a near-uniform *single*-glazing dwelling, but the LLM classifier
latched onto the 4% minority and wrote a "Double glazing…" override — over-
crediting the dwelling.
glazing_mix_guard rescued genuine mixes (no type >= 90%) to MIXED but returned
None for near-uniform splits, trusting the LLM to have applied the dominant type.
It hadn't. Single glazing is era-free, so a dominant-single split is fully
determined: the guard now returns SINGLE for it (dominant double/triple still
carry era ambiguity, so they stay the LLM's job — the existing "96% double" case
still defers).
Adds a portfolio-scoped backfill (reuses the same guard, so it can't drift from
the live path) and applied it to portfolio 796 (Hyde): 115 glazing overrides
corrected from a spurious Double back to Single.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>