"""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", "", "Air source heat pump", "Community heating"], ) def test_unresolvable_or_unmodelled_heating_produces_no_overlay( main_heating_value: str, ) -> None: # Heat pumps (main_heating_index_number) and community heating (community # codes) don't map to a Table 4b sap_main_heating_code yet — no overlay. # 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