Model/tests/domain/epc/test_main_heating_system_overlay.py
Khalim Conn-Kowlessar 1552e24fa5 Recognise an air source heat pump via the default ASHP code 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:30:00 +00:00

470 lines
19 KiB
Python

"""The Landlord-Override main-heating-system → heating Simulation Overlay mapping.
A main-heating-system value resolves to the SAP `sap_main_heating_code` the
calculator reads from the primary system; the overlay is whole-dwelling.
"""
from __future__ import annotations
import pytest
from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType
from domain.epc.property_overlays.main_fuel_overlay import fuel_overlay_for
from domain.epc.property_overlays.main_heating_system_overlay import (
_ASSUMED_DUAL_METER_CODES,
_MAIN_HEATING_CODES,
main_heating_overlay_for,
)
from domain.epc.property_overlays.water_heating_overlay import (
water_heating_overlay_for,
)
from domain.modelling.scoring.overlay_applicator import apply_simulations
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
def test_gas_combi_overlays_the_primary_heating_code() -> None:
# Act
simulation = main_heating_overlay_for("Gas boiler, combi", 0)
# Assert — condensing combi is SAP Table 4b code 104.
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.sap_main_heating_code == 104
def test_electric_room_heaters_overlay_the_direct_acting_room_heater_code() -> None:
# Act — panel/convector/radiant direct-acting electric room heaters
simulation = main_heating_overlay_for("Electric room heaters", 0)
# Assert — SAP Table 4a code 691, NOT convector storage (403).
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.sap_main_heating_code == 691
def test_electric_room_heaters_assume_a_dual_economy7_meter() -> None:
# A dwelling on electric room heaters is all-electric and realistically
# billed on Economy 7 (its immersion hot water charges overnight; §12 Rule 3
# gives the room heaters a 10-hour off-peak window). When the landlord names
# only the system, the coherent meter to assume is Dual — the §12 dispatch
# then applies the realistic high/low split, not a single-rate over-penalty.
simulation = main_heating_overlay_for("Electric room heaters", 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.meter_type == "Dual"
@pytest.mark.parametrize(
("main_heating_value", "code"),
[
("Gas boiler, regular", 102),
("Gas CPSU", 120),
("Electric storage heaters, fan", 404),
("Direct-acting electric", 191),
],
)
def test_heating_archetypes_decode_to_their_sap_codes(
main_heating_value: str, code: int
) -> 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.sap_main_heating_code == code
@pytest.mark.parametrize(
("main_heating_value", "code"),
[
("Electric storage heaters, old", 401),
("Electric storage heaters, slimline", 402),
("Electric storage heaters, convector", 403),
],
)
def test_storage_heater_subtypes_decode_to_their_codes(
main_heating_value: str, code: int
) -> 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.sap_main_heating_code == code
@pytest.mark.parametrize(
"main_heating_value",
[
"Electric storage heaters, old",
"Electric storage heaters, slimline",
"Electric storage heaters, convector",
"Electric storage heaters, fan",
],
)
def test_storage_heaters_carry_an_off_peak_meter(main_heating_value: str) -> None:
# Storage heaters run on an Economy 7 (off-peak) tariff by design; setting
# only the heating code while leaving a single-rate meter bills every heating
# kWh at the peak rate and collapses the score (an override-set storage
# dwelling left on an oil donor's single meter scored SAP 13 vs ~50 on
# Economy 7). The overlay carries the off-peak meter, like the HHRSH measure.
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.meter_type == "Dual"
@pytest.mark.parametrize(
"main_heating_value", ["Gas boiler, combi", "Direct-acting electric"]
)
def test_non_storage_heating_resets_to_a_single_rate_meter(
main_heating_value: str,
) -> None:
# A single-rate system must drag a Single meter, not be left untouched: when
# the landlord switches OFF storage heaters the dwelling's old off-peak
# ("Dual") meter would otherwise bleed through and bill the new gas/direct-
# acting system on an Economy-7 split. Coherence is symmetric — off-peak
# codes synthesise Dual, every other code synthesises Single (ADR-0035).
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.meter_type == "Single"
@pytest.mark.parametrize(
"main_heating_value",
[
"Electric storage heaters, old",
"Electric storage heaters, slimline",
"Electric storage heaters, convector",
"Electric storage heaters, fan",
],
)
def test_storage_heaters_drag_along_conservative_manual_charge_control(
main_heating_value: str,
) -> None:
# The landlord names the system, not its charge control. Manual charge
# control (Table 4e code 2401, +0.7 C MIT adjustment) is the lowest-SAP
# storage control, so it's the safe assumption that never over-credits.
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.main_heating_control == 2401
def test_direct_acting_electric_does_not_drag_along_a_control() -> None:
# Charge control is a storage concept and full boiler controls are a wet-
# system concept; direct-acting electric panel heaters take neither, so the
# overlay leaves the control untouched.
simulation = main_heating_overlay_for("Direct-acting electric", 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.main_heating_control is None
@pytest.mark.parametrize(
"main_heating_value", ["Gas boiler, combi", "Gas boiler, regular", "Gas CPSU"]
)
def test_gas_boiler_assumes_full_modern_controls(main_heating_value: str) -> None:
# The landlord names the boiler, not its controls. A gas boiler installed
# under modern Building Regs carries programmer + room thermostat + TRVs
# (SAP Table 4c code 2106), and the overlay already assumes the modern
# condensing efficiency for the boiler — so assuming modern controls is the
# coherent, consistent default (Khalim). It also overwrites any stale storage
# charge control (2401) the dwelling carried before the switch.
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.main_heating_control == 2106
@pytest.mark.parametrize(
"main_heating_value", ["Gas boiler, combi", "Gas boiler, regular", "Gas CPSU"]
)
def test_gas_boiler_drags_a_coherent_mains_gas_system(main_heating_value: str) -> None:
# A gas boiler implies a mains-gas connection and a gas main fuel, a gas-
# boiler heating category (Table 4a cat 2), and a fanned room-sealed flue
# (a modern condensing boiler). Setting these on the system overlay makes a
# heating-system-only override self-coherent — it does not depend on a
# separate main_fuel override also being present.
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
heating = simulation.heating
assert heating.mains_gas is True
assert heating.main_fuel_type == 26
assert heating.main_heating_category == 2
assert heating.fan_flue_present is True
def test_gas_combi_has_no_hot_water_cylinder() -> None:
# A combi heats hot water instantaneously — there is no cylinder. Elmhurst
# greys out the cylinder choice for a combi (Khalim). The overlay clears the
# cylinder + routes hot water from the main system so a storage dwelling's
# old electric-immersion cylinder doesn't bleed through.
simulation = main_heating_overlay_for("Gas boiler, combi", 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.has_hot_water_cylinder is False
assert simulation.heating.water_heating_code == 901
assert simulation.heating.water_heating_fuel == 26
@pytest.mark.parametrize("main_heating_value", ["Gas boiler, regular", "Gas CPSU"])
def test_regular_gas_boiler_keeps_a_hot_water_cylinder(
main_heating_value: str,
) -> None:
# A regular boiler (and a gas CPSU) heats a hot-water cylinder from the main
# system. Elmhurst lets you pick a cylinder for a regular condensing boiler
# (Khalim).
simulation = main_heating_overlay_for(main_heating_value, 0)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.has_hot_water_cylinder is True
assert simulation.heating.water_heating_code == 901
assert simulation.heating.water_heating_fuel == 26
def test_off_peak_archetypes_drag_dual_others_drag_single() -> None:
# Contract (the drag-along guard): the meter is derived from the SAP code via
# the calculator's single off-peak classification, so any archetype whose
# code implies off-peak MUST synthesise a Dual meter and every other code
# MUST synthesise a Single meter — a system switch can never silently leave
# the previous system's meter in place.
for value, code in _MAIN_HEATING_CODES.items():
simulation = main_heating_overlay_for(value, 0)
assert simulation is not None and simulation.heating is not None
expected = "Dual" if code in _ASSUMED_DUAL_METER_CODES else "Single"
assert simulation.heating.meter_type == expected, value
@pytest.mark.parametrize(
"main_heating_value",
["Unknown", "", "Community heating"],
)
def test_unresolvable_or_unmodelled_heating_produces_no_overlay(
main_heating_value: str,
) -> None:
# 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)
# Assert
assert simulation is None
def test_main_heating_override_remaps_the_primary_system_code() -> None:
# Arrange
baseline = build_epc()
overlay = main_heating_overlay_for("Gas boiler, regular", 0)
assert overlay is not None
# Act
result = apply_simulations(baseline, [overlay])
# Assert — the calculator reads the code off main_heating_details[0].
assert result.sap_heating.main_heating_details[0].sap_main_heating_code == 102
def test_gas_boiler_override_onto_a_storage_baseline_leaves_no_stale_fields() -> None:
# Regression for property 728513: a landlord reclassified an electric-storage
# flat as a gas combi via the main_fuel + water_heating + main_heating_system
# overrides. Before the coherent drag-along the override set only the heating
# code + fuel and left the storage system's fields in place — mains_gas False,
# category 7, the 2401 charge control, a Dual meter, an electric-immersion
# cylinder — an incoherent record that gated out the gas-boiler path and made
# the engine read the dwelling as off-gas (offering HHRSH). The override must
# now overwrite ALL of them, regardless of the (undefined) overlay order.
baseline = build_epc()
overlays = [
fuel_overlay_for("mains gas", 0),
water_heating_overlay_for("From main system, mains gas", 0),
main_heating_overlay_for("Gas boiler, combi", 0),
]
assert all(o is not None for o in overlays)
# Act
result = apply_simulations(baseline, [o for o in overlays if o is not None])
# Assert — a fully coherent mains-gas combi, no electric-storage residue.
main = result.sap_heating.main_heating_details[0]
assert main.sap_main_heating_code == 104
assert main.main_fuel_type == 26
assert main.main_heating_category == 2
assert main.main_heating_control == 2106
assert result.sap_energy_source.mains_gas is True
assert result.sap_energy_source.meter_type == "Single"
assert result.has_hot_water_cylinder is False
assert result.sap_heating.water_heating_code == 901
assert result.sap_heating.water_heating_fuel == 26
def test_the_three_heating_overrides_compose_without_conflict() -> None:
# Arrange — main_fuel, water_heating and main_heating_system all fold onto one
# HeatingOverlay surface but set DISJOINT fields, so they compose (the
# field-disjoint design that makes precedence moot for these three).
baseline = build_epc()
overlays = [
fuel_overlay_for("electricity", 0),
water_heating_overlay_for("Electric immersion, electricity", 0),
main_heating_overlay_for("Electric storage heaters, fan", 0),
]
assert all(o is not None for o in overlays)
# Act
result = apply_simulations(baseline, [o for o in overlays if o is not None])
# Assert — each override landed on its own field.
main = result.sap_heating.main_heating_details[0]
assert main.main_fuel_type == 29
assert main.sap_main_heating_code == 404
assert result.sap_heating.water_heating_code == 903
assert result.sap_heating.water_heating_fuel == 29
def test_room_heaters_preserve_an_existing_more_off_peak_cert_meter() -> None:
# The overlay's assumed Dual (7-hour E7) meter is a coherent *default* for a
# single/unknown-meter dwelling — it must NOT downgrade a cert that already
# lodges a more-off-peak meter (here a 24-hour all-low tariff, code "4").
# Clobbering it to E7 would bill the heating on a high/low split it doesn't
# have, under-rating the dwelling.
baseline = build_epc()
baseline.sap_energy_source.meter_type = "4" # 24-hour tariff
overlay = main_heating_overlay_for("Electric room heaters", 0)
assert overlay is not None
result = apply_simulations(baseline, [overlay])
assert result.sap_energy_source.meter_type == "4"
def test_room_heaters_set_dual_when_the_cert_meter_is_single() -> None:
# The flip side: a single-rate dwelling DOES get the assumed Dual meter —
# off-peak heating can't be billed on a single-rate meter (ADR-0035 drag).
baseline = build_epc()
baseline.sap_energy_source.meter_type = "Single"
overlay = main_heating_overlay_for("Electric room heaters", 0)
assert overlay is not None
result = apply_simulations(baseline, [overlay])
assert result.sap_energy_source.meter_type == "Dual"
def test_electric_room_heaters_member_decodes_to_the_room_heater_code() -> None:
# Arrange — the canonical landlord archetype for direct-acting room heaters
member = MainHeatingSystemType.ELECTRIC_ROOM_HEATERS
# Act
simulation = main_heating_overlay_for(member.value, 0)
# Assert — member value stays in lock-step with the overlay (code 691)
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.sap_main_heating_code == 691
@pytest.mark.parametrize(
"member",
[m for m in MainHeatingSystemType if m is not MainHeatingSystemType.UNKNOWN],
)
def test_every_resolvable_main_heating_value_decodes(
member: MainHeatingSystemType,
) -> None:
# Act
simulation = main_heating_overlay_for(member.value, 0)
# 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