mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
833 lines
34 KiB
Python
833 lines
34 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 logging
|
|
|
|
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,
|
|
_METER_AGNOSTIC_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"
|
|
|
|
|
|
def test_electric_room_heaters_overlay_the_room_heater_category() -> None:
|
|
# A landlord "Electric room heaters" override replaces the lodged system, so
|
|
# the overlay must stamp the room-heater heating category (SAP Table 4a
|
|
# Category 10). Leaving it unset lets a replaced storage heater's category 7
|
|
# survive onto the effective cert, which the SAP 10.2 Table 12a resolver
|
|
# (keyed on category) mis-reads as off-peak storage and bills the peaky room
|
|
# heaters at the all-night low rate.
|
|
# Act
|
|
simulation = main_heating_overlay_for("Electric room heaters", 0)
|
|
|
|
# Assert — SAP Table 4a Category 10 (Room heaters).
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.main_heating_category == 10
|
|
|
|
|
|
def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None:
|
|
# A system-replacing override must stamp its own control, not inherit the
|
|
# replaced system's. Electric room heaters take the Table 4e Group 6
|
|
# room-heater control 2601 (+0.3 C, the conservative no-thermostat default,
|
|
# matching the gas/oil/solid room heaters). Leaving it unset lets a replaced
|
|
# storage heater's manual charge control 2401 (+0.7 C) survive and over-warm
|
|
# the modelled dwelling.
|
|
# Act
|
|
simulation = main_heating_overlay_for("Electric room heaters", 0)
|
|
|
|
# Assert — SAP Table 4e Group 6 room-heater control.
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.main_heating_control == 2601
|
|
|
|
|
|
def test_overlay_logs_when_it_cannot_complete_a_companion_set(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
# An archetype the overlay cannot yet fully companion — here an air-source
|
|
# heat pump, whose Table 4e Group 2 conservative control is not yet mapped —
|
|
# is still overlaid (log-and-continue), but the incomplete companion set is
|
|
# logged as an error so the known gap is visible (CloudWatch) rather than
|
|
# silently inheriting the replaced system's stale value.
|
|
# Act
|
|
with caplog.at_level(logging.ERROR):
|
|
main_heating_overlay_for("Air source heat pump", 0)
|
|
|
|
# Assert — the gap is reported and names the archetype.
|
|
assert "Air source heat pump" in caplog.text
|
|
|
|
|
|
def test_complete_room_heater_overlay_does_not_log_a_companion_gap(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
# Electric room heaters now resolve a full companion set (category 10 +
|
|
# control 2601), so the overlay must NOT log an incomplete-companion gap —
|
|
# guarding that the fix keeps the set complete.
|
|
# Act
|
|
with caplog.at_level(logging.ERROR):
|
|
main_heating_overlay_for("Electric room heaters", 0)
|
|
|
|
# Assert
|
|
assert "incomplete companion set" not in caplog.text
|
|
|
|
|
|
@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"),
|
|
[
|
|
# Solid-fuel room heaters the LLM funnelled into "Gas CPSU" (120, mains
|
|
# gas). SAP Table 4a: 631 open fire, 632 open fire + back boiler, 634
|
|
# closed room heater + boiler (ADR-0045).
|
|
("Solid fuel room heater, open fire", 631),
|
|
("Solid fuel room heater, open fire with back boiler", 632),
|
|
("Solid fuel room heater, closed with boiler", 634),
|
|
],
|
|
)
|
|
def test_solid_fuel_room_heaters_decode_off_gas_cpsu(
|
|
main_heating_value: str, code: int
|
|
) -> None:
|
|
# Act
|
|
simulation = main_heating_overlay_for(main_heating_value, 0)
|
|
|
|
# Assert — its own solid-fuel code, not mains-gas CPSU (120).
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.sap_main_heating_code == code
|
|
|
|
|
|
def test_electric_boiler_decodes_to_191_and_drags_electricity() -> None:
|
|
# An "A rated NA" boiler (all-electric per its main_fuel override) was funnelled
|
|
# into Gas CPSU (120, mains gas). It is a direct-acting electric boiler (SAP 191)
|
|
# and unambiguously electric, so the archetype drags electricity (29) — never
|
|
# leaving a mains-gas fuel to bleed through (ADR-0045).
|
|
simulation = main_heating_overlay_for("Electric boiler", 0)
|
|
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.sap_main_heating_code == 191
|
|
assert simulation.heating.main_fuel_type == 29
|
|
|
|
|
|
def test_electric_cpsu_decodes_to_192_electric_on_an_off_peak_meter() -> None:
|
|
# An electric "Boiler: A rated CPSU" was funnelled into mains-gas Gas CPSU
|
|
# (120). Electric CPSU is SAP Table 4a 192, electric (29), charging overnight on
|
|
# a Dual off-peak meter (§12 Rule 1, 10-hour) — ADR-0045.
|
|
simulation = main_heating_overlay_for("Electric CPSU", 0)
|
|
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.sap_main_heating_code == 192
|
|
assert simulation.heating.main_fuel_type == 29
|
|
assert simulation.heating.meter_type == "Dual"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("main_heating_value", "code"),
|
|
[
|
|
# Electric underfloor funnelled into Direct-acting (191) / old storage (401)
|
|
# for want of the dedicated SAP Table 4a underfloor codes (ADR-0046).
|
|
("Electric underfloor, in concrete slab (off-peak)", 421),
|
|
("Electric underfloor, integrated storage and direct-acting", 422),
|
|
("Electric underfloor, in screed above insulation", 424),
|
|
],
|
|
)
|
|
def test_electric_underfloor_decodes_to_its_own_code_dragging_electricity(
|
|
main_heating_value: str, code: int
|
|
) -> None:
|
|
# Act
|
|
simulation = main_heating_overlay_for(main_heating_value, 0)
|
|
|
|
# Assert — its own underfloor code, electric (29).
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.sap_main_heating_code == code
|
|
assert simulation.heating.main_fuel_type == 29
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"main_heating_value",
|
|
[
|
|
# "off-peak only" / "off peak" positively claim off-peak.
|
|
"Electric underfloor, in concrete slab (off-peak)",
|
|
"Electric underfloor, integrated storage and direct-acting",
|
|
],
|
|
)
|
|
def test_off_peak_underfloor_asserts_a_dual_meter(main_heating_value: str) -> None:
|
|
# Act
|
|
simulation = main_heating_overlay_for(main_heating_value, 0)
|
|
|
|
# Assert — an off-peak underfloor must not bill overnight heat at the peak rate.
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.meter_type == "Dual"
|
|
|
|
|
|
def test_screed_underfloor_defers_the_meter_to_the_cert() -> None:
|
|
# "In screed above insulation (standard or off peak)" is tariff-ambiguous (81%
|
|
# of the corpus lodge off-peak), so the archetype cannot know the meter — it
|
|
# leaves meter_type unset so the cert's lodged meter stands (ADR-0046).
|
|
simulation = main_heating_overlay_for(
|
|
"Electric underfloor, in screed above insulation", 0
|
|
)
|
|
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.meter_type is None
|
|
|
|
|
|
@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
|
|
|
|
|
|
def test_high_heat_retention_storage_decodes_to_its_own_code_not_old_storage() -> None:
|
|
# Act
|
|
simulation = main_heating_overlay_for(
|
|
"Electric storage heaters, high heat retention", 0
|
|
)
|
|
|
|
# Assert — HHRSH is SAP Table 4a code 409, NOT old large-volume storage (401)
|
|
# the classifier funnelled it into (ADR-0044).
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.sap_main_heating_code == 409
|
|
|
|
|
|
def test_high_heat_retention_storage_drags_its_intrinsic_companions() -> None:
|
|
# HHRSH has a single intrinsic charge control (Table 4e 2404, 0 C adjustment),
|
|
# so — unlike a generic storage heater whose control is unobserved — it must
|
|
# NOT drag the conservative manual default (2401, +0.7 C penalty), which would
|
|
# under-credit it. It is electric, on an off-peak (Dual) meter (ADR-0044).
|
|
simulation = main_heating_overlay_for(
|
|
"Electric storage heaters, high heat retention", 0
|
|
)
|
|
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.main_heating_control == 2404
|
|
assert simulation.heating.main_fuel_type == 29
|
|
assert simulation.heating.meter_type == "Dual"
|
|
# Storage category (7) — consistent with storage code 409, so the SAP 10.2
|
|
# Table 12a resolver prices the off-peak storage heating off-peak, not peak.
|
|
assert simulation.heating.main_heating_category == 7
|
|
|
|
|
|
@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
|
|
# ...and the storage category (7), consistent with the storage code, so the
|
|
# SAP 10.2 Table 12a resolver prices these off-peak, not at the peak rate.
|
|
assert simulation.heating.main_heating_category == 7
|
|
|
|
|
|
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.gas_connection_available 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. The one exception is a meter-agnostic
|
|
# code (screed underfloor, tariff-ambiguous), which defers to the cert's
|
|
# lodged meter (None) rather than assert one (ADR-0046).
|
|
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
|
|
if code in _METER_AGNOSTIC_CODES:
|
|
expected = None
|
|
elif code in _ASSUMED_DUAL_METER_CODES:
|
|
expected = "Dual"
|
|
else:
|
|
expected = "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.gas_connection_available 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
|
|
|
|
|
|
def test_air_source_heat_pump_drags_its_coherent_companions() -> None:
|
|
# A heat pump is unambiguously electric (natural fuel 29) and SAP Table 4a
|
|
# category 4. Unlike storage/CPSU it does NOT imply an off-peak tariff (SAP
|
|
# §12 Rule 3 is conditional, not forcing), so the coherent default meter is
|
|
# single-rate — forcing Dual would assume an off-peak split it may not have
|
|
# and mis-bill it. The overlay drags these code-derived companions so a
|
|
# system-only override is self-coherent (ADR-0035 / ADR-0041).
|
|
|
|
# Act
|
|
simulation = main_heating_overlay_for("Air source heat pump", 0)
|
|
|
|
# Assert
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
heating = simulation.heating
|
|
assert heating.main_heating_category == 4
|
|
assert heating.main_fuel_type == 29
|
|
assert heating.meter_type == "Single"
|
|
|
|
|
|
def test_community_boilers_decode_to_the_heat_network_boiler_code() -> None:
|
|
# A community/heat-network scheme driven by boilers is SAP Table 4a code 301
|
|
# (the calculator derives the heat-source efficiency + DLF from it). It must
|
|
# decode to 301, not the Gas CPSU (120) the under-populated taxonomy forced —
|
|
# a single-dwelling gas wet boiler is the wrong picture for a heat network
|
|
# (ADR-0041). A generic "Community heating" with no named source stays None.
|
|
|
|
# Act
|
|
simulation = main_heating_overlay_for("Community heating, boilers", 0)
|
|
|
|
# Assert — SAP Table 4a code 301 (boiler-driven community heating).
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.sap_main_heating_code == 301
|
|
|
|
|
|
def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() -> (
|
|
None
|
|
):
|
|
# A community boiler scheme is SAP main_heating_category 6 (heat network), so
|
|
# the calculator treats it as a heat network (cert_to_inputs `_is_heat_network`
|
|
# checks code OR category 6). Its natural fuel is mains gas (community) — RdSAP
|
|
# main_fuel code 20, the dominant fuel real community-boiler certs lodge. A
|
|
# specific main_fuel override (e.g. biomass community) still wins (ADR-0041).
|
|
|
|
# Act
|
|
simulation = main_heating_overlay_for("Community heating, boilers", 0)
|
|
|
|
# Assert
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
heating = simulation.heating
|
|
assert heating.main_heating_category == 6
|
|
assert heating.main_fuel_type == 20
|
|
|
|
|
|
def test_community_chp_and_boilers_decode_to_their_heat_network_code() -> None:
|
|
# A community scheme with CHP + boilers is SAP Table 4a code 302, still a heat
|
|
# network (category 6) on community mains gas (20). Real audit data: 10
|
|
# properties carry this, today mis-bucketed to Gas CPSU (ADR-0041).
|
|
|
|
# Act
|
|
simulation = main_heating_overlay_for("Community heating, CHP and boilers", 0)
|
|
|
|
# Assert
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
heating = simulation.heating
|
|
assert heating.sap_main_heating_code == 302
|
|
assert heating.main_heating_category == 6
|
|
assert heating.main_fuel_type == 20
|
|
|
|
|
|
def test_oil_room_heater_decodes_to_the_post_2000_code() -> None:
|
|
# A modern standalone oil room heater is SAP Table 4a code 623 ("Oil room
|
|
# heater, 2000 or later", no boiler). It must decode to 623, not the Gas CPSU
|
|
# the under-populated taxonomy forced (ADR-0041).
|
|
|
|
# Act
|
|
simulation = main_heating_overlay_for("Oil room heater, 2000 or later", 0)
|
|
|
|
# Assert — SAP Table 4a code 623.
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.sap_main_heating_code == 623
|
|
|
|
|
|
def test_oil_room_heater_drags_its_coherent_room_heater_companions() -> None:
|
|
# An oil room heater is a room-heater system (category 10) on a single-rate
|
|
# meter, under the conservative room-heater control (2601), with its natural
|
|
# fuel heating oil (RdSAP main_fuel 28). The overlay drags these so a
|
|
# system-only override is self-coherent (ADR-0035 / ADR-0041).
|
|
|
|
# Act
|
|
simulation = main_heating_overlay_for("Oil room heater, 2000 or later", 0)
|
|
|
|
# Assert
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
heating = simulation.heating
|
|
assert heating.main_heating_category == 10
|
|
assert heating.main_heating_control == 2601
|
|
assert heating.main_fuel_type == 28
|
|
assert heating.meter_type == "Single"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "code"),
|
|
[
|
|
("Gas room heater, condensing fire", 611),
|
|
("Gas room heater, decorative fuel-effect", 612),
|
|
("Gas room heater, flush live-effect", 605),
|
|
("Gas room heater, open flue 1980 or later", 603),
|
|
("Gas room heater, open flue pre-1980", 601),
|
|
],
|
|
)
|
|
def test_gas_room_heater_variants_decode_to_their_sap_codes(
|
|
value: str, code: int
|
|
) -> None:
|
|
# Gas room heaters span a wide efficiency range (decorative 0.20 -> condensing
|
|
# 0.85), so each catalogue variant maps to its own SAP Table 4a code rather
|
|
# than collapsing to one representative — which would over-credit a decorative
|
|
# fire and under-credit a condensing one. Today they mis-bucket to "Gas
|
|
# boiler, regular" (ADR-0041).
|
|
|
|
# Act
|
|
simulation = main_heating_overlay_for(value, 0)
|
|
|
|
# Assert
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
assert simulation.heating.sap_main_heating_code == code
|
|
|
|
|
|
def test_gas_room_heater_drags_its_coherent_companions() -> None:
|
|
# A gas room heater is a room-heater system (category 10) on a single-rate
|
|
# meter, under the conservative room-heater control (2601). Its natural fuel
|
|
# is mains gas (26) — an LPG dwelling is refined by a main_fuel override
|
|
# (the overlay can't see the mains connection) (ADR-0041).
|
|
|
|
# Act
|
|
simulation = main_heating_overlay_for("Gas room heater, condensing fire", 0)
|
|
|
|
# Assert
|
|
assert simulation is not None
|
|
assert simulation.heating is not None
|
|
heating = simulation.heating
|
|
assert heating.main_heating_category == 10
|
|
assert heating.main_heating_control == 2601
|
|
assert heating.main_fuel_type == 26
|
|
assert heating.meter_type == "Single"
|