mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
commit
baaadcdf98
9 changed files with 560 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
41
domain/epc/property_overrides/water_heating_guard.py
Normal file
41
domain/epc/property_overrides/water_heating_guard.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
148
scripts/reclassify_water_heating.py
Normal file
148
scripts/reclassify_water_heating.py
Normal 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()
|
||||
80
tests/domain/epc/test_water_heating_guard.py
Normal file
80
tests/domain/epc/test_water_heating_guard.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
101
tests/scripts/test_reclassify_water_heating.py
Normal file
101
tests/scripts/test_reclassify_water_heating.py
Normal 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")]
|
||||
)
|
||||
== {}
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue