Merge pull request #1678 from Hestia-Homes/feat/boiler-fuel-archetypes

Oil & solid-fuel boiler archetypes, fuel-resolved (issue #1676)
This commit is contained in:
KhalimCK 2026-07-24 14:32:55 +01:00 committed by GitHub
commit 68a147edac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 485 additions and 2 deletions

View file

@ -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 (AG 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 AG 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.

View file

@ -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:

View file

@ -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"

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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(