"""Behaviour of the heating Recommendation Generator: detecting a dwelling whose heating system should be replaced and emitting one "Heating & Hot Water" Recommendation of competing whole-system bundles (ADR-0024). This slice covers the high-heat-retention storage (HHRSH) bundle; ASHP and boiler bundles land in later slices. Detection + pricing only; impact is produced by scoring (ADR-0016). """ from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.generators.heating_recommendation import ( recommend_heating, select_ashp_pcdb_id, ) from domain.modelling.measure_type import MeasureType from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation from domain.modelling.simulation import HeatingOverlay from repositories.product.product_repository import ( ProductNotFound, ProductRepository, ) from tests.domain.modelling._elmhurst_recommendation import ( parse_recommendation_summary, ) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) # Electricity fuel codes and the Table 4a SAP code an existing (non-HHR) # electric storage system lodges. 30 is the SAP Table 12 code (used by # Elmhurst / cascade); 29 is the raw gov-API enum that some certs carry. _ELECTRICITY = 30 _API_ELECTRICITY = 29 _OLD_STORAGE_SAP_CODE = 402 def _hhr_measure_type_in(rec: Recommendation | None) -> bool: if rec is None: return False return MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS in { o.measure_type for o in rec.options } def _electric_storage_baseline() -> EpcPropertyData: """A 000490 dwelling re-cast as an existing (non-HHR) electric storage system: electric main fuel, Table 4a code 402.""" epc: EpcPropertyData = build_epc() main = epc.sap_heating.main_heating_details[0] main.main_fuel_type = _ELECTRICITY main.sap_main_heating_code = _OLD_STORAGE_SAP_CODE return epc class _StubProducts(ProductRepository): """In-memory ProductRepository returning a fixed HHRSH product cost.""" def get(self, measure_type: str) -> Product: return Product( measure_type=measure_type, unit_cost_per_m2=3500.0, contingency_rate=0.26 ) def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None: # Arrange — an existing electric storage system (not HHR). baseline: EpcPropertyData = _electric_storage_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — one "Heating & Hot Water" Recommendation carrying the HHRSH # bundle, whose overlay is the absolute HHR end-state. assert recommendation is not None assert recommendation.surface == "Heating & Hot Water" options = {o.measure_type.value: o for o in recommendation.options} assert "high_heat_retention_storage_heaters" in options assert options[ "high_heat_retention_storage_heaters" ].overlay.heating == HeatingOverlay( main_fuel_type=30, sap_main_heating_code=409, # Storage category (7) pins the fix for the off-peak-pricing bug: the # overlay must set it so the Table 12a resolver classifies the off-peak # storage heating as storage (0.00 high-rate) and not direct-acting # electric (1.00) via a stale category. See test_cert_to_inputs # `test_storage_heater_classified_by_sap_code_over_stale_category`. main_heating_category=7, main_heating_control=2404, water_heating_code=903, water_heating_fuel=30, cylinder_size=2, cylinder_insulation_type=1, cylinder_insulation_thickness_mm=120, cylinder_thermostat="Y", immersion_heating_type=1, has_hot_water_cylinder=True, meter_type="Dual", ) def test_on_gas_boiler_dwelling_yields_no_hhr_storage_bundle() -> None: # Arrange — 000490 is a mains-gas combi (fuel 26, mains_gas True). HHR # storage suits off-gas / electric dwellings, not an on-gas gas boiler. baseline: EpcPropertyData = build_epc() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — no HHRSH bundle (no other bundle is built in this slice → None). if recommendation is not None: assert "high_heat_retention_storage_heaters" not in { o.measure_type for o in recommendation.options } def test_already_hhr_storage_dwelling_yields_no_hhr_bundle() -> None: # Arrange — an electric dwelling already on HHR storage (Table 4a code 409) # must not be told to install HHR storage again (ASHP may still be offered as # a better end-state — it is a house here). baseline: EpcPropertyData = _electric_storage_baseline() baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 409 # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — no HHRSH bundle. if recommendation is not None: assert "high_heat_retention_storage_heaters" not in { o.measure_type for o in recommendation.options } def test_existing_heat_pump_dwelling_yields_no_hhr_storage_bundle() -> None: # Arrange — an electric dwelling already on a heat pump (category 4) is never # downgraded to storage heaters. baseline: EpcPropertyData = _electric_storage_baseline() baseline.sap_heating.main_heating_details[0].main_heating_category = 4 # Act / Assert assert recommend_heating(baseline, _StubProducts()) is None def test_hhr_storage_bundle_carries_the_product_cost_and_contingency() -> None: # Arrange baseline: EpcPropertyData = _electric_storage_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — the bundle's fully-loaded cost + contingency come from the product. assert recommendation is not None option = next( o for o in recommendation.options if o.measure_type == "high_heat_retention_storage_heaters" ) assert option.cost is not None assert abs(option.cost.total - 3500.0) <= 1e-9 assert abs(option.cost.contingency_rate - 0.26) <= 1e-9 def test_on_gas_street_electric_dwelling_yields_hhr_storage_bundle() -> None: # Arrange — electrically heated dwelling whose cert carries the raw API # fuel code (29) instead of the SAP Table 12 code (30), AND has a mains- # gas connection at the street (mains_gas=True, the default for 000490). # Bug: off_gas = not True = False; electric_main = 29 == 30 = False → # HHRSH incorrectly blocked. Fix: key on main_fuel_type not in _GAS_FUEL_CODES. baseline: EpcPropertyData = _electric_storage_baseline() baseline.sap_heating.main_heating_details[0].main_fuel_type = _API_ELECTRICITY # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — dwelling heats electrically (not with gas) → HHRSH must be offered. assert recommendation is not None options = {o.measure_type.value: o for o in recommendation.options} assert "high_heat_retention_storage_heaters" in options def test_community_heated_dwelling_yields_no_hhr_storage_bundle() -> None: # Arrange — community boiler network (SAP code 301, category 6) with # electricity as the upstream heat source fuel (code 41): not gas, so the # fuel gate alone would pass — the topology gate must block it. epc = build_epc() main = epc.sap_heating.main_heating_details[0] main.sap_main_heating_code = 301 main.main_heating_category = 6 main.main_fuel_type = 41 # Act / Assert assert _hhr_measure_type_in(recommend_heating(epc, _StubProducts())) is False def _gas_boiler_house() -> EpcPropertyData: """A 000490 mains-gas combi dwelling, explicitly a House — ASHP-eligible.""" epc: EpcPropertyData = build_epc() epc.property_type = "House" return epc def test_gas_boiler_house_yields_an_ashp_bundle() -> None: # Arrange — a mains-gas house; ASHP is offered for any non-flat house/ # bungalow regardless of current system or efficiency (ADR-0024). baseline: EpcPropertyData = _gas_boiler_house() # Act — a 5.5 kW design heat loss sizes to the 5 kW aroTHERM plus (110257): # PSR-0.8 target ~4.4 kW, nearest the 4.37 kW rung. recommendation: Recommendation | None = recommend_heating( baseline, _StubProducts(), design_heat_loss_kw=5.5 ) # Assert — the ASHP bundle carries the absolute heat-pump end-state. assert recommendation is not None options = {o.measure_type.value: o for o in recommendation.options} assert "air_source_heat_pump" in options assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay( main_fuel_type=30, main_heating_control=2210, main_heating_index_number=110257, main_heating_category=4, water_heating_code=901, water_heating_fuel=30, cylinder_size=4, cylinder_insulation_type=1, cylinder_insulation_thickness_mm=50, cylinder_thermostat="Y", has_hot_water_cylinder=True, meter_type="Single", gas_connection_available=False, ) class _StubProductsWithoutAshp(ProductRepository): """A catalogue with no ASHP row. ASHP's cost is composed from the rate sheet (ADR-0025) and the catalogue row is read only for its id, so a missing row must not suppress the bundle — it just carries no material_id.""" def get(self, measure_type: str) -> Product: if measure_type == MeasureType.AIR_SOURCE_HEAT_PUMP: raise ProductNotFound(f"no active product for {measure_type!r}") return Product( measure_type=measure_type, unit_cost_per_m2=3500.0, contingency_rate=0.26 ) def test_ashp_bundle_offered_when_catalogue_lacks_an_ashp_product() -> None: # Arrange — a mains-gas house (ASHP-eligible) priced against a catalogue with # no ASHP row; ASHP is costed from the rate sheet, so the bundle must still # be offered, just without a material id. baseline: EpcPropertyData = _gas_boiler_house() # Act recommendation: Recommendation | None = recommend_heating( baseline, _StubProductsWithoutAshp() ) # Assert — the ASHP bundle is still offered, carrying its composite cost and # no material id. assert recommendation is not None option = next( o for o in recommendation.options if o.measure_type == "air_source_heat_pump" ) assert option.material_id is None assert option.cost is not None assert option.cost.total > 0.0 def test_ashp_bundle_carries_the_composite_per_dwelling_cost() -> None: # Arrange — a mains-gas regular boiler with a cylinder (90 m2, 7 habitable # rooms): the ASHP reuses the existing wet system (ADR-0025). epc: EpcPropertyData = parse_recommendation_summary( "ashp_from_system_boiler_with_cylinder_001431_before.pdf" ) # Act recommendation: Recommendation | None = recommend_heating(epc, _StubProducts()) assert recommendation is not None option = next( o for o in recommendation.options if o.measure_type == "air_source_heat_pump" ) # Assert — composite: decommission (gas) 720 + pump (4.5 kW band) 9720 + # cylinder 2382.60 + reuse distribution (flush 168 + 0.5 x dist(10) 5220 = # 2778) = 15600.60, with the separate 25% ASHP contingency. assert option.cost is not None assert abs(option.cost.total - 15600.60) <= 1e-9 assert abs(option.cost.contingency_rate - 0.25) <= 1e-9 def test_listed_building_yields_no_ashp_bundle() -> None: # Arrange — a listed building protects the fabric; an external ASHP unit is # not auto-offered (ADR-0024). The dwelling is on gas, so HHRSH is also out. # It IS a wet gas combi (PCDB index 10328, no SAP code), so a control tune-up # is legitimately offered — but never ASHP or HHRSH. baseline: EpcPropertyData = _gas_boiler_house() # Act recommendation: Recommendation | None = recommend_heating( baseline, _StubProducts(), PlanningRestrictions(is_listed=True) ) # Assert — no whole-system replacement (ASHP / HHRSH); a tune-up is fine. if recommendation is not None: measure_types = {o.measure_type for o in recommendation.options} assert "air_source_heat_pump" not in measure_types assert "high_heat_retention_storage_heaters" not in measure_types def test_conservation_area_still_yields_an_ashp_bundle() -> None: # Arrange — unlike glazing, a conservation area does NOT exclude ASHP; it is # offered with a planning caveat (ADR-0024, research-grounded). baseline: EpcPropertyData = _gas_boiler_house() # Act recommendation: Recommendation | None = recommend_heating( baseline, _StubProducts(), PlanningRestrictions(in_conservation_area=True) ) # Assert assert recommendation is not None assert "air_source_heat_pump" in {o.measure_type for o in recommendation.options} def test_flat_yields_no_ashp_bundle() -> None: # Arrange — flats are not auto-offered an individual ASHP (siting/lease/ # MCS-020 need a survey — ADR-0024). baseline: EpcPropertyData = _gas_boiler_house() baseline.property_type = "Flat" # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — no ASHP (and the gas flat is not HHRSH-eligible either → None). if recommendation is not None: assert "air_source_heat_pump" not in { o.measure_type for o in recommendation.options } def test_existing_heat_pump_yields_no_ashp_bundle() -> None: # Arrange — a dwelling already on a heat pump (category 4) is not re-offered # an ASHP. baseline: EpcPropertyData = _gas_boiler_house() baseline.sap_heating.main_heating_details[0].main_heating_category = 4 # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert if recommendation is not None: assert "air_source_heat_pump" not in { o.measure_type for o in recommendation.options } def test_community_heated_house_yields_no_ashp_bundle() -> None: # Arrange — community boiler network (SAP code 301, category 6) on a house: # topology alone must block ASHP regardless of fuel. epc = build_epc() main = epc.sap_heating.main_heating_details[0] main.sap_main_heating_code = 301 main.main_heating_category = 6 # Act / Assert recommendation: Recommendation | None = recommend_heating(epc, _StubProducts()) if recommendation is not None: assert MeasureType.AIR_SOURCE_HEAT_PUMP not in { o.measure_type for o in recommendation.options } # --- Gas boiler upgrade (Heating/HW expansion) ---------------------------- def _gas_boiler_with_cylinder_baseline() -> EpcPropertyData: """A mains-gas wet boiler heating an uninsulated, un-thermostatted hot-water cylinder — the boiler-with-cylinder dwelling. The cert lodges code 114 (already condensing), which the efficiency gate excludes from a like-for-like swap; recast to a pre-1998 non-condensing boiler (code 110) so the boiler upgrade is a genuine candidate (the overlay overwrites the code to 102 regardless of the before).""" epc: EpcPropertyData = parse_recommendation_summary( "boiler_cyl_gas_001431_before.pdf" ) epc.sap_heating.main_heating_details[0].sap_main_heating_code = 110 return epc def test_gas_boiler_with_cylinder_dwelling_yields_a_boiler_upgrade_bundle() -> None: # Arrange — a mains-gas wet boiler with an uninsulated, un-thermostatted # cylinder: the upgrade fires both conditional cylinder fixes. baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — the absolute boiler end-state (code 102, fanned flue) with the # cylinder jacketed (type 2 / 80 mm) and thermostatted; controls, cylinder # size, and meter are left unchanged. assert recommendation is not None options = {o.measure_type.value: o for o in recommendation.options} assert "gas_boiler_upgrade" in options assert options["gas_boiler_upgrade"].overlay.heating == HeatingOverlay( main_fuel_type=26, heat_emitter_type=1, sap_main_heating_code=102, fan_flue_present=True, boiler_flue_type=2, water_heating_code=901, water_heating_fuel=26, cylinder_insulation_type=2, cylinder_insulation_thickness_mm=80, cylinder_thermostat="Y", has_hot_water_cylinder=True, ) def test_boiler_upgrade_skips_jacket_when_cylinder_already_insulated() -> None: # Arrange — the same dwelling but with an already well-insulated cylinder # (100 mm > the 80 mm jacket end-state): the jacket must not be re-applied # (and must never downgrade it). baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() baseline.sap_heating.cylinder_insulation_thickness_mm = 100 # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — no jacket fields, but the thermostat still added (absent before). assert recommendation is not None overlay = next( o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" ).overlay.heating assert overlay is not None assert overlay.cylinder_insulation_type is None assert overlay.cylinder_insulation_thickness_mm is None assert overlay.cylinder_thermostat == "Y" def test_boiler_upgrade_skips_thermostat_when_already_present() -> None: # Arrange — the same dwelling but the cylinder already has a thermostat. baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() baseline.sap_heating.cylinder_thermostat = "Y" # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — no thermostat field, but the jacket still added (uninsulated). assert recommendation is not None overlay = next( o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" ).overlay.heating assert overlay is not None assert overlay.cylinder_thermostat is None assert overlay.cylinder_insulation_type == 2 def test_already_condensing_gas_boiler_yields_no_boiler_upgrade() -> None: # Arrange — the real cert: a mains-gas boiler already condensing (Table 4b # code 114, 84% winter — the same as the new code 102). A like-for-like swap # gains nothing, so the boiler upgrade is not offered; the dwelling still # gets a tune-up for its cylinder + controls. baseline: EpcPropertyData = parse_recommendation_summary( "boiler_cyl_gas_001431_before.pdf" ) # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert assert recommendation is not None measure_types = {o.measure_type for o in recommendation.options} assert "gas_boiler_upgrade" not in measure_types assert "system_tune_up_zoned" in measure_types def test_non_gas_boiler_is_not_gated_on_efficiency() -> None: # Arrange — an oil condensing boiler (Table 4b code 127, 84% winter — meets # the new gas boiler's efficiency) on a mains-gas street. Unlike a gas # boiler, the oil→gas fuel switch has value beyond efficiency, so it is NOT # suppressed by the efficiency gate. baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 127 baseline.sap_heating.main_heating_details[0].main_fuel_type = 28 # oil # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — the boiler upgrade is still offered (the fuel switch). assert recommendation is not None assert "gas_boiler_upgrade" in {o.measure_type for o in recommendation.options} def test_electric_boiler_dwelling_yields_no_gas_boiler_upgrade() -> None: # Arrange — an electric boiler (Table 4a code 191) is left alone: # electrification, not a gas swap, is its upgrade path. baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 191 # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert if recommendation is not None: assert "gas_boiler_upgrade" not in { o.measure_type for o in recommendation.options } def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> None: # Arrange — an oil boiler (Table 4b code 130) with no mains-gas connection: # a gas boiler cannot be installed, so no upgrade is offered (the gas end- # state is gated on a mains-gas connection). baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() baseline.sap_heating.main_heating_details[0].sap_main_heating_code = 130 baseline.sap_energy_source.gas_connection_available = False # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert if recommendation is not None: assert "gas_boiler_upgrade" not in { o.measure_type for o in recommendation.options } # --- PCDB-lodged gas boiler with no SAP code (#1388 Work item A#2) ---------- def _pcdb_lodged_gas_boiler_baseline() -> EpcPropertyData: """A mains-gas wet boiler lodged the way the gov EPC API cohort does — a `main_heating_category` (2, "boiler + radiators") and a PCDB Table 105 `main_heating_index_number`, but NO `sap_main_heating_code`. Index 5928 = Ideal Classic, 77.1% winter (a pre-condensing 2001 boiler). Reproduces portfolio-814 pids 742304 / 742306 / 742322 / 742325 / 742336, which model to £0 works and a null SAP heating code because the generators keyed on `sap_main_heating_code` alone and never evaluated these boilers.""" epc: EpcPropertyData = _gas_boiler_with_cylinder_baseline() main = epc.sap_heating.main_heating_details[0] main.sap_main_heating_code = None main.main_heating_category = 2 main.main_heating_index_number = 5928 main.main_fuel_type = 26 # mains gas return epc def test_pcdb_lodged_gas_boiler_without_sap_code_yields_a_boiler_upgrade() -> None: # Arrange — a non-condensing (77.1%) mains-gas boiler lodged with only a PCDB # index + category (no SAP code). The calculator already treats this as a wet # boiler (cert_to_inputs._is_wet_boiler_main); the generator must too, so the # inefficient boiler is offered an upgrade instead of silently getting nothing. baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — the boiler upgrade is offered, targeting the code-102 end-state. assert recommendation is not None options = {o.measure_type.value: o for o in recommendation.options} assert "gas_boiler_upgrade" in options overlay = options["gas_boiler_upgrade"].overlay.heating assert overlay is not None assert overlay.sap_main_heating_code == 102 def test_pcdb_lodged_condensing_gas_boiler_without_sap_code_yields_no_upgrade() -> None: # Arrange — same lodgement shape but a condensing PCDB index (126 = Broag # Remeha, 91% winter ≥ the new boiler's 84%). A like-for-like gas swap gains # nothing, so no boiler upgrade — the dwelling gets a tune-up instead (the # PCDB efficiency stands in for the absent Table 4b code). baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline() baseline.sap_heating.main_heating_details[0].main_heating_index_number = 126 # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — no boiler upgrade; a control tune-up is still offered (the # fixture's controls are already standard 2106, so the zone tune-up is the # offered variant). assert recommendation is not None measure_types = {o.measure_type for o in recommendation.options} assert "gas_boiler_upgrade" not in measure_types assert "system_tune_up_zoned" in measure_types def test_pcdb_lodged_gas_boiler_without_sap_code_is_tune_up_eligible() -> None: # Arrange — the same non-condensing PCDB boiler must also be recognised as a # wet boiler by the tune-up gate (controls/cylinder), not just the upgrade. baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — a tune-up option is offered (zone variant; controls are 2106). assert recommendation is not None assert "system_tune_up_zoned" in {o.measure_type for o in recommendation.options} def test_category_2_electric_boiler_without_sap_code_yields_no_gas_upgrade() -> None: # Arrange — a category-2 boiler with no SAP code and no PCDB index, on # electricity (an electric "boiler and radiators, electric" cohort lodges # category 2). Electrification, not a gas swap, is its path, so no gas boiler # upgrade even on a gas street. baseline: EpcPropertyData = _pcdb_lodged_gas_boiler_baseline() main = baseline.sap_heating.main_heating_details[0] main.main_heating_index_number = None main.main_fuel_type = 30 # electricity # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert if recommendation is not None: assert "gas_boiler_upgrade" not in { o.measure_type for o in recommendation.options } def _gas_combi_baseline() -> EpcPropertyData: """A mains-gas combi (Table 4b code 112, no cylinder) with inadequate controls (2111 "TRVs and bypass" — no room thermostat).""" return parse_recommendation_summary("boiler_combi_gas_001431_before.pdf") def test_gas_combi_dwelling_yields_a_combi_boiler_upgrade_bundle() -> None: # Arrange — a mains-gas combi with no cylinder and inadequate controls: # the upgrade replaces it with a condensing combi (code 104) and upgrades # the controls to 2106, touching no cylinder fields. baseline: EpcPropertyData = _gas_combi_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert assert recommendation is not None options = {o.measure_type.value: o for o in recommendation.options} assert "gas_boiler_upgrade" in options assert options["gas_boiler_upgrade"].overlay.heating == HeatingOverlay( main_fuel_type=26, heat_emitter_type=1, sap_main_heating_code=104, fan_flue_present=True, boiler_flue_type=2, main_heating_control=2106, water_heating_code=901, water_heating_fuel=26, ) def _tune_up_baseline() -> EpcPropertyData: """A mains-gas wet boiler (kept) with "no control" (2101) and an uninsulated, un-thermostatted cylinder — the system tune-up dwelling.""" return parse_recommendation_summary("tune_up_from_2101_001431_before.pdf") def test_wet_boiler_dwelling_yields_both_tune_up_options() -> None: # Arrange — a wet boiler whose controls can be improved: both the standard # (2106) and zone (2110) control tune-ups are offered as competing options, # each keeping the boiler and fixing the cylinder. baseline: EpcPropertyData = _tune_up_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert — both options carry the control end-state + the cylinder fixes, and # leave the boiler untouched (no fuel / sap code / flue fields). assert recommendation is not None options = {o.measure_type.value: o for o in recommendation.options} assert options["system_tune_up"].overlay.heating == HeatingOverlay( main_heating_control=2106, cylinder_insulation_type=2, cylinder_insulation_thickness_mm=80, cylinder_thermostat="Y", ) assert options["system_tune_up_zoned"].overlay.heating == HeatingOverlay( main_heating_control=2110, cylinder_insulation_type=2, cylinder_insulation_thickness_mm=80, cylinder_thermostat="Y", ) def test_tune_up_standard_not_offered_when_controls_already_standard() -> None: # Arrange — controls are already standard (2106): the standard tune-up would # be a control no-op, so only the zone tune-up is offered. baseline: EpcPropertyData = _tune_up_baseline() baseline.sap_heating.main_heating_details[0].main_heating_control = 2106 # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert assert recommendation is not None measure_types = {o.measure_type for o in recommendation.options} assert "system_tune_up" not in measure_types assert "system_tune_up_zoned" in measure_types def test_tune_up_neither_offered_when_controls_already_zoned() -> None: # Arrange — controls are already zone control (2110): neither tune-up would # improve them, so neither is offered. baseline: EpcPropertyData = _tune_up_baseline() baseline.sap_heating.main_heating_details[0].main_heating_control = 2110 # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert if recommendation is not None: measure_types = {o.measure_type for o in recommendation.options} assert "system_tune_up" not in measure_types assert "system_tune_up_zoned" not in measure_types def test_tune_up_carries_the_composite_per_dwelling_cost() -> None: # Arrange — a wet boiler with "no control" (2101) and an uninsulated, un- # thermostatted cylinder, 10 radiators: the standard tune-up fits the full # control set + both cylinder fixes (ADR-0027). baseline: EpcPropertyData = _tune_up_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) assert recommendation is not None option = next( o for o in recommendation.options if o.measure_type == "system_tune_up" ) # Assert — programmer 120 + room stat 150 + TRVs 10x35=350 = 620 controls + # jacket 50 + cylinder stat 150 = 820, with the 0.10 tune-up contingency # (composed, not the stub catalogue scalar). assert option.cost is not None assert abs(option.cost.total - 820.0) <= 1e-9 assert abs(option.cost.contingency_rate - 0.10) <= 1e-9 def test_boiler_upgrade_carries_the_composite_per_dwelling_cost() -> None: # Arrange — a gas boiler with a cylinder and adequate controls (2106): the # boiler is a like-for-like swap (no controls upgrade, no system-change # extras), with both cylinder fixes (ADR-0027). baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline() # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) assert recommendation is not None option = next( o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" ) # Assert — boiler 3200 + jacket 50 + cylinder stat 150 = 3400, 0.26 boiler # contingency. assert option.cost is not None assert abs(option.cost.total - 3400.0) <= 1e-9 assert abs(option.cost.contingency_rate - 0.26) <= 1e-9 def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None: # Arrange — the same combi but with already-adequate controls (2113, room # thermostat and TRVs): the upgrade must not move the controls (and must # never downgrade a better control). baseline: EpcPropertyData = _gas_combi_baseline() baseline.sap_heating.main_heating_details[0].main_heating_control = 2113 # Act recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts()) # Assert assert recommendation is not None overlay = next( o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade" ).overlay.heating assert overlay is not None assert overlay.main_heating_control is None def test_ashp_sizing_targets_the_appendix_n_efficiency_peak() -> None: # A 6 kW design heat loss sizes to the SAP Appendix-N efficiency peak # (PSR ~= 0.8, so ~4.8 kW rated output): the 5 kW aroTHERM plus (PCDB 110257, # ~4.37 kW) — what a real installer fits — not the oversized 7 kW unit, and # well clear of the low-PSR efficiency collapse. # Act pcdb_id = select_ashp_pcdb_id(design_heat_loss_kw=6.0) # Assert — the 5 kW aroTHERM plus rung. assert pcdb_id == 110257 def test_ashp_sizing_caps_at_the_largest_pump_for_a_high_heat_loss_dwelling() -> None: # A very leaky dwelling (17.5 kW design heat loss, e.g. UPRN 10002468116) # exceeds the domestic aroTHERM plus range, so sizing caps at the largest # rung — the 12 kW aroTHERM plus (PCDB 110281, ~11.48 kW) — an honest, still # far better than the fixed 5 kW pump whose PSR collapsed to ~0.25. # Act pcdb_id = select_ashp_pcdb_id(design_heat_loss_kw=17.5) # Assert — capped at the 12 kW rung, not left undersized at 5 kW. assert pcdb_id == 110281 def test_ashp_bundle_sizes_the_pump_to_the_design_heat_loss() -> None: # A high-heat-loss house (17.5 kW) must get a pump sized to it — the capped # 12 kW aroTHERM plus (PCDB 110281) — so SAP's Appendix-N PSR efficiency reads # near its peak, not the fixed 5 kW unit (110257) whose PSR collapses. # Arrange baseline: EpcPropertyData = _gas_boiler_house() # Act recommendation: Recommendation | None = recommend_heating( baseline, _StubProducts(), design_heat_loss_kw=17.5 ) # Assert — the ASHP overlay carries the sized 12 kW record, not the fixed 5 kW. assert recommendation is not None ashp = next( o for o in recommendation.options if o.measure_type == "air_source_heat_pump" ) assert ashp.overlay.heating is not None assert ashp.overlay.heating.main_heating_index_number == 110281