mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
commit
68a147edac
7 changed files with 485 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
tests/domain/epc/test_main_heating_system_type.py
Normal file
33
tests/domain/epc/test_main_heating_system_type.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue