From e0478cfa5d452455dee4df7212b838f0ba9672c4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 24 Jul 2026 09:09:38 +0000 Subject: [PATCH] Document oil and solid-fuel boiler archetypes (ADR-0067) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...r-archetypes-resolve-fuel-at-resolution.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/adr/0067-oil-and-solid-fuel-boiler-archetypes-resolve-fuel-at-resolution.md diff --git a/docs/adr/0067-oil-and-solid-fuel-boiler-archetypes-resolve-fuel-at-resolution.md b/docs/adr/0067-oil-and-solid-fuel-boiler-archetypes-resolve-fuel-at-resolution.md new file mode 100644 index 000000000..dd8a67af0 --- /dev/null +++ b/docs/adr/0067-oil-and-solid-fuel-boiler-archetypes-resolve-fuel-at-resolution.md @@ -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 (A–G 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 A–G 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.