Document oil and solid-fuel boiler archetypes (ADR-0067)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-24 09:09:38 +00:00
parent 6f8724aa04
commit e0478cfa5d

View file

@ -0,0 +1,130 @@
# Oil and solid-fuel boiler archetypes, with the fuel resolved at resolution not in the classifier
## Status
accepted
## Context
A portfolio-796 re-audit found ~43 properties whose `main_heating` override is a
**gas** boiler archetype (`Gas boiler, regular/combi`) sitting on a **non-gas**
`main_fuel` (oil 24+3, dual-fuel 7, house coal 5, wood logs 4). Same root cause as
the electric-boiler cohort (ADR-0052) one fuel over: the landlord's heating
description is a **fuel-agnostic** "Boiler … Regular/Combi Boiler", and with no
oil/solid wet-boiler member in `MainHeatingSystemType` the LLM funnelled them all
onto the gas archetype. LPG-on-gas is deliberately excluded — RdSAP models LPG on
the gas archetype, so those are correct.
The app side is ready (`assessment-model#455` adds the members to
`MainHeatingSystemValues`; migration 0279), inert until this repo lands the
counterpart (lock-step, ADR-0002).
The issue framed the fix as "make the classifier resolve the boiler description
**using the fuel column**". That conflates the *rule* with the *layer*: the
`ColumnClassifier` port classifies a **deduped, portfolio-wide set of description
strings** (`classify(descriptions: set[str]) -> {description: archetype}`), with
no access to another column, and stores the result keyed by description. Fuel
varies **per property**, not per description — so two dwellings sharing the
description `"regular boiler"` on different fuels cannot map to different
archetypes in that model. A fuel-aware *classifier* would need a composite
`(description, fuel)` key threaded through the port, the orchestrator dedup, and
the description-keyed override table — the first cross-column classifier in the
system.
But `ResolvedPropertyOverrides` already carries every override component for a
property together (`main_heating_system` **and** `main_fuel`), so the fuel is in
hand at **resolution** — where `overlays_from` builds the overlays.
## Decision
Add three members to `MainHeatingSystemType` (byte-for-byte with #455):
```
Oil boiler, regular
Oil boiler, combi
Solid fuel boiler
```
**1. Overlay — model them on the gas-boiler pattern** (a dedicated path that
forces a complete, coherent companion set; ADR-0048/0052/0053). Representative
SAP codes map to the modern/condensing variant (AG efficiency deferred, as for
gas):
| archetype | SAP code | fuel | fan flue | cylinder | control | category | meter |
|---|---|---|---|---|---|---|---|
| Oil boiler, regular | 127 (condensing oil, 84%) | oil (28) | yes | yes | 2106 | 2 | Single |
| Oil boiler, combi | 130 (condensing combi oil, 82%) | oil (28) | yes | no | 2106 | 2 | Single |
| Solid fuel boiler | 151 (manual-feed independent, 0.60) | house coal (33) | no | yes | 2106 | 2 | Single |
- **Control 2106** (full modern), **not** the electric-boiler's conservative 2101
— decided with Khalim: model the boiler modern, exactly as the gas archetype
does (the whole install is treated as a modern condensing system).
- **`gas_connection_available` is left unset (`None`)**, not forced. Unlike a gas
boiler (which *requires* the connection, so forces `True`), an oil/solid boiler
has no such requirement — a `None` overlay field inherits the lodged EPC's
value ("default to the existing EPC"), and an absent lodgement reads as no gas
main. Forcing `False` would wrongly erase a real gas cooking connection.
- **Solid fuel is single-member, conservative code 151** (0.60, the lowest-
efficiency independent solid-fuel boiler). The `main_fuel` override drives
carbon, but it does **not** change `sap_main_heating_code`, so one member fixes
one efficiency: 151 under-credits a wood dwelling (its true 155 is 0.70) rather
than over-crediting a coal one — the safe direction. Splitting into
mineral/biomass sub-members is deferred until demand and app-side members
justify it. `main_fuel = house coal` is the coherent default, always overridden
by the property's actual `main_fuel` (last-wins).
**2. Fuel-awareness is a pure fuel-join at *resolution*, not a classifier
change.** A function `resolve_boiler_archetype(heating_value, fuel_value)`
re-points a **gas** wet-boiler archetype by the property's `main_fuel` family
(reusing the existing `_FUEL_FAMILY` map): oil → `Oil boiler, regular/combi`
(preserving regular/combi); solid (coal/wood/dual) → `Solid fuel boiler`;
gas/LPG/electric/none → unchanged. `overlays_from` applies it before building the
`main_heating_system` overlay, reading the sibling `main_fuel` row. The LLM
classifier and its deduped port are **untouched**.
This is the same rule the issue's per-property backfill describes, so
recurrence-prevention and the 43-row correction are **one rule**. It also fixes
modelling for the existing 43 at model-time (their stored `Gas boiler` snapshot
re-points to `Oil`/`Solid` when scored) — the persisted-label backfill (for UI
truthfulness) is a separate, deferred step.
**Composition (unchanged, ADR-0041):** the archetype's forced companions are
*defaults*; `main_fuel` is in `_APPLY_LAST`, so a fuel override applies **after**
the archetype and wins on `main_fuel_type` (never on `sap_main_heating_code`) —
efficiency from the boiler code, carbon from the fuel. No contradiction by
construction: the join *picked* the archetype *from* the fuel. Category/control
stay archetype-forced (no override column, and a stale inherited one silently
mis-bills, ADR-0048); a future user-tunable control would add its own column and
join into `_APPLY_LAST`, not change this.
## Considered options
- **Fuel-aware classifier via a composite `(description, fuel)` key.** Rejected:
wrong layer — forces a schema + shared-port change and the first cross-column
classifier, when `ResolvedPropertyOverrides` already hands resolution both
values for free.
- **Split `Solid fuel boiler` into mineral vs biomass now.** Rejected: doubles the
enum + app-side members for a 16-row cohort; the conservative single code (151)
bounds the error to the safe (under-credit) direction. Revisit on demand.
- **Conservative control 2101 (the electric-boiler choice).** Rejected: these are
wet boilers modelled modern like gas (decided with Khalim); 2101 is for the
unknown-vintage electric-boiler case (ADR-0052).
- **Force `gas_connection_available=False`.** Rejected: erases a real gas cooking
connection; inherit the lodged EPC instead (`None`).
- **Re-point only at a one-off backfill.** Rejected alone: leaves the *engine*
emitting gas-on-oil for every future portfolio; the resolution join prevents
recurrence and the backfill becomes cosmetic (label truthfulness).
## Consequences
- The 43 rows score on the correct boiler efficiency + fuel at model-time with no
data migration; the persisted-snapshot/vocab backfill (label truthfulness) is a
deferred follow-up, after #455 is live.
- `resolve_boiler_archetype` is extensible to the planned `Boiler, unspecified
fuel` member — its resolution order (fuel override → EPC → default gas) is the
same shape; the EPC-fuel fallback (resolution has no EPC today) lands with that
member.
- Efficiency AG bands remain deferred for these as for gas (modern/condensing
code); solid sub-fuel efficiency (155/153) is bounded by the conservative 151.
- Lock-step obligation (ADR-0002): the three member strings are the contract with
#455 / migration 0279.