mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
The ASHP bundle is priced from the rate sheet (ADR-0025); the catalogue row is read only for its material id, which is nullable end-to-end. The live `material` catalogue has no `air_source_heat_pump` row, so `products.get` raised `ValueError: no active product` and aborted every ASHP-eligible property. Add `ProductNotFound(ValueError)` + a concrete `ProductRepository .get_optional`, raise the typed error from both repos, and have `_ashp_option` look the row up optionally — a missing row now yields an ASHP Option with `material_id=None` rather than crashing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
603 lines
24 KiB
Python
603 lines
24 KiB
Python
"""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
|
|
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 main-fuel code (Elmhurst → SAP10) and the Table 4a SAP code an
|
|
# existing (non-HHR) electric storage system lodges.
|
|
_ELECTRICITY = 30
|
|
_OLD_STORAGE_SAP_CODE = 402
|
|
|
|
|
|
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,
|
|
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 _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
|
|
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
|
|
|
|
# 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",
|
|
mains_gas=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.
|
|
baseline: EpcPropertyData = _gas_boiler_house()
|
|
|
|
# Act
|
|
recommendation: Recommendation | None = recommend_heating(
|
|
baseline, _StubProducts(), PlanningRestrictions(is_listed=True)
|
|
)
|
|
|
|
# Assert
|
|
assert recommendation is None
|
|
|
|
|
|
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
|
|
}
|
|
|
|
|
|
# --- 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.mains_gas = 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
|
|
}
|
|
|
|
|
|
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
|