"""Behaviour of `Products.ashp_bundle_cost` — the composite, per-dwelling ASHP bundle cost (ADR-0025). Pure catalogue math: given a typed `AshpCostInputs` it selects and sums the applicable Southern Housing rate lines (decommission + heat pump + cylinder + distribution) into a `Cost`, carrying the separate ASHP contingency. No EpcPropertyData / calculator — the dwelling interpretation that produces the inputs lives in the modelling layer. Costs are pinned against the real Southern Housing Group rate sheet, so the totals are exact (delta <= 1e-9), mirroring the cascade-pin philosophy. """ from domain.modelling.products import ( AshpCostInputs, AshpExistingSystem, Products, ) from domain.modelling.recommendation import Cost def test_ashp_bundle_cost_composes_an_electric_storage_full_distribution_dwelling() -> None: # Arrange — a small electric-storage dwelling: no reusable wet system, so a # full new wet distribution is priced. 4 kW design heat loss (smallest pump # band), 7 radiators. products = Products() inputs = AshpCostInputs( existing_system=AshpExistingSystem.ELECTRIC_STORAGE, is_small_property=True, design_heat_loss_kw=4.0, radiator_count=7, has_reusable_wet_system=False, ) # Act cost: Cost = products.ashp_bundle_cost(inputs) # Assert — decommission 570 + pump 9720 + cylinder 2382.60 + distribution # (7 rads) 3618 = 16290.60, with the separate 25% ASHP contingency. assert abs(cost.total - 16290.60) <= 1e-9 assert abs(cost.contingency_rate - 0.25) <= 1e-9 def _large_no_reuse(system: AshpExistingSystem) -> AshpCostInputs: """A large dwelling, 8 kW band, 8 radiators, no reusable wet system — so the only thing varying with ``system`` is the decommission line.""" return AshpCostInputs( existing_system=system, is_small_property=False, design_heat_loss_kw=8.0, radiator_count=8, has_reusable_wet_system=False, ) def test_decommission_cost_varies_by_existing_system() -> None: # Arrange — common: pump (8 kW) 9840 + cylinder 2382.60 + distribution (8 # rads) 4152 = 16374.60; only decommission differs by system. products = Products() common = 16374.60 # Act / Assert — gas and oil are flat 720; LPG 960; electric-storage large # 840 (small 570 is pinned by the tracer above). assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.GAS)).total - (common + 720.0)) <= 1e-9 assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.OIL)).total - (common + 720.0)) <= 1e-9 assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.LPG)).total - (common + 960.0)) <= 1e-9 assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.ELECTRIC_STORAGE)).total - (common + 840.0)) <= 1e-9 def test_reusable_wet_system_prices_a_flush_plus_half_the_distribution() -> None: # Arrange — a gas dwelling whose wet system is reusable: instead of a full # new distribution, the ASHP pays a power-flush plus half the radiator band # (a documented estimate for partial radiator upsizing — ADR-0025). products = Products() inputs = AshpCostInputs( existing_system=AshpExistingSystem.GAS, is_small_property=False, design_heat_loss_kw=8.0, radiator_count=8, has_reusable_wet_system=True, ) # Act cost: Cost = products.ashp_bundle_cost(inputs) # Assert — decommission 720 + pump 9840 + cylinder 2382.60 + distribution # (flush 168 + 0.5 x 4152 = 2244) = 15186.60. assert abs(cost.total - 15186.60) <= 1e-9 def _small_no_reuse(system: AshpExistingSystem) -> AshpCostInputs: """A small dwelling, 4 kW band, 7 radiators, no reusable wet system — pump 9720 + cylinder 2382.60 + distribution (7) 3618 = 15720.60 common base.""" return AshpCostInputs( existing_system=system, is_small_property=True, design_heat_loss_kw=4.0, radiator_count=7, has_reusable_wet_system=False, ) def test_decommission_falls_back_for_systems_not_on_the_rate_sheet() -> None: # Arrange — the rate sheet covers gas/oil/LPG/electric-storage, but ASHP is # offered to any house regardless of fuel (ADR-0025): no system costs nothing # to remove; electric room/panel heaters use the electric-storage line; any # other system defaults to the gas line — never a raise (that would wrongly # block ASHP eligibility). products = Products() base = 15720.60 # Act / Assert assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.NONE)).total - (base + 0.0)) <= 1e-9 assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.ELECTRIC_OTHER)).total - (base + 570.0)) <= 1e-9 assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.OTHER)).total - (base + 720.0)) <= 1e-9