diff --git a/docs/adr/0067-oil-and-solid-fuel-boiler-archetypes-resolve-fuel-at-resolution.md b/docs/adr/0067-oil-and-solid-fuel-boiler-archetypes-resolve-fuel-at-resolution.md new file mode 100644 index 000000000..dd8a67af0 --- /dev/null +++ b/docs/adr/0067-oil-and-solid-fuel-boiler-archetypes-resolve-fuel-at-resolution.md @@ -0,0 +1,130 @@ +# Oil and solid-fuel boiler archetypes, with the fuel resolved at resolution not in the classifier + +## Status + +accepted + +## Context + +A portfolio-796 re-audit found ~43 properties whose `main_heating` override is a +**gas** boiler archetype (`Gas boiler, regular/combi`) sitting on a **non-gas** +`main_fuel` (oil 24+3, dual-fuel 7, house coal 5, wood logs 4). Same root cause as +the electric-boiler cohort (ADR-0052) one fuel over: the landlord's heating +description is a **fuel-agnostic** "Boiler … Regular/Combi Boiler", and with no +oil/solid wet-boiler member in `MainHeatingSystemType` the LLM funnelled them all +onto the gas archetype. LPG-on-gas is deliberately excluded — RdSAP models LPG on +the gas archetype, so those are correct. + +The app side is ready (`assessment-model#455` adds the members to +`MainHeatingSystemValues`; migration 0279), inert until this repo lands the +counterpart (lock-step, ADR-0002). + +The issue framed the fix as "make the classifier resolve the boiler description +**using the fuel column**". That conflates the *rule* with the *layer*: the +`ColumnClassifier` port classifies a **deduped, portfolio-wide set of description +strings** (`classify(descriptions: set[str]) -> {description: archetype}`), with +no access to another column, and stores the result keyed by description. Fuel +varies **per property**, not per description — so two dwellings sharing the +description `"regular boiler"` on different fuels cannot map to different +archetypes in that model. A fuel-aware *classifier* would need a composite +`(description, fuel)` key threaded through the port, the orchestrator dedup, and +the description-keyed override table — the first cross-column classifier in the +system. + +But `ResolvedPropertyOverrides` already carries every override component for a +property together (`main_heating_system` **and** `main_fuel`), so the fuel is in +hand at **resolution** — where `overlays_from` builds the overlays. + +## Decision + +Add three members to `MainHeatingSystemType` (byte-for-byte with #455): + +``` +Oil boiler, regular +Oil boiler, combi +Solid fuel boiler +``` + +**1. Overlay — model them on the gas-boiler pattern** (a dedicated path that +forces a complete, coherent companion set; ADR-0048/0052/0053). Representative +SAP codes map to the modern/condensing variant (A–G efficiency deferred, as for +gas): + +| archetype | SAP code | fuel | fan flue | cylinder | control | category | meter | +|---|---|---|---|---|---|---|---| +| Oil boiler, regular | 127 (condensing oil, 84%) | oil (28) | yes | yes | 2106 | 2 | Single | +| Oil boiler, combi | 130 (condensing combi oil, 82%) | oil (28) | yes | no | 2106 | 2 | Single | +| Solid fuel boiler | 151 (manual-feed independent, 0.60) | house coal (33) | no | yes | 2106 | 2 | Single | + +- **Control 2106** (full modern), **not** the electric-boiler's conservative 2101 + — decided with Khalim: model the boiler modern, exactly as the gas archetype + does (the whole install is treated as a modern condensing system). +- **`gas_connection_available` is left unset (`None`)**, not forced. Unlike a gas + boiler (which *requires* the connection, so forces `True`), an oil/solid boiler + has no such requirement — a `None` overlay field inherits the lodged EPC's + value ("default to the existing EPC"), and an absent lodgement reads as no gas + main. Forcing `False` would wrongly erase a real gas cooking connection. +- **Solid fuel is single-member, conservative code 151** (0.60, the lowest- + efficiency independent solid-fuel boiler). The `main_fuel` override drives + carbon, but it does **not** change `sap_main_heating_code`, so one member fixes + one efficiency: 151 under-credits a wood dwelling (its true 155 is 0.70) rather + than over-crediting a coal one — the safe direction. Splitting into + mineral/biomass sub-members is deferred until demand and app-side members + justify it. `main_fuel = house coal` is the coherent default, always overridden + by the property's actual `main_fuel` (last-wins). + +**2. Fuel-awareness is a pure fuel-join at *resolution*, not a classifier +change.** A function `resolve_boiler_archetype(heating_value, fuel_value)` +re-points a **gas** wet-boiler archetype by the property's `main_fuel` family +(reusing the existing `_FUEL_FAMILY` map): oil → `Oil boiler, regular/combi` +(preserving regular/combi); solid (coal/wood/dual) → `Solid fuel boiler`; +gas/LPG/electric/none → unchanged. `overlays_from` applies it before building the +`main_heating_system` overlay, reading the sibling `main_fuel` row. The LLM +classifier and its deduped port are **untouched**. + +This is the same rule the issue's per-property backfill describes, so +recurrence-prevention and the 43-row correction are **one rule**. It also fixes +modelling for the existing 43 at model-time (their stored `Gas boiler` snapshot +re-points to `Oil`/`Solid` when scored) — the persisted-label backfill (for UI +truthfulness) is a separate, deferred step. + +**Composition (unchanged, ADR-0041):** the archetype's forced companions are +*defaults*; `main_fuel` is in `_APPLY_LAST`, so a fuel override applies **after** +the archetype and wins on `main_fuel_type` (never on `sap_main_heating_code`) — +efficiency from the boiler code, carbon from the fuel. No contradiction by +construction: the join *picked* the archetype *from* the fuel. Category/control +stay archetype-forced (no override column, and a stale inherited one silently +mis-bills, ADR-0048); a future user-tunable control would add its own column and +join into `_APPLY_LAST`, not change this. + +## Considered options + +- **Fuel-aware classifier via a composite `(description, fuel)` key.** Rejected: + wrong layer — forces a schema + shared-port change and the first cross-column + classifier, when `ResolvedPropertyOverrides` already hands resolution both + values for free. +- **Split `Solid fuel boiler` into mineral vs biomass now.** Rejected: doubles the + enum + app-side members for a 16-row cohort; the conservative single code (151) + bounds the error to the safe (under-credit) direction. Revisit on demand. +- **Conservative control 2101 (the electric-boiler choice).** Rejected: these are + wet boilers modelled modern like gas (decided with Khalim); 2101 is for the + unknown-vintage electric-boiler case (ADR-0052). +- **Force `gas_connection_available=False`.** Rejected: erases a real gas cooking + connection; inherit the lodged EPC instead (`None`). +- **Re-point only at a one-off backfill.** Rejected alone: leaves the *engine* + emitting gas-on-oil for every future portfolio; the resolution join prevents + recurrence and the backfill becomes cosmetic (label truthfulness). + +## Consequences + +- The 43 rows score on the correct boiler efficiency + fuel at model-time with no + data migration; the persisted-snapshot/vocab backfill (label truthfulness) is a + deferred follow-up, after #455 is live. +- `resolve_boiler_archetype` is extensible to the planned `Boiler, unspecified + fuel` member — its resolution order (fuel override → EPC → default gas) is the + same shape; the EPC-fuel fallback (resolution has no EPC today) lands with that + member. +- Efficiency A–G bands remain deferred for these as for gas (modern/condensing + code); solid sub-fuel efficiency (155/153) is bounded by the conservative 151. +- Lock-step obligation (ADR-0002): the three member strings are the contract with + #455 / migration 0279. diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 946c648ca..651177065 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -217,6 +217,22 @@ _GAS_BOILER_CODES = frozenset({102, 104, 120}) _COMBI_CODES = frozenset({104}) _GAS_BOILER_CATEGORY = 2 _MAINS_GAS_FUEL = 26 + +# Non-gas wet boilers modelled on the gas-boiler pattern (ADR-0067). Oil boilers +# take the modern/condensing SAP Table 4b codes (A-G deferred, like gas): 127 +# regular condensing (84%), 130 combi condensing (82%) — 130 is the combi (no +# cylinder). A solid-fuel boiler is a single conservative member — SAP Table 4a +# 151 (manual-feed independent boiler, 0.60), the lowest-efficiency independent +# solid boiler, so it never over-credits an unknown coal/wood/dual fuel; the +# property's `main_fuel` override drives carbon (it does not change the code). +_OIL_BOILER_CODES = frozenset({127, 130}) +_OIL_BOILER_COMBI_CODES = frozenset({130}) +_SOLID_FUEL_BOILER_CODES = frozenset({151}) +_FUEL_BOILER_CODES = _OIL_BOILER_CODES | _SOLID_FUEL_BOILER_CODES +_FUEL_BOILER_CATEGORY = 2 +# Fan flue: a modern condensing oil boiler is room-sealed / fan-assisted (like a +# gas boiler); a solid-fuel boiler vents through a conventional (non-fanned) flue. +_FAN_FLUE_BOILER_CODES = _OIL_BOILER_CODES # SAP Table 4a "from the main system" water-heating code — a gas boiler heats hot # water from itself, so the override routes water heating to the main system on # mains gas (clearing a storage dwelling's old electric-immersion arrangement). @@ -288,6 +304,13 @@ _MAIN_HEATING_CODES: dict[str, int] = { "Gas room heater, flush live-effect": 605, "Gas room heater, open flue 1980 or later": 603, "Gas room heater, open flue pre-1980": 601, + # Oil / solid-fuel wet boilers off the fuel-agnostic gas default (ADR-0067): + # oil regular 127 / oil combi 130 (Table 4b condensing), solid 151 (Table 4a + # manual-feed independent). Companions (fuel, category, control, cylinder, fan + # flue) drag from `_fuel_boiler_overlay`, modelled on the gas-boiler path. + "Oil boiler, regular": 127, + "Oil boiler, combi": 130, + "Solid fuel boiler": 151, } @@ -368,6 +391,12 @@ def _natural_fuel_for(code: int) -> Optional[int]: or code in _ELECTRIC_UNDERFLOOR_CODES ): return _ELECTRICITY_FUEL + if code in _OIL_BOILER_CODES: + return _OIL_FUEL + if code in _SOLID_FUEL_BOILER_CODES: + # Solid fuel is ambiguous (coal / wood / dual); house coal is the + # conservative default, overridden by the property's `main_fuel` (ADR-0067). + return _HOUSE_COAL_FUEL if code in _SOLID_FUEL_ROOM_HEATER_CODES: return _HOUSE_COAL_FUEL if code in _OIL_ROOM_HEATER_CODES: @@ -398,6 +427,32 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay: ) +def _fuel_boiler_overlay(code: int) -> HeatingOverlay: + """The coherent companion set for a non-gas wet boiler (oil / solid fuel), + modelled on the gas-boiler pattern (ADR-0067): SAP Table 4a category 2, full + modern controls (Table 4e 2106), a single-rate meter, hot water from the main + system, and a cylinder unless it is a combi. + + Differs from the gas boiler deliberately: the natural fuel is oil / house coal + (the property's `main_fuel` override wins by last-wins composition and drives + carbon); `gas_connection_available` is left UNSET (None) so it inherits the + lodged EPC — an oil/solid boiler needs no gas main, unlike a gas boiler which + forces it True; and a solid-fuel boiler vents through a conventional + (non-fanned) flue.""" + fuel = _natural_fuel_for(code) + return HeatingOverlay( + sap_main_heating_code=code, + main_heating_category=_FUEL_BOILER_CATEGORY, + main_fuel_type=fuel, + main_heating_control=_FULL_BOILER_CONTROL, + fan_flue_present=code in _FAN_FLUE_BOILER_CODES, + meter_type=_SINGLE_RATE_METER, + water_heating_code=_FROM_MAIN_WATER_HEATING_CODE, + water_heating_fuel=fuel, + has_hot_water_cylinder=code not in _OIL_BOILER_COMBI_CODES, + ) + + 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 @@ -416,6 +471,8 @@ def main_heating_overlay_for( return None if code in _GAS_BOILER_CODES: return EpcSimulation(heating=_gas_boiler_overlay(code)) + if code in _FUEL_BOILER_CODES: + return EpcSimulation(heating=_fuel_boiler_overlay(code)) category = _category_for(code) control = _control_for(code) if category is None or control is None: diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index f8426fdcf..98d07a6e4 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -19,6 +19,15 @@ class MainHeatingSystemType(Enum): GAS_COMBI = "Gas boiler, combi" GAS_REGULAR = "Gas boiler, regular" GAS_CPSU = "Gas CPSU" + # Oil / solid-fuel wet boilers off the fuel-agnostic gas default (ADR-0067). + # A fuel-blind "Boiler … Regular/Combi" description on a non-gas main_fuel was + # funnelled onto the gas archetype; these give it a home, with the fuel column + # (`resolve_boiler_archetype`) re-pointing at resolution. Solid fuel is a single + # conservative member (SAP 151, 0.60); sub-fuels deferred. Lock-step with + # assessment-model#455 / migration 0279 (ADR-0002). + OIL_BOILER_REGULAR = "Oil boiler, regular" + OIL_BOILER_COMBI = "Oil boiler, combi" + SOLID_FUEL_BOILER = "Solid fuel boiler" ELECTRIC_STORAGE_OLD = "Electric storage heaters, old" ELECTRIC_STORAGE_SLIMLINE = "Electric storage heaters, slimline" ELECTRIC_STORAGE_CONVECTOR = "Electric storage heaters, convector" diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index 29ebcea00..21d9a8c1a 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -90,7 +90,15 @@ def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: mapper = _COMPONENT_OVERLAYS.get(row.override_component) if mapper is None: continue - overlay = mapper(row.override_value, row.building_part) + value = row.override_value + if row.override_component == "main_heating_system": + # A fuel-agnostic gas-boiler archetype re-points to the oil / solid-fuel + # member matching this property's `main_fuel`, so an oil/solid boiler + # scores its own code — not the gas default the LLM assigned (ADR-0067). + value = resolve_boiler_archetype( + value, _override_value(overrides, "main_fuel") + ) + overlay = mapper(value, row.building_part) if overlay is not None: overlays.append(overlay) return overlays @@ -106,10 +114,59 @@ _FUEL_FAMILY: dict[int, str] = { 27: "lpg", 3: "lpg", 17: "lpg", 28: "oil", 29: "electricity", 25: "electricity", - 33: "solid", 15: "solid", 10: "solid", 31: "solid", + # Solid fuels — house coal 33, smokeless 15, dual mineral/wood 10, plus wood + # logs (main_fuel_overlay code 6): omitting 6 left a wood-logs dwelling with no + # fuel family, so `resolve_boiler_archetype` never re-pointed it and + # `flag_fuel_mismatch` never checked it (ADR-0067). + 33: "solid", 15: "solid", 10: "solid", 31: "solid", 6: "solid", } +# A fuel-agnostic gas wet-boiler archetype re-points to the oil / solid-fuel +# member matching the property's `main_fuel` family (ADR-0067). Oil preserves the +# regular/combi split; any solid fuel (coal / wood / dual) collapses to the single +# `Solid fuel boiler` member. Gas/LPG (RdSAP models LPG on gas), electric, and a +# missing fuel leave the archetype unchanged, as does any non-gas-boiler archetype. +_GAS_BOILER_REPOINT: dict[tuple[str, str], str] = { + ("Gas boiler, regular", "oil"): "Oil boiler, regular", + ("Gas boiler, combi", "oil"): "Oil boiler, combi", + ("Gas boiler, regular", "solid"): "Solid fuel boiler", + ("Gas boiler, combi", "solid"): "Solid fuel boiler", +} + + +def _fuel_family_of(fuel_value: Optional[str]) -> Optional[str]: + """The coarse fuel family a landlord `main_fuel` value resolves to (via the + fuel overlay's RdSAP code), or None when absent / unmapped.""" + if fuel_value is None: + return None + fuel_overlay = fuel_overlay_for(fuel_value, 0) + code = ( + fuel_overlay.heating.main_fuel_type + if fuel_overlay is not None and fuel_overlay.heating is not None + else None + ) + if not isinstance(code, int): + return None + return _FUEL_FAMILY.get(code) + + +def resolve_boiler_archetype(heating_value: str, fuel_value: Optional[str]) -> str: + """Re-point a fuel-agnostic gas wet-boiler archetype to the oil / solid-fuel + member matching the property's `main_fuel` (ADR-0067). + + The landlord's boiler description cannot name its fuel, so the LLM defaults a + plain "Boiler … Regular/Combi" to the gas archetype; this join corrects it at + resolution using the sibling `main_fuel` override — the same rule as the + per-property backfill. Non-gas-boiler archetypes, gas/LPG fuels, and a missing + fuel are returned unchanged. Extensible to a future `Boiler, unspecified fuel` + member (fuel override → EPC → default gas).""" + family = _fuel_family_of(fuel_value) + if family is None: + return heating_value + return _GAS_BOILER_REPOINT.get((heating_value, family), heating_value) + + def _override_value(overrides: ResolvedPropertyOverrides, component: str) -> Optional[str]: for row in overrides.rows: if row.override_component == component: diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index dc50dd2d5..40be0a988 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -817,6 +817,116 @@ def test_every_resolvable_main_heating_value_decodes( assert simulation is not None +@pytest.mark.parametrize( + ("main_heating_value", "code"), + [ + # Oil / solid-fuel wet boilers off the fuel-agnostic gas default (ADR-0067). + # Modern/condensing codes (A-G deferred, like gas): SAP Table 4b 127 + # condensing oil regular, 130 condensing oil combi; Table 4a 151 manual-feed + # independent solid-fuel boiler (the conservative single member). + ("Oil boiler, regular", 127), + ("Oil boiler, combi", 130), + ("Solid fuel boiler", 151), + ], +) +def test_oil_and_solid_fuel_boilers_decode_to_their_boiler_codes( + main_heating_value: str, code: int +) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — its own boiler code, not the mains-gas default. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == code + + +@pytest.mark.parametrize( + ("main_heating_value", "fuel"), + [ + ("Oil boiler, regular", 28), # heating oil + ("Oil boiler, combi", 28), + ("Solid fuel boiler", 33), # house coal — the conservative solid default + ], +) +def test_oil_and_solid_fuel_boilers_drag_their_natural_fuel( + main_heating_value: str, fuel: int +) -> None: + # The archetype implies a fuel so a system-only override is self-coherent; the + # property's `main_fuel` override still wins by last-wins composition (ADR-0041). + + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_fuel_type == fuel + + +@pytest.mark.parametrize( + "main_heating_value", ["Oil boiler, regular", "Oil boiler, combi", "Solid fuel boiler"] +) +def test_fuel_boilers_drag_the_gas_pattern_boiler_companions( + main_heating_value: str, +) -> None: + # Modelled on the gas boiler (ADR-0067): SAP Table 4a category 2, full modern + # controls (Table 4e 2106), a single-rate meter — but NOT a forced gas + # connection (an oil/solid boiler needs no gas main, so it inherits the lodged + # EPC: `gas_connection_available` stays unset). + + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_category == 2 + assert simulation.heating.main_heating_control == 2106 + assert simulation.heating.meter_type == "Single" + assert simulation.heating.gas_connection_available is None + + +@pytest.mark.parametrize( + ("main_heating_value", "has_cylinder"), + [ + ("Oil boiler, regular", True), + ("Oil boiler, combi", False), # a combi heats hot water instantaneously + ("Solid fuel boiler", True), # no solid-fuel combi exists + ], +) +def test_fuel_boiler_cylinder_follows_combi( + main_heating_value: str, has_cylinder: bool +) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — hot water from the main system, with a cylinder unless a combi. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.has_hot_water_cylinder is has_cylinder + + +@pytest.mark.parametrize( + ("main_heating_value", "fan_flue"), + [ + ("Oil boiler, regular", True), # modern condensing oil: room-sealed/fan flue + ("Oil boiler, combi", True), + ("Solid fuel boiler", False), # solid fuel: conventional (non-fanned) flue + ], +) +def test_fuel_boiler_fan_flue_follows_fuel( + main_heating_value: str, fan_flue: bool +) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.fan_flue_present is fan_flue + + 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 diff --git a/tests/domain/epc/test_main_heating_system_type.py b/tests/domain/epc/test_main_heating_system_type.py new file mode 100644 index 000000000..b206b672b --- /dev/null +++ b/tests/domain/epc/test_main_heating_system_type.py @@ -0,0 +1,33 @@ +"""Contract tests for the `MainHeatingSystemType` Landlord-Override taxonomy. + +The oil / solid-fuel boiler family is lock-step with the app-side +`MainHeatingSystemValues` enum + migration 0279 (assessment-model#455, ADR-0002 / +ADR-0067): the string values here are the shared contract and must match +byte-for-byte. The classifier resolves a reply value to a member via +`MainHeatingSystemType(value)`, so "is this a recognised system" is exactly that +construction. +""" + +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) + +# The oil / solid-fuel boiler family, byte-for-byte with assessment-model#455. +_BOILER_VALUES: list[str] = [ + "Oil boiler, regular", + "Oil boiler, combi", + "Solid fuel boiler", +] + + +@pytest.mark.parametrize("value", _BOILER_VALUES) +def test_oil_and_solid_fuel_boiler_string_is_a_recognised_system(value: str) -> None: + # Act — a missing member raises ValueError (the classifier falls to UNKNOWN). + member = MainHeatingSystemType(value) + + # Assert — the canonical string round-trips as its own member value. + assert member.value == value diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index e35034ebc..e6e60b4e3 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -15,6 +15,7 @@ from domain.modelling.scoring.overlay_applicator import apply_simulations from repositories.property.landlord_override_overlays import ( flag_fuel_mismatch, overlays_from, + resolve_boiler_archetype, ) from repositories.property.property_overrides_reader import ( ResolvedPropertyOverride, @@ -25,6 +26,92 @@ from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( ) +@pytest.mark.parametrize( + ("heating_value", "fuel_value", "expected"), + [ + # A fuel-agnostic gas wet boiler re-points by the property's main_fuel + # family (ADR-0067). Oil preserves regular/combi; any solid fuel + # (coal/wood/dual) collapses to the single Solid fuel boiler member. + ("Gas boiler, regular", "oil", "Oil boiler, regular"), + ("Gas boiler, combi", "oil", "Oil boiler, combi"), + ("Gas boiler, regular", "house coal", "Solid fuel boiler"), + ("Gas boiler, regular", "wood logs", "Solid fuel boiler"), + ("Gas boiler, regular", "dual fuel (mineral and wood)", "Solid fuel boiler"), + ("Gas boiler, combi", "house coal", "Solid fuel boiler"), + ], +) +def test_resolve_boiler_archetype_repoints_a_gas_boiler_by_fuel( + heating_value: str, fuel_value: str, expected: str +) -> None: + # Act + resolved = resolve_boiler_archetype(heating_value, fuel_value) + + # Assert + assert resolved == expected + + +@pytest.mark.parametrize( + ("heating_value", "fuel_value"), + [ + # Gas / LPG stay on the gas archetype (RdSAP models LPG on gas), a missing + # fuel can't disambiguate, and a non-boiler archetype is never re-pointed. + ("Gas boiler, regular", "mains gas"), + ("Gas boiler, regular", "LPG (bulk)"), + ("Gas boiler, regular", None), + ("Electric room heaters", "oil"), + ("Solid fuel room heater, closed", "oil"), + ], +) +def test_resolve_boiler_archetype_leaves_other_cases_unchanged( + heating_value: str, fuel_value: str +) -> None: + # Act + resolved = resolve_boiler_archetype(heating_value, fuel_value) + + # Assert + assert resolved == heating_value + + +def test_gas_boiler_override_on_oil_fuel_builds_the_oil_boiler_overlay() -> None: + # ADR-0067: a fuel-agnostic gas-boiler override sitting on an oil `main_fuel` + # must resolve to the oil boiler at overlay time (the join reads the sibling + # fuel row), so it scores the oil boiler code (127), not the gas default (102). + + # Arrange + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride("main_heating_system", 0, "Gas boiler, regular"), + ResolvedPropertyOverride("main_fuel", 0, "oil"), + ) + ) + + # Act + overlays = overlays_from(overrides) + + # Assert — the heating overlay carries the oil boiler code. + heating = next(o.heating for o in overlays if o.heating is not None) + assert heating.sap_main_heating_code == 127 + + +def test_gas_boiler_override_on_gas_fuel_stays_a_gas_boiler() -> None: + # The join only fires for non-gas fuels; a genuine gas boiler is untouched. + + # Arrange + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride("main_heating_system", 0, "Gas boiler, regular"), + ResolvedPropertyOverride("main_fuel", 0, "mains gas"), + ) + ) + + # Act + overlays = overlays_from(overrides) + + # Assert — the gas regular condensing code (102). + heating = next(o.heating for o in overlays if o.heating is not None) + assert heating.sap_main_heating_code == 102 + + def test_roof_type_row_produces_a_roof_overlay() -> None: # Arrange overrides = ResolvedPropertyOverrides(