Resolve a from-main wood-logs water-heating override to the wood fuel 🟥

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-01 12:59:56 +00:00
parent 9db8b60a54
commit c24e465974
2 changed files with 136 additions and 0 deletions

View file

@ -0,0 +1,123 @@
# Water-heating override: community DHW is scored by its heat-network main, individual DHW by its own fuel
## Status
accepted
## Context
A **Landlord Override** for water heating names a canonical `"<system>, <fuel>"`
**Recognised Internal Description** (`WaterHeatingType`), which the water-heating
**Simulation Overlay** (`water_heating_overlay_for`) decomposes into the SAP
`water_heating_code` (Table 4a system) + `water_heating_fuel` int codes the
calculator reads (ADR-0032). The taxonomy was **too small** — it had no biomass /
wood / dual-fuel / biodiesel water fuel — so the LLM classifier funnelled every
such description into the nearest bucket, **`"From main system, house coal"`**
(fuel 33). An audit found **131** `property_overrides` rows on that value; only
**3** are genuinely house coal. This is the same missing-archetype defect as
ADR-0041 (heating) — a dumping ground standing in for a gap in the taxonomy.
Investigating the mis-score revealed the fix is **not** a uniform "swap coal for
biomass". The 131 rows split by whether the dwelling's **main heating is a heat
network**, because the calculator scores hot water differently in the two cases:
- **Community main** (SAP Table 4a 301/302/304). When DHW inherits from the main
(`water_heating_code` ∈ {901, 902, 914}) and the main is a costable heat
network, `cert_to_inputs._is_community_heating_hw_from_main` computes the hot-
water CO2/PE/cost from the **main's** heat-network fuel (via
`_heat_network_community_fuel_code`, e.g. biomass-community fuel 31 → Table 12
code 43), scaled by heat-source efficiency — and **ignores
`water_heating_fuel` entirely**. Verified: HW CO2 is `0.036` whether the water
fuel is 33 (coal) or 31 (biomass). So the **84** community rows already score
their DHW as biomass-heat-network; the `"house coal"` value is a **cosmetic
display lie**, not a scoring error.
- **Individual main** (a real boiler / room heater). DHW inherits the explicitly
lodged `water_heating_fuel`, so `33` scores as **house coal (0.395 kgCO2/kWh)**
a genuine ~14× carbon overstatement for a wood/biomass dwelling. **47** rows are
mis-scored this way (20 wood logs, 4 dual fuel, 23 "electric immersion
assumed"); **3** genuine house-coal rows score correctly.
The community-fuel collision (API enum 30/31/32 collide in value with the Table-32
electricity tariff codes 30/31/32) is **already handled** at the fuel-type
boundary, gated on heat-network context (`_heat_network_community_fuel_code`) — a
deliberate design that must **not** be globally canonicalised, because the cascade
uses bare code 30 internally as standard-rate grid electricity
(`table_32.py` comment). So **no calculator change is required**: community DHW is
scored correctly *given the coherent community main the override data already
carries* (`main_heating_system = "Community heating, boilers"`,
`main_fuel = "biomass (community)"`).
## Decision
Complete the water-heating taxonomy and reclassify the dumping-ground rows to
**faithful** archetypes. No calculator change.
1. **Add the missing water archetypes**, each mapping to its RdSAP
`water_heating_fuel` code in `water_heating_overlay._WATER_HEATING_CODES`:
- `"From main system, wood logs"` → (901, **6**)
- `"From main system, biomass (community)"` → (901, **31**)
- `"From main system, dual fuel (mineral and wood)"` → (901, **9**)
- `"From main system, biodiesel (community)"` → (901, **34**)
2. **Community DHW maps to its community fuel (31 / 34) and is score-neutral.**
Because the community main drives the HW score, mapping the 75 community
biomass/biodiesel rows to their honest community fuel corrects the **displayed
value** without changing the score — we do **not** flatten them to individual
wood (6), which would be a semantic lie. Correct labelling, zero scoring risk.
3. **Individual DHW maps to its real fuel and moves the score.** Wood-logs → 6
(0.028), dual fuel → 9, off the coal 0.395 — the genuine fix for the 47
mis-scored individual-main rows.
4. **"No hot water system present electric immersion assumed" → the existing
`"Electric immersion, electricity"` archetype (903, 29).** The description
states the assessment's own assumption; honouring it is faithful. This corrects
the 23 individual-main rows (coal → electricity) and re-labels the 9
community-main rows off the accidental biomass score onto the assessed
immersion — a small, defensible score change, surfaced here as the one non-
cosmetic community reclassification.
5. **Genuine `"From main system, house coal"` (3 rows) is retained** — the taxonomy
keeps house coal for the dwellings that really burn it.
6. **Deterministic guard + reclassify, TEXT-first / pgEnum-deferred.** A pure
`water_heating_guard(description) -> Optional[WaterHeatingType]` (mirroring
`glazing_mix_guard`) recognises the structured `"<system>: <fuel>"` phrasings
and is wired via `GuardedColumnClassifier(guard, fallback=ChatGpt…)`; varied
phrasings stay with the LLM. A one-time `scripts/reclassify_water_heating.py`
reuses the same guard as its decision function (so backfill and live path can't
drift), updates `property_overrides.override_value` (TEXT) unconditionally, and
defers the `landlord_water_heating_overrides.value` pgEnum-cache write for any
value the live enum doesn't yet carry — printing the deferred set for the FE
owner (the `main-heating-system-pgenum-is-fe-owned` constraint).
## Consequences
- **FE-owned pgEnum additions (Dan).** Four new `landlord_water_heating_overrides`
values: `"From main system, wood logs"`, `"From main system, biomass
(community)"`, `"From main system, dual fuel (mineral and wood)"`, `"From main
system, biodiesel (community)"`. The TEXT read path is fixed immediately; the
cache write for these four defers until the migration lands.
- **No `cert_to_inputs.py` change**, so no risk to the thousands of lodged certs
that carry water fuel 31/33/34 directly. (A separate latent question — whether a
lodged *individual*-main cert with `water_heating_fuel = 31` or `34` mis-scores
as electricity because those codes are un-normalised outside heat-network
context — is **out of scope** here and tracked separately; it does not affect any
override row, all of which resolve correctly under this decision.)
- Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md)
(complete taxonomy, no dumping ground) and [ADR-0035](0035-coherent-heating-system-synthesis.md)
(the community main and its DHW cohere — here the coherence is what makes the
community rows score correctly). The visible baseline shift on the 47 individual
rows is correct Rebaselining ([ADR-0039](0039-override-aware-rebaselining.md)).
### Alternatives rejected
- **Flatten community biomass to individual wood (code 6).** Scores ~identically
(0.028 vs 0.029) but mislabels a district-heating dwelling as burning its own
wood — a semantic lie for no benefit, since the community main already scores it.
- **A calculator "collision fix" normalising 31/33/34 globally.** Rejected: 33 is
already normalised (`canonical_fuel_code` 33→11); 31/32 must stay un-normalised
outside heat-network context or genuine grid electricity mis-prices as community
waste (`table_32.py`). No override row needs it.
- **Defer the community rows to `None`.** Rejected per ADR-0041 dec-4 (community is
modelled, not deferred) — and unnecessary, since they already score correctly.

View file

@ -73,6 +73,19 @@ def test_more_water_heating_combos_decode_to_their_codes(
assert simulation.heating.water_heating_fuel == fuel
def test_from_main_wood_logs_decodes_to_the_wood_fuel_not_house_coal() -> None:
# Act
simulation = water_heating_overlay_for("From main system, wood logs", 0)
# Assert — wood logs is RdSAP water_heating_fuel 6, from main system (901).
# A wood-fired dwelling's DHW must not fall to house coal (33), which scores
# ~14x the carbon (ADR-0043).
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.water_heating_code == 901
assert simulation.heating.water_heating_fuel == 6
@pytest.mark.parametrize("water_heating_value", ["Unknown", ""])
def test_unresolvable_water_heating_produces_no_overlay(
water_heating_value: str,