diff --git a/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md b/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md new file mode 100644 index 000000000..9f579d228 --- /dev/null +++ b/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md @@ -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 `", "` +**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 `": "` 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. diff --git a/tests/domain/epc/test_water_heating_overlay.py b/tests/domain/epc/test_water_heating_overlay.py index a1955aa73..58ba813f7 100644 --- a/tests/domain/epc/test_water_heating_overlay.py +++ b/tests/domain/epc/test_water_heating_overlay.py @@ -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,