Merge pull request #1392 from Hestia-Homes/fix/1376-water-heating-house-coal

Reclassify water-heating overrides off the house-coal dumping ground (#1376)
This commit is contained in:
Daniel Roth 2026-07-01 15:19:39 +01:00 committed by GitHub
commit baaadcdf98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 560 additions and 2 deletions

View file

@ -22,6 +22,7 @@ from domain.data_transformation.guarded_column_classifier import (
GuardedColumnClassifier,
)
from domain.epc.property_overrides.water_heating_type import WaterHeatingType
from domain.epc.property_overrides.water_heating_guard import water_heating_guard
from domain.epc.property_overrides.wall_type import WallType
from domain.epc.property_overrides.wall_type_construction_dates import (
wall_type_construction_date_prompt_hint,
@ -174,8 +175,16 @@ def _build_columns(
"water_heating": lambda src: ClassifiableColumn(
name="water_heating",
source_column=src,
classifier=ChatGptColumnClassifier(
chat_gpt, WaterHeatingType, WaterHeatingType.UNKNOWN
# A biomass / wood / dual-fuel / biodiesel DHW description has no
# dedicated LLM target and was funnelled into "house coal"; the
# deterministic guard resolves the structured fuels (and the "electric
# immersion assumed" no-system case) and the LLM handles the rest
# (#1376, ADR-0043).
classifier=GuardedColumnClassifier(
guard=water_heating_guard,
fallback=ChatGptColumnClassifier(
chat_gpt, WaterHeatingType, WaterHeatingType.UNKNOWN
),
),
repo=LandlordOverridesRepository[WaterHeatingType](
session, LandlordWaterHeatingOverrideRow

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

@ -26,6 +26,18 @@ _WATER_HEATING_CODES: dict[str, tuple[int, int]] = {
"From main system, LPG (bulk)": (901, 27),
"From main system, bottled LPG": (901, 3),
"From main system, house coal": (901, 33),
# Biomass / wood water fuels routed off the house-coal dumping ground
# (ADR-0043), all from main (901) with their own RdSAP water_heating_fuel:
# 6 wood logs (biomass ~0.028 kgCO2/kWh vs house coal 33 ~0.395)
# 9 dual fuel (mineral + wood)
# 31 biomass (community) — scored via the coherent community main; the
# fuel is still emitted so an individual main would score biomass
# 34 biodiesel (community)
# A wood-fired individual-main dwelling's DHW must never fall to coal 33.
"From main system, wood logs": (901, 6),
"From main system, dual fuel (mineral and wood)": (901, 9),
"From main system, biomass (community)": (901, 31),
"From main system, biodiesel (community)": (901, 34),
"Electric immersion, electricity": (903, 29),
# "boiler/circulator for water heating only" — SAP Table 4a code 911 (gas).
"Gas boiler/circulator, mains gas": (911, 26),

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Optional
from domain.epc.property_overrides.water_heating_type import WaterHeatingType
def water_heating_guard(description: str) -> Optional[WaterHeatingType]:
"""Deterministically resolve the structured water-heating descriptions the LLM
funnelled into the ``"From main system, house coal"`` dumping ground (#1376).
Landlord water-heating descriptions arrive as ``"<system>: <fuel>"`` (e.g.
``"From main heating system: Wood Logs"``). Where the taxonomy had no biomass /
wood / dual-fuel / biodiesel water fuel, the classifier force-picked house coal,
scoring a biomass dwelling's DHW at ~14x its carbon (ADR-0043). This guard keys
on the **fuel token** and returns the faithful archetype the ``"Hot-water only
community scheme"`` system flattens to ``"From main system, "`` exactly as the
community mains-gas description already does. ``"No Heating or Hot Water"`` under
an ``"electric immersion assumed"`` system is the assessment's own immersion
assumption, so it resolves to ``ELECTRIC_IMMERSION``.
Returns ``None`` for fuels the LLM already classifies correctly (mains gas,
electricity, oil, ) and for varied / unstructured phrasings, so those still
reach the LLM classifier via ``GuardedColumnClassifier``. Order matters:
biodiesel and dual fuel are tested before the bare biomass / wood tokens they
contain.
"""
text = description.lower()
if "biodiesel" in text:
return WaterHeatingType.FROM_MAIN_BIODIESEL_COMMUNITY
if "biomass" in text:
return WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY
if "dual fuel" in text:
return WaterHeatingType.FROM_MAIN_DUAL_FUEL_MINERAL_WOOD
if "wood log" in text:
return WaterHeatingType.FROM_MAIN_WOOD_LOGS
if "house coal" in text:
return WaterHeatingType.FROM_MAIN_HOUSE_COAL
if "no heating or hot water" in text:
return WaterHeatingType.ELECTRIC_IMMERSION
return None

View file

@ -21,6 +21,10 @@ class WaterHeatingType(Enum):
FROM_MAIN_LPG_BULK = "From main system, LPG (bulk)"
FROM_MAIN_BOTTLED_LPG = "From main system, bottled LPG"
FROM_MAIN_HOUSE_COAL = "From main system, house coal"
FROM_MAIN_WOOD_LOGS = "From main system, wood logs"
FROM_MAIN_DUAL_FUEL_MINERAL_WOOD = "From main system, dual fuel (mineral and wood)"
FROM_MAIN_BIOMASS_COMMUNITY = "From main system, biomass (community)"
FROM_MAIN_BIODIESEL_COMMUNITY = "From main system, biodiesel (community)"
ELECTRIC_IMMERSION = "Electric immersion, electricity"
GAS_BOILER_CIRCULATOR_MAINS_GAS = "Gas boiler/circulator, mains gas"
UNKNOWN = "Unknown"

View file

@ -0,0 +1,148 @@
"""One-time re-classification of water-heating overrides funnelled into house coal.
#1376 / ADR-0043: with no biomass / wood / dual-fuel / biodiesel water fuel in the
taxonomy, the LLM classified those DHW descriptions (and the "no system / electric
immersion assumed" case) to ``"From main system, house coal"`` (fuel 33). On an
individual-main dwelling that scores hot water at ~14x the carbon of biomass; on a
community-main dwelling the score is already right but the stored value lies.
The live classifier now applies ``water_heating_guard`` deterministically (so new
intakes are correct); this fixes the rows written before it. The **same guard**
decides the correction here, so the backfill and the live path cannot drift.
Updates the TEXT ``property_overrides.override_value`` (what the modelling reads
the immediate fix) unconditionally. The ``landlord_water_heating_overrides.value``
classifier cache is a ``water_heating`` **pgEnum**; the four new archetypes are
FE-owned values, so their cache writes are **deferred** until the Drizzle migration
adds them (the Class-A/B pattern). ``Electric immersion, electricity`` and
``From main system, house coal`` are existing enum members and update the cache.
DRY-RUN BY DEFAULT: prints the counts it would change and writes nothing. Pass
``--apply`` to execute inside a transaction. Idempotent only rows whose stored
value differs from the target member are touched.
"""
from __future__ import annotations
import argparse
from collections.abc import Iterable
from sqlalchemy import Connection, text
from domain.epc.property_overrides.water_heating_guard import water_heating_guard
from scripts.e2e_common import build_engine, load_env
def water_heating_corrections(
stored: Iterable[tuple[str, str]],
) -> dict[str, str]:
"""``(description, stored override_value)`` → the faithful archetype value, for
the descriptions the water-heating guard resolves whose stored value differs.
Descriptions the guard defers (mains gas, electricity, ), genuine house-coal
rows, and rows already on the target are omitted, so re-running against
corrected data is a no-op."""
corrections: dict[str, str] = {}
for description, value in stored:
member = water_heating_guard(description)
if member is not None and value != member.value:
corrections[description] = member.value
return corrections
_DISTINCT = text(
"""
SELECT DISTINCT lower(original_spreadsheet_description) AS description,
override_value AS value
FROM property_overrides
WHERE override_component = 'water_heating'
"""
)
_OVERRIDES_UPDATE = text(
"""
UPDATE property_overrides
SET override_value = :new_value
WHERE override_component = 'water_heating'
AND lower(original_spreadsheet_description) = :description
AND override_value <> :new_value
"""
)
_OVERRIDES_COUNT = text(
"""
SELECT count(*) FROM property_overrides
WHERE override_component = 'water_heating'
AND lower(original_spreadsheet_description) = :description
AND override_value <> :new_value
"""
)
_CACHE_UPDATE = text(
"""
UPDATE landlord_water_heating_overrides
SET value = :new_value, updated_at = now()
WHERE lower(description) = :description
AND value::text <> :new_value
"""
)
_ENUM_VALUES = text(
"SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid "
"WHERE t.typname = 'water_heating'"
)
def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]:
"""Re-map house-coal water-heating overrides onto their faithful archetypes.
Returns the number of ``property_overrides`` rows found and the set of target
values the live ``water_heating`` pgEnum does not yet carry (cache-deferred
until the FE migration)."""
stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)]
enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)}
total = 0
deferred: set[str] = set()
for description, new_value in water_heating_corrections(stored).items():
params = {"description": description, "new_value": new_value}
total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0
in_enum = new_value in enum_values
if not in_enum:
deferred.add(new_value)
if apply:
conn.execute(_OVERRIDES_UPDATE, params)
if in_enum:
conn.execute(_CACHE_UPDATE, params)
return total, deferred
def main() -> None:
load_env()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--apply",
action="store_true",
help="execute the updates (default: dry-run, writes nothing)",
)
args = parser.parse_args()
engine = build_engine()
with engine.begin() as conn:
conn.execute(text("SET statement_timeout = 120000"))
total, deferred = reclassify(conn, apply=args.apply)
verb = "re-classified" if args.apply else "would re-classify"
print(
f"{verb} {total} water-heating override row(s) off the house-coal dumping "
"ground onto faithful biomass / wood / dual-fuel / biodiesel / immersion "
"archetypes (property_overrides / TEXT — what the modelling reads)."
)
if deferred:
print(
f"\n{len(deferred)} target value(s) NOT yet in the water_heating "
"pgEnum — their classifier-cache rows are deferred until the FE-repo "
"enum migration adds these members (property_overrides was still "
"updated, which is what the modelling reads):"
)
for value in sorted(deferred):
print(f" {value!r}")
if not args.apply:
print("\nDRY-RUN — nothing written. Re-run with --apply to execute.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,80 @@
from __future__ import annotations
import pytest
from domain.epc.property_overrides.water_heating_guard import water_heating_guard
from domain.epc.property_overrides.water_heating_type import WaterHeatingType
@pytest.mark.parametrize(
("description", "expected"),
[
# The biomass/wood family the LLM funnelled into "house coal" (#1376).
(
"From main heating system: Wood Logs",
WaterHeatingType.FROM_MAIN_WOOD_LOGS,
),
(
"From main heating system: Dual Fuel: Mineral and Wood",
WaterHeatingType.FROM_MAIN_DUAL_FUEL_MINERAL_WOOD,
),
# "Hot-water only community scheme" flattens to "from main", per the
# existing convention (community mains-gas already resolves that way).
(
"From main heating system: Biomass (Community)",
WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY,
),
(
"Hot-water only community scheme - boilers: Biomass (Community)",
WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY,
),
(
"Hot-water only community scheme - boilers: Heat from Boilers "
"using Biodiesel from any source (Community)",
WaterHeatingType.FROM_MAIN_BIODIESEL_COMMUNITY,
),
# "No Heating or Hot Water" under "electric immersion assumed" is the
# assessment's own immersion assumption — honour it (ADR-0043).
(
"No hot water system present - electric immersion assumed: "
"No Heating or Hot Water",
WaterHeatingType.ELECTRIC_IMMERSION,
),
# A genuinely coal DHW stays house coal.
(
"From main heating system: House Coal",
WaterHeatingType.FROM_MAIN_HOUSE_COAL,
),
],
)
def test_guard_resolves_the_structured_biomass_and_immersion_descriptions(
description: str, expected: WaterHeatingType
) -> None:
# Act
result = water_heating_guard(description)
# Assert
assert result is expected
@pytest.mark.parametrize(
"description",
[
# Fuels the LLM already classifies correctly — the guard must not claim
# them (it only rescues the previously-misclassified structured cases).
"From main heating system: Mains Gas",
"Electric immersion: Electricity",
"From main heating system: Oil",
# A varied / unstructured phrasing is the LLM's job.
"hot water from the wood burner",
"",
],
)
def test_guard_defers_recognised_and_unstructured_descriptions_to_the_llm(
description: str,
) -> None:
# Act
result = water_heating_guard(description)
# Assert
assert result is None

View file

@ -73,6 +73,46 @@ 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", "fuel"),
[
# Biomass (community) — RdSAP fuel 31. Under a coherent community main
# the calculator scores DHW from the main, so this is a faithful,
# score-neutral relabel off house coal; the fuel is still emitted so an
# individual-main dwelling would score biomass, not coal (ADR-0043).
("From main system, biomass (community)", 31),
# Dual fuel (mineral + wood) — RdSAP fuel 9.
("From main system, dual fuel (mineral and wood)", 9),
# Biodiesel (community) — RdSAP fuel 34.
("From main system, biodiesel (community)", 34),
],
)
def test_biomass_family_water_heating_decodes_off_house_coal(
water_heating_value: str, fuel: int
) -> None:
# Act
simulation = water_heating_overlay_for(water_heating_value, 0)
# Assert — from main system (901), each to its own RdSAP fuel, not coal 33.
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.water_heating_code == 901
assert simulation.heating.water_heating_fuel == fuel
@pytest.mark.parametrize("water_heating_value", ["Unknown", ""])
def test_unresolvable_water_heating_produces_no_overlay(
water_heating_value: str,

View file

@ -0,0 +1,101 @@
"""The water-heating reclassify maps the house-coal dumping ground onto faithful
biomass / wood / dual-fuel / biodiesel / immersion archetypes, leaves genuine coal
and already-correct rows alone, and is idempotent (#1376 / ADR-0043)."""
from __future__ import annotations
from domain.epc.property_overlays.water_heating_overlay import (
water_heating_overlay_for,
)
from domain.epc.property_overrides.water_heating_guard import water_heating_guard
from domain.epc.property_overrides.water_heating_type import WaterHeatingType
from scripts.reclassify_water_heating import water_heating_corrections
def test_house_coal_bucket_rows_are_remapped_to_faithful_fuels() -> None:
# Arrange — the biomass/wood family + the "no system / immersion assumed" case
# all funnelled into house coal, plus a genuinely-coal row and a correctly-
# classified mains-gas row (descriptions arrive lower-cased from the query).
stored = [
("from main heating system: wood logs", "From main system, house coal"),
(
"from main heating system: dual fuel: mineral and wood",
"From main system, house coal",
),
(
"hot-water only community scheme - boilers: biomass (community)",
"From main system, house coal",
),
(
"hot-water only community scheme - boilers: heat from boilers using "
"biodiesel from any source (community)",
"From main system, house coal",
),
(
"no hot water system present - electric immersion assumed: "
"no heating or hot water",
"From main system, house coal",
),
# Genuinely coal — stays (guard resolves house coal, value unchanged).
("from main heating system: house coal", "From main system, house coal"),
# Already correct — the guard does not claim it.
("from main heating system: mains gas", "From main system, mains gas"),
]
# Act
corrections = water_heating_corrections(stored)
# Assert — only the mis-scored rows move, each to its faithful archetype.
assert corrections == {
"from main heating system: wood logs": "From main system, wood logs",
"from main heating system: dual fuel: mineral and wood": (
"From main system, dual fuel (mineral and wood)"
),
"hot-water only community scheme - boilers: biomass (community)": (
"From main system, biomass (community)"
),
"hot-water only community scheme - boilers: heat from boilers using "
"biodiesel from any source (community)": (
"From main system, biodiesel (community)"
),
"no hot water system present - electric immersion assumed: "
"no heating or hot water": "Electric immersion, electricity",
}
def test_every_guard_target_round_trips_to_a_resolvable_archetype() -> None:
# The reclassify decides via the guard; every archetype the guard can emit for
# the house-coal bucket must be a real member that decodes to SAP water-heating
# codes — a guard/overlay drift can't silently write an unmodellable value to
# the DB (ADR-0043).
house_coal_bucket = [
"from main heating system: wood logs",
"from main heating system: dual fuel: mineral and wood",
"hot-water only community scheme - boilers: biomass (community)",
"hot-water only community scheme - boilers: heat from boilers using "
"biodiesel from any source (community)",
"no hot water system present - electric immersion assumed: "
"no heating or hot water",
"from main heating system: house coal",
]
for description in house_coal_bucket:
member = water_heating_guard(description)
assert member is not None, description
assert member in WaterHeatingType, description
simulation = water_heating_overlay_for(member.value, 0)
assert simulation is not None, description
assert simulation.heating is not None, description
assert simulation.heating.water_heating_code is not None, description
assert simulation.heating.water_heating_fuel is not None, description
def test_already_corrected_rows_need_no_change() -> None:
# Act / Assert — idempotent: a re-run against corrected data is a no-op.
assert (
water_heating_corrections(
[("from main heating system: wood logs", "From main system, wood logs")]
)
== {}
)