"""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_raises_on_an_incomplete_companion_set( monkeypatch: pytest.MonkeyPatch, ) -> None: # Every real archetype resolves a complete companion set, so the # log-and-continue mitigation has no remaining population and the # ADR-0048 end-state applies: an incomplete set FAILS LOUDLY instead of # shipping a silently-incoherent cert (ADR-0053, #1444 Part C). Simulated # with a hypothetical warm-air heat-pump archetype (SAP 521 — category 4 # but its Table 4e Group 5 control is deliberately unmapped): a future # archetype cannot land without choosing its companions. from domain.epc.property_overlays import main_heating_system_overlay as mod monkeypatch.setitem(mod._MAIN_HEATING_CODES, "Warm-air heat pump", 521) # Act / Assert — the raise names the archetype. with pytest.raises(ValueError, match="Warm-air heat pump"): main_heating_overlay_for("Warm-air heat pump", 0) @pytest.mark.parametrize( "main_heating_value", ["Community heating, boilers", "Community heating, CHP and boilers"], ) def test_community_heating_drags_the_default_group_3_control( main_heating_value: str, ) -> None: # The landlord names the scheme, not its control or charging arrangement. # The Table 4e Group 3 default is 2313 ("flat rate charging, room # thermostat and TRVs"): community schemes skew modern and the portfolio's # own lodged community certs are overwhelmingly thermostatted (modal 2306; # none lodge 2301), so the KIT axis concedes to the evidence — while the # CHARGING axis stays flat-rate (space 1.05 / DHW 1.05): the billing # arrangement is scheme paperwork an override genuinely cannot see, so # usage-linked (1.0/1.0) is never assumed (ADR-0053, decided with Khalim). # Leaving the control unset inherits the replaced system's instead (the # ADR-0048 bug class). # Act simulation = main_heating_overlay_for(main_heating_value, 0) # Assert — SAP Table 4e Group 3 default heat-network control. assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_heating_control == 2313 def test_air_source_heat_pump_drags_the_conservative_group_2_control() -> None: # The landlord names the system, not its control, so the overlay assumes # the conservative Table 4e Group 2 default: 2201 ("no time or thermostatic # control", +0.3 C — the largest Group 2 adjustment, so an unobserved # control is never over-credited; the heat-pump mirror of storage 2401 / # boiler 2101). Deliberately NOT the ASHP Measure's 2210 zone control — # a measure designs a modern end-state (ADR-0024); an override describes an # existing pump (ADR-0053). # Act simulation = main_heating_overlay_for("Air source heat pump", 0) # Assert — SAP Table 4e Group 2 conservative heat-pump control. assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_heating_control == 2201 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" @pytest.mark.parametrize( "main_heating_value", [ "Electric underfloor, in concrete slab (off-peak)", "Electric underfloor, integrated storage and direct-acting", "Electric underfloor, in screed above insulation", ], ) def test_electric_underfloor_overlays_the_underfloor_category( main_heating_value: str, ) -> None: # A system-replacing override must stamp the underfloor heating category # (SAP Table 4a Category 8). Leaving it unset lets the replaced system's # category survive onto the effective cert — property 711795's community # heating (category 6) survived an underfloor override, so the heating # generator read the dwelling as a heat network and offered no heating # upgrade at all, and the baseline mis-rated 51 vs 67 (ADR-0050). # Act simulation = main_heating_overlay_for(main_heating_value, 0) # Assert — SAP Table 4a Category 8 (Electric underfloor heating). assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_heating_category == 8 @pytest.mark.parametrize( "main_heating_value", [ "Electric underfloor, in concrete slab (off-peak)", "Electric underfloor, integrated storage and direct-acting", "Electric underfloor, in screed above insulation", ], ) def test_electric_underfloor_drags_the_conservative_group_7_control( main_heating_value: str, ) -> None: # The landlord names the system, not its control, so the overlay assumes the # conservative Table 4e Group 7 default: 2701 ("no time or thermostatic # control", +0.3 C — the largest adjustment in the group, so an unobserved # control is never over-credited). Mirrors storage-manual 2401 / room-heater # 2601; also overwrites a stale control inherited from the replaced system # (711795 kept a community control 2311) — ADR-0050. # Act simulation = main_heating_overlay_for(main_heating_value, 0) # Assert — SAP Table 4e Group 7 conservative underfloor control. assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_heating_control == 2701 @pytest.mark.parametrize( "main_heating_value", [ "Electric underfloor, in concrete slab (off-peak)", "Electric underfloor, integrated storage and direct-acting", "Electric underfloor, in screed above insulation", ], ) def test_complete_underfloor_overlay_does_not_log_a_companion_gap( main_heating_value: str, caplog: pytest.LogCaptureFixture ) -> None: # Electric underfloor now resolves a full companion set (category 8 + # control 2701, ADR-0050), so the overlay must NOT log an # incomplete-companion gap — guarding that the set stays complete. # Act with caplog.at_level(logging.ERROR): main_heating_overlay_for(main_heating_value, 0) # Assert assert "incomplete companion set" not in caplog.text 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 @pytest.mark.parametrize( "main_heating_value", ["Direct-acting electric", "Electric boiler", "Electric CPSU"], ) def test_electric_boiler_overlays_the_boiler_category( main_heating_value: str, ) -> None: # A system-replacing override must stamp the boiler heating category (SAP # Table 4a Category 2 — codes 191/192 are wet electric boilers). Leaving it # unset inherits the replaced system's: on portfolio 796, 30 of the first # 40 live 191/192 overrides carried an inherited category — 6 (community) # or 4 (heat pump) suppressed the HHRSH pathway entirely, and 7 (storage) # made the Table 12a resolver bill direct-acting heat 100% at the off-peak # low rate (ADR-0052, #1444). # Act simulation = main_heating_overlay_for(main_heating_value, 0) # Assert — SAP Table 4a Category 2 (boiler system). assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_heating_category == 2 @pytest.mark.parametrize( "main_heating_value", ["Direct-acting electric", "Electric boiler", "Electric CPSU"], ) def test_complete_electric_boiler_overlay_does_not_log_a_companion_gap( main_heating_value: str, caplog: pytest.LogCaptureFixture ) -> None: # Electric boilers now resolve a full companion set (category 2 + control # 2101, ADR-0052), so the overlay must NOT log an incomplete-companion gap # — guarding that the set stays complete. # Act with caplog.at_level(logging.ERROR): main_heating_overlay_for(main_heating_value, 0) # Assert assert "incomplete companion set" not in caplog.text @pytest.mark.parametrize( "main_heating_value", ["Direct-acting electric", "Electric boiler", "Electric CPSU"], ) def test_electric_boiler_drags_the_conservative_group_1_control( main_heating_value: str, ) -> None: # The landlord names the system, not its control, so the overlay assumes # the conservative Table 4e Group 1 default: 2101 ("no time or thermostatic # control", +0.6 C — the largest Group 1 adjustment, so an unobserved # control is never over-credited; the boiler mirror of storage 2401 / # room-heater 2601 / underfloor 2701). Supersedes the old "direct-acting # drags no control" pin: an unset control is not "no control" — it INHERITS # the replaced system's (a community 2306 or storage 2401 rode along on # portfolio 796). Deliberately NOT the gas boilers' modern 2106 — nothing # in the electric archetype implies a modern install (ADR-0052). # Act simulation = main_heating_overlay_for(main_heating_value, 0) # Assert — SAP Table 4e Group 1 conservative boiler control. assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_heating_control == 2101 @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_community_override_keeps_an_observed_group_3_control() -> None: # A community override on a cert that ALREADY lodges community heating is a # confirmation, not a system switch — the assessor observed the control # (e.g. 2306, "charging linked to use, programmer and TRVs", worth ~1-2 SAP # over the flat-rate default). Stamping the 2313 default over it would # destroy real information, so the overlay opts into keeping an existing # Table 4e GROUP 3 control (the heat-network mirror of # `keep_existing_off_peak_meter`, ADR-0053). # Arrange — a cert lodging community heating with an observed 2306. baseline = build_epc() main = baseline.sap_heating.main_heating_details[0] main.sap_main_heating_code = 301 main.main_heating_category = 6 main.main_heating_control = 2306 overlay = main_heating_overlay_for("Community heating, boilers", 0) assert overlay is not None # Act result = apply_simulations(baseline, [overlay]) # Assert — the observed usage-linked control survives. assert result.sap_heating.main_heating_details[0].main_heating_control == 2306 def test_community_override_replaces_a_cross_family_control() -> None: # When the replaced system was a DIFFERENT family, its control is stale by # definition — a Group-1 boiler zone control (2110) has no meaning on a # heat network (two live portfolio-796 dwellings carried exactly that) — # so the Group 3 default is stamped (ADR-0053). # Arrange — a cert lodging a gas boiler with zone control. baseline = build_epc() main = baseline.sap_heating.main_heating_details[0] main.sap_main_heating_code = 102 main.main_heating_category = 2 main.main_heating_control = 2110 overlay = main_heating_overlay_for("Community heating, boilers", 0) assert overlay is not None # Act result = apply_simulations(baseline, [overlay]) # Assert — the stale boiler control is replaced by the Group 3 default. assert result.sap_heating.main_heating_details[0].main_heating_control == 2313 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"