diff --git a/CONTEXT.md b/CONTEXT.md index fe6493ee4..19825e15a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -101,6 +101,10 @@ _Avoid_: energy assessment, site survey, field survey, Domna survey, Hestia surv Property data supplied by a landlord that may correct or supplement the public EPC for a single Property; triggers Rebaselining when applied; not applicable when Site Notes are present. _Avoid_: patches (deprecated), corrections, manual EPC, edits +**Landlord-Description Classification**: +Resolving a **Landlord Description** (unbounded free-text a landlord supplies for one component — "CWI" / "Cav filled" / "cavity insulated" all name one thing) onto a **Recognised Internal Description** via an LLM classifier, persisted in the `landlord_*_overrides` table (`source=classifier`) as a reviewed cache. Four vocabularies are kept **distinct** and must not be conflated: a **Landlord Description** (unbounded input); a **Recognised Internal Description** (the closed target taxonomy — e.g. a `MainHeatingSystemType` archetype — each binding to a Simulation Overlay); a **Lodged Description** (the gov-EPC `main_heating[].description` rendering, e.g. "Room heaters, electric" — only an example of which system *types* occur, never a map key); and the **SAP main heating code** (Table 4a/4b, what the calculator consumes). The classifier maps Landlord → Recognised Internal → SAP code. When it cannot confidently place the text it emits **`None`** (no overlay → the lodged EPC stands, surfaced to the user as "no suitable match"), **never the nearest wrong archetype** — the target taxonomy must be complete enough that a real system always has a correct home, so the classifier never overflows into a garbage-drawer archetype (ADR-0041). +_Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target taxonomy, not LLM language ability); conflating the landlord input vocabulary with the gov-EPC lodged rendering or the RdSAP entry-tool catalogue; treating a deterministic dict as a *replacement* for the LLM rather than a reviewed cache of its output + ### Modelling **Effective EPC**: diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 916c2c247..b4848f694 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -108,7 +108,10 @@ from repositories.product.composite_product_repository import ( from repositories.property.in_memory_property_overrides_reader import ( InMemoryPropertyOverridesReader, ) -from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.landlord_override_overlays import ( + flag_fuel_mismatch, + overlays_from, +) from repositories.property.override_backed_prediction_attributes_reader import ( OverrideBackedPredictionAttributesReader, ) @@ -561,7 +564,9 @@ def handler( epc = ( None # no stored lodged EPC; prediction path handles this property ) - overrides = overlays_from(overrides_reader.overrides_for(pid)) + resolved_overrides = overrides_reader.overrides_for(pid) + flag_fuel_mismatch(resolved_overrides) + overrides = overlays_from(resolved_overrides) predicted_epc: Optional[EpcPropertyData] = None predicted_epc_is_new = False diff --git a/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md new file mode 100644 index 000000000..47614fe56 --- /dev/null +++ b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md @@ -0,0 +1,129 @@ +# Landlord-heating classification targets a complete, modellable taxonomy; unmapped input is no-override + +## Status + +accepted + +## Context + +Landlord supplementary data describes a dwelling's components in **unbounded +free-text** — a wall is "cavity insulated" / "CWI" / "Cav filled"; a heating +system is "communal gas boiler" / "Quantum storage" / a boiler make. An LLM +classifier maps that free-text onto a **closed internal taxonomy** of recognised +descriptions, each of which binds to a Simulation Overlay applied to +`EpcPropertyData` (ADR-0032). For heating the taxonomy is the +`MainHeatingSystemType` enum, mapped to a representative SAP Table 4a/4b code by +`main_heating_system_overlay`, with coherent companions dragged from the code +(ADR-0035). The LLM is the right tool for the unbounded-free-text problem; the +deterministic dicts in legacy `asset_list` were a *reviewed cache of LLM output*, +not a replacement, and the `landlord_*_overrides` table already serves as that +verified cache (keyed `portfolio_id + description`). + +The taxonomy was **too small** — 9 heating archetypes against a ~13-family RdSAP +main-heating taxonomy. With no correct target the classifier force-picked the +nearest wrong archetype, and treated **"Gas CPSU" as a garbage drawer**: oil and +solid-fuel room heaters, community heating, and `"boiler: a rated na"` all +classified to Gas CPSU; the word "convector" in `"electric (direct acting) room +heaters: panel, convector or radiant heaters"` pulled it to `"Electric storage +heaters, convector"` (code 403, off-peak storage) instead of `"Electric room +heaters"` (691, direct-acting, single-rate). + +Folded into the Effective EPC, a single-rate dwelling modelled as off-peak +storage scores **SAP ~10 (band G)**. This is the true cause of **PRD #1361 +Class A** — 14 properties lodged in band C/D rebaselining to band G — which had +been mis-attributed to a fabric "no insulation (assumed)" assumption and the +pre-SAP10 rebaseline. The walls are correct (landlord asserted as-built); the +heating is the crater. Exemplar **property 718066**: lodged SAP 57, landlord +described "electric (direct acting) room heaters: panel, convector or radiant" +(correct code 691, single-rate), mis-stored as code 403 → live SAP **9.9**. The +stored override was classified `source=classifier` on 2026-06-20, **before** the +691 "Electric room heaters" archetype existed (commit ada2bc07, 2026-06-29) — so +it is also stale. + +## Decision + +The fix is the **target taxonomy, not the mapper**. The LLM stays. + +1. **The classifier targets a complete, modellable taxonomy.** Expand + `MainHeatingSystemType` + `_MAIN_HEATING_CODES` to cover every RdSAP + main-heating family the calculator can score: room heaters by fuel + (gas / oil / solid), warm air, **heat pumps**, and **community heating**. Each + maps to a representative Table 4a/4b code; coherent companions drag from the + code per ADR-0035, so adding an archetype is still "just add its code". + +2. **Unmapped free-text → `None`, never a forced archetype.** The overlay already + returns `None` (no overlay → the lodged EPC heating stands) for any value not + in `_MAIN_HEATING_CODES`; the classifier must *emit* Unknown/`None` when it + cannot confidently place the text, rather than the nearest wrong archetype. + "Gas CPSU" (or any other archetype) is never a fallback. The `None` mapping is + persisted and surfaced to the user as "no suitable match" for later edit. A + forced archetype overwrites a correct lodged cert; `None` (keep lodged) is the + only safe and honest behaviour. + +3. **Heat pumps are modellable without a PCDB index** — a "model unknown" heat + pump maps to a default Table 4a heat-pump code (211–224) with the table's + default seasonal efficiency; no `main_heating_index_number` is required. + +4. **Community heating is modelled via its explicit codes** — 301 (boiler + community) / 302 (CHP + boilers) / 304 (electric heat-pump community), which + the catalogue strings disambiguate ("community boilers only" / "community CHP + and boilers" / "community heat pump"). The calculator (Appendix C §C3.2 + distribution loss, Table 12c DLF, Table 12 source factors) and Bill Derivation + (`HEAT_NETWORK` indicative rate) already support these. **Load-bearing + assumption:** when the landlord text does not supply them, the **DLF defaults + to Table 12c 1.50** and the source to a boiler — community SAP is *sensitive* + to the DLF (1.0 vs 1.5 is a large swing), so this default is documented here + and surfaced as an assumption. Coarse "communal heating" with **no named + source** falls to `None` rather than inventing a heat-network model. + +5. **Re-classify the stale `source=classifier` overrides** once the taxonomy is + complete, so the band-G properties pick up their correct code. + +## Considered Options + +- **Replace the LLM with a deterministic catalogue map** — rejected: landlord + input is unbounded free-text, not a closed vocabulary. The LLM is the right + front door; determinism belongs as a downstream *verified* cache (the override + table), built from LLM output + review, exactly as `asset_list` was. +- **Keep the 9-member enum, tune the LLM prompt** — rejected: with no correct + target the LLM must pick a wrong archetype regardless of prompt. Target + completeness is the fix; prompt quality is secondary. +- **Force unmapped input to a conservative archetype** (e.g. direct-acting + electric) — rejected: any forced archetype overwrites a correct lodged cert. +- **Defer community heating to `None`** (grill option b) — rejected in favour of + modelling the three explicit community codes, since calculator + bill already + support heat networks and the catalogue strings disambiguate the cases; only + coarse unnamed "communal heating" falls to `None`. + +This extends [ADR-0032](0032-landlord-override-epc-overlay.md) (the override +overlay) and [ADR-0035](0035-coherent-heating-system-synthesis.md) (coherent +companions drag from the code). The visible baseline shift it produces is +correct Rebaselining per [ADR-0039](0039-override-aware-rebaselining.md). It +**corrects the stated cause of PRD #1361 Class A** (classifier taxonomy gap, not +fabric/rebaseline). + +### Amendment: natural-fuel coherence + +A room-heater / direct-acting archetype names a *system*, not its fuel — and fuel +arrives as its own composable `main_fuel` override. Two rules keep the system +self-coherent without coupling the two overlays: + +- **Every archetype drags a natural fuel so the system self-coheres without a + `main_fuel` override.** Where the fuel is unambiguous it is exact — electric + room heaters / direct-acting / storage → electricity (RdSAP `main_fuel` 29), a + gas boiler → mains gas (26), an oil room heater → oil (28). **Solid fuel is + ambiguous** (coal / anthracite / smokeless / dual fuel / wood logs / pellets), + so it **defaults to house coal (33)** — the most common solid fuel — which a + specific `main_fuel` override then refines. (The `main_fuel` override's own + vocabulary must grow to carry the full solid-fuel list — a parallel taxonomy + gap, same shape as this one.) +- **A present `main_fuel` override wins** by the applicator's last-wins + composition (`apply_simulations` setattrs non-`None` fields in order), so the + `main_fuel` overlay is applied **after** the heating overlay. The agreeing + case (electric system + electricity fuel) is order-immaterial; only a + *disagreeing* one depends on the ordering. +- **A landlord fuel that contradicts the archetype's natural fuel is *logged*, + not raised** (e.g. "gas" fuel on a solid-fuel room heater) — an + override-plausibility signal (cf. the ADR-0039 scanner); what to do with it is + deferred. The override is still honoured (the landlord wins); we only record + the implausibility. diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index e763fea75..6069b7a49 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -68,6 +68,61 @@ _ASSUMED_DUAL_METER_CODES = OFF_PEAK_IMPLYING_HEATING_CODES | _ROOM_HEATER_CODES _MANUAL_CHARGE_CONTROL = 2401 _STORAGE_HEATER_CODES = frozenset(range(401, 410)) +# SAP Table 4a category 10 ("Room heaters") and its conservative Table 4e Group 6 +# control. A landlord names a room heater, not its control; code 2601 ("no +# thermostatic control of room temperature") is the lowest-SAP room-heater +# control — the modal one real solid-fuel room-heater certs lodge — so it never +# over-credits an unobserved control (the room-heater mirror of the storage +# manual-charge default). Scoped per family as archetypes land; solid-fuel room +# heaters are Table 4a 631-636. +_ROOM_HEATER_CATEGORY = 10 +_ROOM_HEATER_CONTROL = 2601 +_SOLID_FUEL_ROOM_HEATER_CODES = frozenset(range(631, 637)) +# Oil room heaters (SAP Table 4a 621-625) — category 10, conservative room-heater +# control, natural fuel heating oil (RdSAP main_fuel 28). +_OIL_ROOM_HEATER_CODES = frozenset(range(621, 626)) +_OIL_FUEL = 28 +# Gas (incl. LPG) room heaters (SAP Table 4a 601-613) — category 10, conservative +# control, natural fuel mains gas (26); an LPG dwelling is refined by a main_fuel +# override (the overlay can't see the mains connection). +_GAS_ROOM_HEATER_CODES = frozenset(range(601, 614)) +# Fuel-burning room heaters (solid + oil + gas) share category 10 + the +# conservative room-heater control; only the natural fuel differs by family. +# (Distinct from `_ROOM_HEATER_CODES` above, the electric-room-heater dual-meter +# set.) +_CATEGORY_10_ROOM_HEATER_CODES = ( + _SOLID_FUEL_ROOM_HEATER_CODES | _OIL_ROOM_HEATER_CODES | _GAS_ROOM_HEATER_CODES +) + +# Natural-fuel coherence (ADR-0041): where an archetype unambiguously implies a +# fuel, the overlay drags it so a system-only override is self-coherent on a cert +# that lodged a different fuel. A later `main_fuel` override still wins (last-wins +# composition); a contradicting landlord fuel is logged, not silently overridden. +# Electric room heaters (Table 4a 691-701) are unambiguously electricity (RdSAP +# main_fuel code 29). Solid fuel is ambiguous (coal / anthracite / smokeless / +# dual fuel / wood logs / pellets), so it defaults to house coal (33) — the most +# common solid fuel — when the landlord gives no `main_fuel` override; a specific +# solid-fuel override still wins by last-wins composition. +_ELECTRICITY_FUEL = 29 +_HOUSE_COAL_FUEL = 33 +_ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) + +# Heat pumps (SAP Table 4a 211-224 wet, 521-527 warm-air) are category 4 and +# unambiguously electric (natural fuel 29). Modellable on the default code's SPF +# without a PCDB index (ADR-0041). +_HEAT_PUMP_CATEGORY = 4 +_HEAT_PUMP_CODES = frozenset(range(211, 225)) | frozenset(range(521, 528)) + +# Community / heat-network heating (SAP Table 4a 301-304) is category 6; the +# calculator's `_is_heat_network` keys off code OR category 6. The boiler-driven +# schemes (301/302/303) are dominantly mains gas (community) — RdSAP main_fuel 20 +# — per real community certs; the heat-pump scheme (304) is electricity +# (community), added with its archetype (ADR-0041). +_HEAT_NETWORK_CATEGORY = 6 +_HEAT_NETWORK_CODES = frozenset({301, 302, 303, 304}) +_COMMUNITY_BOILER_CODES = frozenset({301, 302, 303}) +_COMMUNITY_GAS_FUEL = 20 + # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The # landlord names the boiler, not its controls — but a gas boiler installed under # modern Building Regs must carry compliant controls, and this overlay already @@ -109,6 +164,29 @@ _MAIN_HEATING_CODES: dict[str, int] = { "Electric storage heaters, fan": 404, "Direct-acting electric": 191, "Electric room heaters": 691, + "Solid fuel room heater, closed": 633, + # Default air-source heat pump — SAP Table 4a 211 ("flow temp in other + # cases", default SPF 2.30). Modellable without a PCDB index; an actual + # product index would refine it (ADR-0041). + "Air source heat pump": 211, + # Boiler-driven community / heat-network heating — SAP Table 4a 301. The + # calculator derives the heat-source efficiency (80%) + DLF (1.50 default) + # from the code (cert_to_inputs). A generic "Community heating" with no named + # source stays unmapped (-> None) — the source can't be assumed (ADR-0041). + "Community heating, boilers": 301, + # Community CHP + boilers — SAP Table 4a 302 (heat network, category 6). + "Community heating, CHP and boilers": 302, + # Modern standalone oil room heater — SAP Table 4a 623 ("Oil room heater, + # 2000 or later", no boiler). Natural fuel heating oil (ADR-0041). + "Oil room heater, 2000 or later": 623, + # Gas room heaters — each catalogue variant to its own SAP Table 4a code + # (wide efficiency spread, so no single representative). Natural fuel mains + # gas; an LPG dwelling is refined by a `main_fuel` override (ADR-0041). + "Gas room heater, condensing fire": 611, + "Gas room heater, decorative fuel-effect": 612, + "Gas room heater, flush live-effect": 605, + "Gas room heater, open flue 1980 or later": 603, + "Gas room heater, open flue pre-1980": 601, } @@ -129,6 +207,38 @@ def _control_for(code: int) -> Optional[int]: return _MANUAL_CHARGE_CONTROL if code in _GAS_BOILER_CODES: return _FULL_BOILER_CONTROL + if code in _CATEGORY_10_ROOM_HEATER_CODES: + return _ROOM_HEATER_CONTROL + return None + + +def _category_for(code: int) -> Optional[int]: + """The SAP Table 4a heating category a code implies, where the archetype + fixes it. Room heaters are category 10 — set so a system-only override reads + as a room-heater system, not whatever category the replaced system carried.""" + if code in _CATEGORY_10_ROOM_HEATER_CODES: + return _ROOM_HEATER_CATEGORY + if code in _HEAT_PUMP_CODES: + return _HEAT_PUMP_CATEGORY + if code in _HEAT_NETWORK_CODES: + return _HEAT_NETWORK_CATEGORY + return None + + +def _natural_fuel_for(code: int) -> Optional[int]: + """The fuel an archetype unambiguously implies (a coherent default), or None + where the fuel is ambiguous and must come from the `main_fuel` override. A + present `main_fuel` override wins by last-wins composition.""" + if code in _ELECTRIC_ROOM_HEATER_CODES or code in _HEAT_PUMP_CODES: + return _ELECTRICITY_FUEL + if code in _SOLID_FUEL_ROOM_HEATER_CODES: + return _HOUSE_COAL_FUEL + if code in _OIL_ROOM_HEATER_CODES: + return _OIL_FUEL + if code in _GAS_ROOM_HEATER_CODES: + return _MAINS_GAS_FUEL + if code in _COMMUNITY_BOILER_CODES: + return _COMMUNITY_GAS_FUEL return None @@ -151,6 +261,16 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay: ) +def natural_fuel_for(main_heating_value: str) -> Optional[int]: + """The RdSAP `main_fuel` code a heating archetype unambiguously implies — its + natural fuel (ADR-0041) — or None for an unmapped archetype. Exposed so a + plausibility check can compare it against a landlord `main_fuel` override.""" + code = _MAIN_HEATING_CODES.get(main_heating_value) + if code is None: + return None + return _natural_fuel_for(code) + + def main_heating_overlay_for( main_heating_value: str, building_part: int ) -> Optional[EpcSimulation]: @@ -162,6 +282,8 @@ def main_heating_overlay_for( return EpcSimulation( heating=HeatingOverlay( sap_main_heating_code=code, + main_heating_category=_category_for(code), + main_fuel_type=_natural_fuel_for(code), meter_type=_meter_for(code), main_heating_control=_control_for(code), # A landlord override describes the existing dwelling, so its assumed diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index 3bfff01e5..d79fec1d1 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -25,4 +25,14 @@ class MainHeatingSystemType(Enum): ELECTRIC_STORAGE_FAN = "Electric storage heaters, fan" DIRECT_ELECTRIC = "Direct-acting electric" ELECTRIC_ROOM_HEATERS = "Electric room heaters" + SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" + AIR_SOURCE_HEAT_PUMP = "Air source heat pump" + COMMUNITY_BOILERS = "Community heating, boilers" + COMMUNITY_CHP_AND_BOILERS = "Community heating, CHP and boilers" + OIL_ROOM_HEATER_POST_2000 = "Oil room heater, 2000 or later" + GAS_FIRE_CONDENSING = "Gas room heater, condensing fire" + GAS_FIRE_DECORATIVE = "Gas room heater, decorative fuel-effect" + GAS_FIRE_FLUSH_LIVE_EFFECT = "Gas room heater, flush live-effect" + GAS_FIRE_OPEN_FLUE_POST_1980 = "Gas room heater, open flue 1980 or later" + GAS_FIRE_OPEN_FLUE_PRE_1980 = "Gas room heater, open flue pre-1980" UNKNOWN = "Unknown" diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index b65cf5d29..29ebcea00 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -28,6 +28,7 @@ values are live vs no-op before any write. from __future__ import annotations +import logging from typing import Callable, Optional from domain.epc.property_overlays.attribute_overlay import ( @@ -41,6 +42,7 @@ from domain.epc.property_overlays.glazing_overlay import glazing_overlay_for from domain.epc.property_overlays.main_fuel_overlay import fuel_overlay_for from domain.epc.property_overlays.main_heating_system_overlay import ( main_heating_overlay_for, + natural_fuel_for, ) from domain.epc.property_overlays.water_heating_overlay import ( water_heating_overlay_for, @@ -50,6 +52,8 @@ from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for from domain.modelling.simulation import EpcSimulation from repositories.property.property_overrides_reader import ResolvedPropertyOverrides +logger = logging.getLogger(__name__) + # Each override component maps its value (+ building part) to an overlay, or None # when the value isn't resolvable. Fabric (wall/roof) folds onto building parts; # property_type / built_form_type are whole-dwelling categorical corrections @@ -67,9 +71,22 @@ _COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = } +# Components whose overlay must be applied LAST so an explicit value wins a +# default another overlay dragged. `apply_simulations` is last-wins and override +# rows arrive in arbitrary order, so a `main_fuel` override must be applied after +# the `main_heating_system` archetype, whose natural-fuel default it overrides +# (ADR-0041) — e.g. "smokeless coal" must beat a solid-fuel room heater's coal +# default. +_APPLY_LAST: frozenset[str] = frozenset({"main_fuel"}) + + def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: overlays: list[EpcSimulation] = [] - for row in overrides.rows: + # Stable sort: non-`_APPLY_LAST` rows keep their order, `main_fuel` goes last. + ordered_rows = sorted( + overrides.rows, key=lambda row: row.override_component in _APPLY_LAST + ) + for row in ordered_rows: mapper = _COMPONENT_OVERLAYS.get(row.override_component) if mapper is None: continue @@ -77,3 +94,55 @@ def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: if overlay is not None: overlays.append(overlay) return overlays + + +# Coarse fuel family per RdSAP `main_fuel` code (main_fuel_overlay._FUEL_CODES), +# for the plausibility check. The natural fuel a solid-fuel archetype drags +# (house coal) is a *default* across the ambiguous solid family, so a same-family +# override (smokeless / dual fuel / biomass) is a refinement, not a contradiction +# — only a different family (gas/electric on a solid heater) is flagged. +_FUEL_FAMILY: dict[int, str] = { + 26: "gas", 20: "gas", + 27: "lpg", 3: "lpg", 17: "lpg", + 28: "oil", + 29: "electricity", 25: "electricity", + 33: "solid", 15: "solid", 10: "solid", 31: "solid", +} + + +def _override_value(overrides: ResolvedPropertyOverrides, component: str) -> Optional[str]: + for row in overrides.rows: + if row.override_component == component: + return row.override_value + return None + + +def flag_fuel_mismatch(overrides: ResolvedPropertyOverrides) -> None: + """Log (not raise) when a landlord `main_fuel` override contradicts the + `main_heating_system` archetype's natural fuel — a plausibility signal (e.g. + a gas fuel on an electric room heater). The override is still honoured (it + wins, ADR-0041); we only record the implausibility. Deferred handling.""" + heating_value = _override_value(overrides, "main_heating_system") + fuel_value = _override_value(overrides, "main_fuel") + if heating_value is None or fuel_value is None: + return + natural = natural_fuel_for(heating_value) + fuel_overlay = fuel_overlay_for(fuel_value, 0) + override_fuel = ( + fuel_overlay.heating.main_fuel_type + if fuel_overlay is not None and fuel_overlay.heating is not None + else None + ) + if natural is None or not isinstance(override_fuel, int): + return + natural_family = _FUEL_FAMILY.get(natural) + override_family = _FUEL_FAMILY.get(override_fuel) + if natural_family is None or override_family is None: + return + if natural_family != override_family: + logger.warning( + "Landlord main_fuel %r contradicts the natural fuel of heating " + "system %r (override is honoured; flagged for review)", + fuel_value, + heating_value, + ) diff --git a/repositories/property/property_postgres_repository.py b/repositories/property/property_postgres_repository.py index dec92814e..1072c18d8 100644 --- a/repositories/property/property_postgres_repository.py +++ b/repositories/property/property_postgres_repository.py @@ -14,7 +14,10 @@ from domain.property.properties import Properties from domain.property.property import Property, PropertyIdentity from infrastructure.postgres.property_table import PropertyRow from repositories.epc.epc_repository import EpcRepository -from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.landlord_override_overlays import ( + flag_fuel_mismatch, + overlays_from, +) from repositories.property.property_overrides_reader import PropertyOverridesReader from repositories.property.property_repository import ( PropertyIdentityInsert, @@ -63,7 +66,9 @@ class PropertyPostgresRepository(PropertyRepository): no reader is wired (the overlay stays off) or the Property has none.""" if self._overrides_reader is None: return [] - return overlays_from(self._overrides_reader.overrides_for(property_id)) + overrides = self._overrides_reader.overrides_for(property_id) + flag_fuel_mismatch(overrides) + return overlays_from(overrides) def get(self, property_id: int) -> Property: row = self._session.get(PropertyRow, property_id) diff --git a/scripts/reclassify_heating_overrides.py b/scripts/reclassify_heating_overrides.py new file mode 100644 index 000000000..002ebef9b --- /dev/null +++ b/scripts/reclassify_heating_overrides.py @@ -0,0 +1,176 @@ +"""One-time re-classification of stale landlord main-heating-system overrides. + +PRD #1361 Class A: the LLM landlord-description classifier ran against a +too-small `MainHeatingSystemType` taxonomy (ADR-0041), so canonical RdSAP +heating descriptions were stored against the wrong archetype — community heating +and oil/solid room heaters dumped into "Gas CPSU", and direct-acting +"panel, convector or radiant" room heaters mis-read as off-peak storage (the +band-G crater). The taxonomy is now complete, so these *canonical* descriptions +re-resolve deterministically — no LLM, no nondeterminism. + +This rewrites the resolved archetype on every affected row in BOTH the +per-Property fact layer (`property_overrides.override_value`) and the +per-portfolio classifier cache (`landlord_main_heating_system_overrides.value`), +keyed on the raw description (`original_spreadsheet_description` / `description`). + +DRY-RUN BY DEFAULT: prints the row counts it would change and writes nothing. +Pass `--apply` to execute inside a transaction. Idempotent — only rows whose +stored value differs from the corrected archetype are touched, so a second run +is a no-op. Descriptions with no confident archetype yet (solid-fuel open-fire / +with-boiler, warm air, electric underfloor) are deliberately NOT remapped here. +""" + +from __future__ import annotations + +import argparse + +from sqlalchemy import text + +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) +from scripts.e2e_common import build_engine, load_env + +# Canonical RdSAP main-heating description (lowercased, as stored in +# `original_spreadsheet_description` / `description`) → the corrected archetype. +# Only descriptions whose correct archetype now exists in the taxonomy appear +# here; the value is a `MainHeatingSystemType` so a typo can't slip a non-archetype +# string into the DB (the enum is the single source of truth — ADR-0041). +REMAP: dict[str, MainHeatingSystemType] = { + "community heating systems: community boilers only (rdsap)": ( + MainHeatingSystemType.COMMUNITY_BOILERS + ), + "community heating systems: community chp and boilers (rdsap)": ( + MainHeatingSystemType.COMMUNITY_CHP_AND_BOILERS + ), + "electric (direct acting) room heaters: panel, convector or radiant heaters": ( + MainHeatingSystemType.ELECTRIC_ROOM_HEATERS + ), + "oil room heaters: room heater, 2000 or later": ( + MainHeatingSystemType.OIL_ROOM_HEATER_POST_2000 + ), + "solid fuel room heaters: closed room heater": ( + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_CLOSED + ), + "gas (including lpg) room heaters: condensing gas fire": ( + MainHeatingSystemType.GAS_FIRE_CONDENSING + ), + "gas (including lpg) room heaters: decorative fuel effect gas fire, " + "open to chimney": MainHeatingSystemType.GAS_FIRE_DECORATIVE, + "gas (including lpg) room heaters: flush fitting live fuel effect gas fire " + "(open fronted), sealed to fireplace opening": ( + MainHeatingSystemType.GAS_FIRE_FLUSH_LIVE_EFFECT + ), + "gas (including lpg) room heaters: gas fire, open flue, 1980 or later " + "(open fronted), sitting proud of, and sealed to, fireplace opening": ( + MainHeatingSystemType.GAS_FIRE_OPEN_FLUE_POST_1980 + ), + "gas (including lpg) room heaters: gas fire, open flue, pre-1980 " + "(open fronted)": MainHeatingSystemType.GAS_FIRE_OPEN_FLUE_PRE_1980, +} + +# property_overrides keys the raw text on `original_spreadsheet_description`; the +# classifier cache keys it on `description`. Both compared case-insensitively. +_PROPERTY_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'main_heating_system' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +# The cache `value` column is the Drizzle-owned `main_heating_system` pgEnum; +# compare/update it as text (no CAST) so a target value the DB enum does not yet +# carry doesn't error the read. The UPDATE is only issued for values the live +# enum already has — `_db_enum_values` gates it (see main()). +_CACHE_UPDATE = text( + """ + UPDATE landlord_main_heating_system_overrides + SET value = :new_value, updated_at = now() + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_PROPERTY_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'main_heating_system' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_COUNT = text( + """ + SELECT count(*) FROM landlord_main_heating_system_overrides + 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 = 'main_heating_system'" +) + + +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")) + # The cache `value` is the Drizzle-owned pgEnum; only update it for + # archetypes the live enum already carries. The rest need the Drizzle + # migration (FE repo) before the cache — and the classifier — can store + # them; property_overrides (TEXT, what the modelling reads) is updated + # regardless, which is the actual band-G fix. + enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} + prop_total = 0 + cache_total = 0 + deferred: list[str] = [] + for description, archetype in REMAP.items(): + params = {"description": description, "new_value": archetype.value} + prop_n = conn.execute(_PROPERTY_OVERRIDES_COUNT, params).scalar() or 0 + prop_total += prop_n + in_enum = archetype.value in enum_values + cache_n = conn.execute(_CACHE_COUNT, params).scalar() or 0 if in_enum else 0 + cache_total += cache_n + if not in_enum and prop_n: + deferred.append(archetype.value) + if prop_n or cache_n: + tag = "" if in_enum else " [cache deferred: enum value missing]" + print( + f" {prop_n:>6} property_overrides + {cache_n} cache " + f"-> {archetype.value!r} <= {description!r}{tag}" + ) + if args.apply: + conn.execute(_PROPERTY_OVERRIDES_UPDATE, params) + if in_enum: + conn.execute(_CACHE_UPDATE, params) + verb = "updated" if args.apply else "would update" + print( + f"\n{verb} {prop_total} property_overrides rows + {cache_total} " + f"classifier-cache rows across {len(REMAP)} descriptions." + ) + if deferred: + print( + f"\n{len(set(deferred))} archetype(s) NOT in the Drizzle " + "`main_heating_system` pgEnum — their cache rows are deferred " + "until the FE-repo enum migration lands (property_overrides was " + "still updated, which is what the modelling reads):" + ) + for v in sorted(set(deferred)): + print(f" {v!r}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index ea73c0f70..d98d01458 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -245,13 +245,14 @@ def test_off_peak_archetypes_drag_dual_others_drag_single() -> None: @pytest.mark.parametrize( "main_heating_value", - ["Unknown", "", "Air source heat pump", "Community heating"], + ["Unknown", "", "Community heating"], ) def test_unresolvable_or_unmodelled_heating_produces_no_overlay( main_heating_value: str, ) -> None: - # Heat pumps (main_heating_index_number) and community heating (community - # codes) don't map to a Table 4b sap_main_heating_code yet — no overlay. + # Genuinely unrecognised values (Unknown / empty) and not-yet-modelled + # community heating produce no overlay — the lodged EPC heating stands + # (ADR-0041). Air source heat pumps ARE now modelled (Table 4a 211). # Act simulation = main_heating_overlay_for(main_heating_value, 0) @@ -383,3 +384,238 @@ def test_every_resolvable_main_heating_value_decodes( # Assert assert simulation is not None + + +def test_solid_fuel_room_heater_decodes_to_the_closed_room_heater_code() -> None: + # A landlord-named solid-fuel room heater (e.g. a closed stove) is a + # recognised archetype, not a gas wet system — it must decode to its SAP + # Table 4a code (633, closed solid-fuel room heater), not overflow into the + # nearest wrong archetype the way the under-populated taxonomy did, sending + # "solid fuel room heaters: closed room heater" to Gas CPSU (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert — SAP Table 4a code 633 (closed solid-fuel room heater). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 633 + + +def test_solid_fuel_room_heater_drags_its_coherent_room_heater_companions() -> None: + # The landlord names the system, not its category/control/meter. A solid-fuel + # room heater is a room-heater system (Table 4a category 10) on a single-rate + # meter (not off-peak storage), under the conservative room-heater control + # real certs lodge (Table 4e Group 6 code 2601 — "no thermostatic control", + # the lowest-SAP room-heater control, so it never over-credits an unobserved + # control, mirroring the storage manual-charge default). ADR-0035. + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.meter_type == "Single" + + +def test_electric_room_heaters_drag_their_natural_electricity_fuel() -> None: + # A landlord names the system; an electric room heater's natural fuel is + # unambiguously electricity (RdSAP main_fuel code 29). The overlay drags it so + # a system-only override is self-coherent even on a cert that lodged a + # different fuel — a later `main_fuel` override still wins (last-wins + # composition), and a contradicting fuel is logged, not silently kept + # (ADR-0041 natural-fuel coherence). + + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_fuel_type == 29 + + +def test_solid_fuel_room_heater_defaults_to_house_coal_as_its_natural_fuel() -> None: + # Solid fuel is fuel-ambiguous (coal / anthracite / smokeless / dual fuel / + # wood logs / pellets), but the system must still self-cohere when no + # main_fuel override is given. Default to house coal (RdSAP main_fuel code + # 33), the most common solid fuel; a main_fuel override naming a specific + # solid fuel still wins by last-wins composition (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_fuel_type == 33 + + +def test_air_source_heat_pump_decodes_to_the_default_ashp_code() -> None: + # A landlord names "air source heat pump" without a PCDB model. It is + # modellable via the SAP Table 4a default ASHP code (211, "flow temp in other + # cases", default SPF 2.30) — no main_heating_index_number needed — so it must + # decode to 211, not produce no overlay / fall to Unknown (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Air source heat pump", 0) + + # Assert — SAP Table 4a code 211 (default air-source heat pump). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 211 + + +def test_air_source_heat_pump_drags_its_coherent_companions() -> None: + # A heat pump is unambiguously electric (natural fuel 29) and SAP Table 4a + # category 4. Unlike storage/CPSU it does NOT imply an off-peak tariff (SAP + # §12 Rule 3 is conditional, not forcing), so the coherent default meter is + # single-rate — forcing Dual would assume an off-peak split it may not have + # and mis-bill it. The overlay drags these code-derived companions so a + # system-only override is self-coherent (ADR-0035 / ADR-0041). + + # Act + simulation = main_heating_overlay_for("Air source heat pump", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 4 + assert heating.main_fuel_type == 29 + assert heating.meter_type == "Single" + + +def test_community_boilers_decode_to_the_heat_network_boiler_code() -> None: + # A community/heat-network scheme driven by boilers is SAP Table 4a code 301 + # (the calculator derives the heat-source efficiency + DLF from it). It must + # decode to 301, not the Gas CPSU (120) the under-populated taxonomy forced — + # a single-dwelling gas wet boiler is the wrong picture for a heat network + # (ADR-0041). A generic "Community heating" with no named source stays None. + + # Act + simulation = main_heating_overlay_for("Community heating, boilers", 0) + + # Assert — SAP Table 4a code 301 (boiler-driven community heating). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 301 + + +def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() -> None: + # A community boiler scheme is SAP main_heating_category 6 (heat network), so + # the calculator treats it as a heat network (cert_to_inputs `_is_heat_network` + # checks code OR category 6). Its natural fuel is mains gas (community) — RdSAP + # main_fuel code 20, the dominant fuel real community-boiler certs lodge. A + # specific main_fuel override (e.g. biomass community) still wins (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Community heating, boilers", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 6 + assert heating.main_fuel_type == 20 + + +def test_community_chp_and_boilers_decode_to_their_heat_network_code() -> None: + # A community scheme with CHP + boilers is SAP Table 4a code 302, still a heat + # network (category 6) on community mains gas (20). Real audit data: 10 + # properties carry this, today mis-bucketed to Gas CPSU (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Community heating, CHP and boilers", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.sap_main_heating_code == 302 + assert heating.main_heating_category == 6 + assert heating.main_fuel_type == 20 + + +def test_oil_room_heater_decodes_to_the_post_2000_code() -> None: + # A modern standalone oil room heater is SAP Table 4a code 623 ("Oil room + # heater, 2000 or later", no boiler). It must decode to 623, not the Gas CPSU + # the under-populated taxonomy forced (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Oil room heater, 2000 or later", 0) + + # Assert — SAP Table 4a code 623. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 623 + + +def test_oil_room_heater_drags_its_coherent_room_heater_companions() -> None: + # An oil room heater is a room-heater system (category 10) on a single-rate + # meter, under the conservative room-heater control (2601), with its natural + # fuel heating oil (RdSAP main_fuel 28). The overlay drags these so a + # system-only override is self-coherent (ADR-0035 / ADR-0041). + + # Act + simulation = main_heating_overlay_for("Oil room heater, 2000 or later", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.main_fuel_type == 28 + assert heating.meter_type == "Single" + + +@pytest.mark.parametrize( + ("value", "code"), + [ + ("Gas room heater, condensing fire", 611), + ("Gas room heater, decorative fuel-effect", 612), + ("Gas room heater, flush live-effect", 605), + ("Gas room heater, open flue 1980 or later", 603), + ("Gas room heater, open flue pre-1980", 601), + ], +) +def test_gas_room_heater_variants_decode_to_their_sap_codes( + value: str, code: int +) -> None: + # Gas room heaters span a wide efficiency range (decorative 0.20 -> condensing + # 0.85), so each catalogue variant maps to its own SAP Table 4a code rather + # than collapsing to one representative — which would over-credit a decorative + # fire and under-credit a condensing one. Today they mis-bucket to "Gas + # boiler, regular" (ADR-0041). + + # Act + simulation = main_heating_overlay_for(value, 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == code + + +def test_gas_room_heater_drags_its_coherent_companions() -> None: + # A gas room heater is a room-heater system (category 10) on a single-rate + # meter, under the conservative room-heater control (2601). Its natural fuel + # is mains gas (26) — an LPG dwelling is refined by a main_fuel override + # (the overlay can't see the mains connection) (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Gas room heater, condensing fire", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.main_fuel_type == 26 + assert heating.meter_type == "Single" diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index 1f790aa88..e35034ebc 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -6,12 +6,23 @@ that fold onto the lodged EPC — per component, partial, skipping unmapped rows from __future__ import annotations +import logging + +import pytest + from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier -from repositories.property.landlord_override_overlays import overlays_from +from domain.modelling.scoring.overlay_applicator import apply_simulations +from repositories.property.landlord_override_overlays import ( + flag_fuel_mismatch, + overlays_from, +) from repositories.property.property_overrides_reader import ( ResolvedPropertyOverride, ResolvedPropertyOverrides, ) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) def test_roof_type_row_produces_a_roof_overlay() -> None: @@ -143,3 +154,74 @@ def test_unresolvable_rows_are_skipped() -> None: # Assert assert overlays == [] + + +def test_main_fuel_override_wins_over_a_heating_archetype_natural_fuel() -> None: + # A heating-system override drags a natural fuel (a solid-fuel room heater + # defaults to house coal, RdSAP main_fuel 33), but an explicit main_fuel + # override must win. apply_simulations is last-wins and the override rows + # arrive in arbitrary order, so overlays_from must apply main_fuel AFTER + # main_heating_system — otherwise the natural-fuel default clobbers the + # explicit fuel (ADR-0041). The rows here are in the order that exposes it. + baseline = build_epc() + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride("main_fuel", 0, "smokeless coal"), + ResolvedPropertyOverride( + "main_heating_system", 0, "Solid fuel room heater, closed" + ), + ) + ) + + # Act + result = apply_simulations(baseline, overlays_from(overrides)) + + # Assert — the explicit fuel (smokeless coal 15) wins over the coal default. + assert result.sap_heating.main_heating_details[0].main_fuel_type == 15 + + +def test_a_fuel_override_contradicting_the_heating_archetype_is_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + # An electric room heater is unambiguously electricity; a "mains gas" main_fuel + # override contradicts it (a different fuel family). The override still wins + # (ADR-0041), but the implausibility is logged for later review — not raised. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", 0, "Electric room heaters" + ), + ResolvedPropertyOverride("main_fuel", 0, "mains gas"), + ) + ) + + # Act + with caplog.at_level(logging.WARNING): + flag_fuel_mismatch(overrides) + + # Assert + assert any("fuel" in r.message.lower() for r in caplog.records) + + +def test_a_same_family_fuel_refinement_is_not_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + # A solid-fuel room heater's natural fuel (house coal 33) is a *default* for + # the ambiguous solid family. A "smokeless coal" override refines it within + # the same family — that is expected, not a contradiction, so it must NOT log. + # Only a different fuel family (gas/electric on a solid heater) is flagged. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", 0, "Solid fuel room heater, closed" + ), + ResolvedPropertyOverride("main_fuel", 0, "smokeless coal"), + ) + ) + + # Act + with caplog.at_level(logging.WARNING): + flag_fuel_mismatch(overrides) + + # Assert + assert caplog.records == [] diff --git a/tests/scripts/test_reclassify_heating_overrides.py b/tests/scripts/test_reclassify_heating_overrides.py new file mode 100644 index 000000000..f3cb73382 --- /dev/null +++ b/tests/scripts/test_reclassify_heating_overrides.py @@ -0,0 +1,30 @@ +"""The one-time heating-override re-classification map is internally valid.""" + +from __future__ import annotations + +from domain.epc.property_overlays.main_heating_system_overlay import ( + main_heating_overlay_for, +) +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) +from scripts.reclassify_heating_overrides import REMAP + + +def test_every_remap_target_is_a_resolvable_archetype() -> None: + # The remap must never point a stored description at an archetype the overlay + # can't model — that would replace one broken value with another. Every + # target must decode to a real SAP heating code (ADR-0041). + for description, archetype in REMAP.items(): + simulation = main_heating_overlay_for(archetype.value, 0) + + assert simulation is not None, description + assert simulation.heating is not None, description + assert simulation.heating.sap_main_heating_code is not None, description + + +def test_remap_targets_are_main_heating_system_members() -> None: + # Belt and braces: the values are enum members (a typo can't smuggle a + # non-archetype string into the DB). + for archetype in REMAP.values(): + assert archetype in MainHeatingSystemType