mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge branch 'feature/bill-derivation' of https://github.com/Hestia-Homes/Model into feature/junte+khalim
This commit is contained in:
commit
8ad560dc48
27 changed files with 812 additions and 0 deletions
|
|
@ -20,6 +20,9 @@ _CONTINGENCY_RATES: dict[str, float] = {
|
|||
"low_energy_lighting": 0.26,
|
||||
"high_heat_retention_storage_heaters": 0.10,
|
||||
"air_source_heat_pump": 0.25,
|
||||
"gas_boiler_upgrade": 0.26,
|
||||
"system_tune_up": 0.15,
|
||||
"system_tune_up_zoned": 0.15,
|
||||
"solar_pv": 0.15,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ _HEATING_SURFACE = "Heating & Hot Water"
|
|||
|
||||
_HHR_STORAGE_MEASURE_TYPE = MeasureType.HIGH_HEAT_RETENTION_STORAGE_HEATERS
|
||||
_ASHP_MEASURE_TYPE = MeasureType.AIR_SOURCE_HEAT_PUMP
|
||||
_GAS_BOILER_UPGRADE_MEASURE_TYPE = MeasureType.GAS_BOILER_UPGRADE
|
||||
_SYSTEM_TUNE_UP_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP
|
||||
_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE = MeasureType.SYSTEM_TUNE_UP_ZONED
|
||||
|
||||
# Electricity main-fuel code (Elmhurst → SAP10 Table 12).
|
||||
_ELECTRICITY_FUEL = 30
|
||||
|
|
@ -111,6 +114,78 @@ _ASHP_OVERLAY = HeatingOverlay(
|
|||
)
|
||||
|
||||
|
||||
# --- Gas boiler upgrade (Heating/HW expansion): replace an existing wet boiler
|
||||
# with a modern gas condensing boiler. Validated against Elmhurst before/after
|
||||
# re-lodgements (cert 001431): the upgrade always targets mains gas — gas->gas
|
||||
# directly, and a non-gas wet boiler (oil/LPG/solid) ->gas ONLY where a mains-gas
|
||||
# connection is present (electric boilers are left alone; electrification is the
|
||||
# national target). The end-state is a Table 4b SAP code (not a PCDB index): code
|
||||
# 102 for a regular boiler heating a hot-water cylinder, code 104 for a combi
|
||||
# (no cylinder, a later slice). The calculator derives the condensing-boiler
|
||||
# seasonal efficiency from the code, so no efficiency input is needed. ---
|
||||
|
||||
# Mains-gas main/water fuel code (Elmhurst -> SAP10 Table 12).
|
||||
_MAINS_GAS_FUEL = 26
|
||||
# Table 4a heat-emitter code for radiators (the wet-distribution end-state).
|
||||
_RADIATOR_EMITTER = 1
|
||||
# Table 4b SAP main-heating codes for the new gas condensing boiler: code 102
|
||||
# for a regular boiler heating a cylinder, code 104 for a combi (no cylinder).
|
||||
_REGULAR_GAS_BOILER_SAP_CODE = 102
|
||||
_COMBI_GAS_BOILER_SAP_CODE = 104
|
||||
# Water-heating code 901 — hot water from the main heating system.
|
||||
_WATER_FROM_MAIN_SYSTEM_CODE = 901
|
||||
# Elmhurst boiler flue type for the new condensing boiler (room-sealed/balanced);
|
||||
# every relodged after lodges type 2. SAP-inert, written for end-state fidelity.
|
||||
_CONDENSING_BOILER_FLUE_TYPE = 2
|
||||
|
||||
# Controls upgrade (SAP 10.2 Table 4e Group 1, PDF p.172): bring an inadequate
|
||||
# boiler control up to full programmer + room thermostat + TRVs (code 2106).
|
||||
# "Inadequate" = the Group-1 codes whose description carries NO room thermostat
|
||||
# (2101 no control, 2102 programmer-only, 2107/2108/2109 programmer+TRVs without
|
||||
# a room thermostat, 2111 TRVs and bypass) — these lack boiler interlock (Table
|
||||
# 4c(2) / footnote c)), so adding a room thermostat is a genuine improvement.
|
||||
# Controls with a room thermostat (2103/2104/2105/2106/2113) or better time-and-
|
||||
# temperature zone control (2110/2112) are left unchanged — never downgraded.
|
||||
_FULL_BOILER_CONTROL = 2106
|
||||
_INADEQUATE_BOILER_CONTROL_CODES: frozenset[int] = frozenset(
|
||||
{2101, 2102, 2107, 2108, 2109, 2111}
|
||||
)
|
||||
|
||||
# System tune-up control end-states (SAP 10.2 Table 4e Group 1): the two best
|
||||
# competing control upgrades offered while KEEPING the existing boiler —
|
||||
# "standard" (programmer + room thermostat + TRVs, code 2106) and "zone"
|
||||
# (time-and-temperature zone control, code 2110, type 3). Zone gives more SAP
|
||||
# uplift for more cost, so the Optimiser steps to it when its extra SAP is
|
||||
# needed (ADR-0024).
|
||||
_STANDARD_CONTROL = _FULL_BOILER_CONTROL # 2106
|
||||
_ZONE_CONTROL = 2110
|
||||
# Controls already providing standard (2106) or better — a standard tune-up
|
||||
# would be a no-op or a downgrade, so it is not offered to these.
|
||||
_STANDARD_OR_BETTER_CONTROL_CODES: frozenset[int] = frozenset({2106, 2110, 2112})
|
||||
# Controls already providing zone control (type 3) — a zone tune-up is not
|
||||
# offered to these.
|
||||
_ZONE_CONTROL_CODES: frozenset[int] = frozenset({2110, 2112})
|
||||
|
||||
# Wet-boiler SAP main_heating_code ranges (SAP 10.2 Table 4a + 4b): gas/oil
|
||||
# boilers 101-141, solid-fuel boilers 151-161, electric boilers 191-196 (held
|
||||
# locally so the generator does not depend on the calculator's internals,
|
||||
# mirroring `domain/sap10_calculator/rdsap/cert_to_inputs.py`). Electric boilers
|
||||
# are a wet system but are deliberately not upgraded to gas.
|
||||
_WET_BOILER_SAP_CODE_RANGES: tuple[range, ...] = (
|
||||
range(101, 142),
|
||||
range(151, 162),
|
||||
range(191, 197),
|
||||
)
|
||||
_ELECTRIC_BOILER_SAP_CODE_RANGE = range(191, 197)
|
||||
|
||||
# Cylinder jacket end-state (from the after-cert): an 80 mm jacket
|
||||
# (`cylinder_insulation_type=2`). The jacket is added only when the existing
|
||||
# cylinder is below this thickness — bringing every cylinder up to 80 mm and
|
||||
# never downgrading a better-insulated one.
|
||||
_CYLINDER_JACKET_INSULATION_TYPE = 2
|
||||
_MIN_CYLINDER_INSULATION_MM = 80
|
||||
|
||||
|
||||
# --- ASHP cost interpretation (ADR-0025): read the dwelling into the typed
|
||||
# inputs the catalogue math needs. The modelling-layer half of the split; the
|
||||
# pricing itself lives on `Products`. ---
|
||||
|
|
@ -201,11 +276,230 @@ def recommend_heating(
|
|||
if ashp_option is not None:
|
||||
options.append(ashp_option)
|
||||
|
||||
boiler_option = _boiler_upgrade_option(epc, products)
|
||||
if boiler_option is not None:
|
||||
options.append(boiler_option)
|
||||
|
||||
options.extend(_system_tune_up_options(epc, products))
|
||||
|
||||
if not options:
|
||||
return None
|
||||
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
|
||||
|
||||
|
||||
def _system_tune_up_options(
|
||||
epc: EpcPropertyData, products: ProductRepository
|
||||
) -> list[MeasureOption]:
|
||||
"""The system tune-up options: keep the existing wet boiler but install
|
||||
better heating controls (standard 2106 and/or zone 2110, as competing
|
||||
options) and fix the cylinder (jacket when under-insulated, thermostat when
|
||||
absent). Each control option is offered only when it genuinely improves the
|
||||
existing controls — never a downgrade or a no-op (ADR-0024)."""
|
||||
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||
code: Optional[int] = main.sap_main_heating_code
|
||||
if code is None or not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
|
||||
return []
|
||||
control = main.main_heating_control
|
||||
control_code: Optional[int] = control if isinstance(control, int) else None
|
||||
|
||||
options: list[MeasureOption] = []
|
||||
if control_code not in _STANDARD_OR_BETTER_CONTROL_CODES:
|
||||
options.append(
|
||||
_tune_up_option(
|
||||
epc,
|
||||
products,
|
||||
measure_type=_SYSTEM_TUNE_UP_MEASURE_TYPE,
|
||||
control=_STANDARD_CONTROL,
|
||||
description=(
|
||||
"Tune up the heating: install a programmer, room thermostat "
|
||||
"and TRVs and insulate and thermostat the hot-water cylinder"
|
||||
),
|
||||
)
|
||||
)
|
||||
if control_code not in _ZONE_CONTROL_CODES:
|
||||
options.append(
|
||||
_tune_up_option(
|
||||
epc,
|
||||
products,
|
||||
measure_type=_SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE,
|
||||
control=_ZONE_CONTROL,
|
||||
description=(
|
||||
"Tune up the heating: install time-and-temperature zone "
|
||||
"control and insulate and thermostat the hot-water cylinder"
|
||||
),
|
||||
)
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _tune_up_option(
|
||||
epc: EpcPropertyData,
|
||||
products: ProductRepository,
|
||||
*,
|
||||
measure_type: MeasureType,
|
||||
control: int,
|
||||
description: str,
|
||||
) -> MeasureOption:
|
||||
"""One tune-up Option: the existing boiler is kept; only the heating control
|
||||
and the conditional cylinder fixes change."""
|
||||
product = products.get(measure_type)
|
||||
return MeasureOption(
|
||||
measure_type=measure_type,
|
||||
description=description,
|
||||
overlay=EpcSimulation(heating=_tune_up_overlay(epc, control)),
|
||||
cost=Cost(
|
||||
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
|
||||
),
|
||||
material_id=product.id,
|
||||
)
|
||||
|
||||
|
||||
def _tune_up_overlay(epc: EpcPropertyData, control: int) -> HeatingOverlay:
|
||||
"""Build a tune-up end-state: set the heating control to ``control`` and
|
||||
apply the conditional cylinder fixes (an 80 mm jacket when under-insulated, a
|
||||
thermostat when absent) — only when the dwelling has a cylinder. The boiler,
|
||||
fuel and meter are left unchanged (the boiler is kept)."""
|
||||
sap_heating = epc.sap_heating
|
||||
jacket_type: Optional[int] = None
|
||||
jacket_thickness_mm: Optional[int] = None
|
||||
thermostat: Optional[str] = None
|
||||
if epc.has_hot_water_cylinder:
|
||||
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
|
||||
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
|
||||
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
|
||||
if sap_heating.cylinder_thermostat != "Y":
|
||||
thermostat = "Y"
|
||||
return HeatingOverlay(
|
||||
main_heating_control=control,
|
||||
cylinder_insulation_type=jacket_type,
|
||||
cylinder_insulation_thickness_mm=jacket_thickness_mm,
|
||||
cylinder_thermostat=thermostat,
|
||||
)
|
||||
|
||||
|
||||
def _boiler_upgrade_option(
|
||||
epc: EpcPropertyData, products: ProductRepository
|
||||
) -> Optional[MeasureOption]:
|
||||
"""The gas-condensing-boiler upgrade for a dwelling with an existing wet
|
||||
boiler: a combi (Table 4b code 104) where there is no cylinder, or a regular
|
||||
boiler (code 102) heating the existing cylinder where there is one. Both
|
||||
upgrade inadequate controls and the cylinder variant adds the conditional
|
||||
cylinder fixes (a jacket when under-insulated, a thermostat when absent). One
|
||||
Option per dwelling — a dwelling has a cylinder or it does not — offered only
|
||||
where a mains-gas connection makes the gas end-state installable (ADR-0024
|
||||
revised)."""
|
||||
if not _boiler_upgrade_eligible(epc):
|
||||
return None
|
||||
has_cylinder: bool = epc.has_hot_water_cylinder
|
||||
overlay: HeatingOverlay = (
|
||||
_boiler_cylinder_overlay(epc) if has_cylinder else _boiler_combi_overlay(epc)
|
||||
)
|
||||
description: str = (
|
||||
"Replace the boiler with a gas condensing boiler and insulate and "
|
||||
"thermostat the hot-water cylinder"
|
||||
if has_cylinder
|
||||
else "Replace the boiler with a gas condensing combi boiler"
|
||||
)
|
||||
product = products.get(_GAS_BOILER_UPGRADE_MEASURE_TYPE)
|
||||
return MeasureOption(
|
||||
measure_type=_GAS_BOILER_UPGRADE_MEASURE_TYPE,
|
||||
description=description,
|
||||
overlay=EpcSimulation(heating=overlay),
|
||||
cost=Cost(
|
||||
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
|
||||
),
|
||||
material_id=product.id,
|
||||
)
|
||||
|
||||
|
||||
def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool:
|
||||
"""Whether a dwelling's existing wet boiler can be upgraded to a gas
|
||||
condensing boiler. The gas end-state is installable only with a mains-gas
|
||||
connection, so gas dwellings always qualify and a non-gas wet boiler
|
||||
(oil/LPG/solid) qualifies only where mains gas is present. Electric boilers
|
||||
are left alone — electrification, not a gas swap, is their upgrade path."""
|
||||
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||
code: Optional[int] = main.sap_main_heating_code
|
||||
if code is None:
|
||||
return False
|
||||
if not any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
|
||||
return False
|
||||
if code in _ELECTRIC_BOILER_SAP_CODE_RANGE:
|
||||
return False
|
||||
return epc.sap_energy_source.mains_gas
|
||||
|
||||
|
||||
def _boiler_combi_overlay(epc: EpcPropertyData) -> HeatingOverlay:
|
||||
"""Build the per-dwelling combi end-state: a gas condensing combi (Table 4b
|
||||
code 104, fanned flue) on radiators with hot water from the boiler, plus a
|
||||
controls upgrade when the existing controls are inadequate. No cylinder, so
|
||||
no cylinder fields are touched."""
|
||||
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||
return HeatingOverlay(
|
||||
main_fuel_type=_MAINS_GAS_FUEL,
|
||||
heat_emitter_type=_RADIATOR_EMITTER,
|
||||
sap_main_heating_code=_COMBI_GAS_BOILER_SAP_CODE,
|
||||
fan_flue_present=True,
|
||||
boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE,
|
||||
main_heating_control=_upgraded_boiler_control(main),
|
||||
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
|
||||
water_heating_fuel=_MAINS_GAS_FUEL,
|
||||
)
|
||||
|
||||
|
||||
def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay:
|
||||
"""Build the per-dwelling boiler-with-cylinder end-state: a regular gas
|
||||
condensing boiler on radiators, hot water from the main system, a controls
|
||||
upgrade when the existing controls are inadequate, and the conditional
|
||||
cylinder fixes — an 80 mm jacket only when the cylinder is under-insulated, a
|
||||
thermostat only when one is absent. The existing cylinder size and meter are
|
||||
left unchanged."""
|
||||
sap_heating = epc.sap_heating
|
||||
main: MainHeatingDetail = sap_heating.main_heating_details[0]
|
||||
jacket_type: Optional[int] = None
|
||||
jacket_thickness_mm: Optional[int] = None
|
||||
if _cylinder_under_insulated(sap_heating.cylinder_insulation_thickness_mm):
|
||||
jacket_type = _CYLINDER_JACKET_INSULATION_TYPE
|
||||
jacket_thickness_mm = _MIN_CYLINDER_INSULATION_MM
|
||||
thermostat: Optional[str] = (
|
||||
"Y" if sap_heating.cylinder_thermostat != "Y" else None
|
||||
)
|
||||
return HeatingOverlay(
|
||||
main_fuel_type=_MAINS_GAS_FUEL,
|
||||
heat_emitter_type=_RADIATOR_EMITTER,
|
||||
sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE,
|
||||
fan_flue_present=True,
|
||||
boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE,
|
||||
main_heating_control=_upgraded_boiler_control(main),
|
||||
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
|
||||
water_heating_fuel=_MAINS_GAS_FUEL,
|
||||
cylinder_insulation_type=jacket_type,
|
||||
cylinder_insulation_thickness_mm=jacket_thickness_mm,
|
||||
cylinder_thermostat=thermostat,
|
||||
has_hot_water_cylinder=True,
|
||||
)
|
||||
|
||||
|
||||
def _cylinder_under_insulated(thickness_mm: Optional[int]) -> bool:
|
||||
"""Whether a hot-water cylinder is below the 80 mm jacket end-state (an
|
||||
un-jacketed cylinder lodges no thickness)."""
|
||||
return thickness_mm is None or thickness_mm < _MIN_CYLINDER_INSULATION_MM
|
||||
|
||||
|
||||
def _upgraded_boiler_control(main: MainHeatingDetail) -> Optional[int]:
|
||||
"""The full-controls code (2106) when the existing boiler control is
|
||||
inadequate (lacks a room thermostat — SAP 10.2 Table 4e Group 1), else
|
||||
``None`` to leave a room-thermostatted or better control unchanged. So the
|
||||
overlay only ever moves controls where it genuinely improves them."""
|
||||
control = main.main_heating_control
|
||||
code: Optional[int] = control if isinstance(control, int) else None
|
||||
if code is None and isinstance(control, str) and control.isdigit():
|
||||
code = int(control)
|
||||
if code in _INADEQUATE_BOILER_CONTROL_CODES:
|
||||
return _FULL_BOILER_CONTROL
|
||||
return None
|
||||
|
||||
|
||||
def _ashp_option(
|
||||
epc: EpcPropertyData,
|
||||
products: ProductRepository,
|
||||
|
|
|
|||
|
|
@ -32,4 +32,7 @@ class MeasureType(StrEnum):
|
|||
MECHANICAL_VENTILATION = "mechanical_ventilation"
|
||||
HIGH_HEAT_RETENTION_STORAGE_HEATERS = "high_heat_retention_storage_heaters"
|
||||
AIR_SOURCE_HEAT_PUMP = "air_source_heat_pump"
|
||||
GAS_BOILER_UPGRADE = "gas_boiler_upgrade"
|
||||
SYSTEM_TUNE_UP = "system_tune_up"
|
||||
SYSTEM_TUNE_UP_ZONED = "system_tune_up_zoned"
|
||||
SOLAR_PV = "solar_pv"
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ _MAIN_HEATING_FIELDS: tuple[str, ...] = (
|
|||
"sap_main_heating_code",
|
||||
"main_heating_index_number",
|
||||
"main_heating_category",
|
||||
"fan_flue_present",
|
||||
"boiler_flue_type",
|
||||
)
|
||||
_SAP_HEATING_FIELDS: tuple[str, ...] = (
|
||||
"water_heating_code",
|
||||
|
|
|
|||
|
|
@ -118,6 +118,14 @@ class HeatingOverlay:
|
|||
sap_main_heating_code: Optional[int] = None
|
||||
main_heating_index_number: Optional[int] = None
|
||||
main_heating_category: Optional[int] = None
|
||||
# A modern condensing boiler has a fanned (room-sealed) flue; the boiler
|
||||
# upgrade sets this True (SAP 10.2 Table 4f flue-fan electricity + the
|
||||
# Table 4b condensing-boiler seasonal-efficiency basis depend on it).
|
||||
fan_flue_present: Optional[bool] = None
|
||||
# The boiler's flue type (Elmhurst enum) — a new condensing boiler lodges
|
||||
# type 2 (room-sealed/balanced). SAP-inert, but written for fidelity so the
|
||||
# end-state matches the installed boiler.
|
||||
boiler_flue_type: Optional[int] = None
|
||||
# sap_heating (top-level)
|
||||
water_heating_code: Optional[int] = None
|
||||
water_heating_fuel: Optional[int] = None
|
||||
|
|
|
|||
|
|
@ -153,6 +153,29 @@ def _triggers_for(epc: EpcPropertyData, measure_type: str) -> dict[str, Any]:
|
|||
epc.sap_heating.main_heating_details[0].main_heating_category
|
||||
),
|
||||
}
|
||||
if measure_type == "gas_boiler_upgrade":
|
||||
# heating_recommendation.py offers a gas condensing boiler to a dwelling
|
||||
# with an existing (non-electric) wet boiler and a mains-gas connection;
|
||||
# the cylinder presence shapes it (combi vs regular + cylinder fixes).
|
||||
return {
|
||||
"sap_main_heating_code": (
|
||||
epc.sap_heating.main_heating_details[0].sap_main_heating_code
|
||||
),
|
||||
"mains_gas": epc.sap_energy_source.mains_gas,
|
||||
"has_hot_water_cylinder": epc.has_hot_water_cylinder,
|
||||
}
|
||||
if measure_type in ("system_tune_up", "system_tune_up_zoned"):
|
||||
# heating_recommendation.py offers a tune-up (keep the boiler, upgrade
|
||||
# the controls + fix the cylinder) to a wet-boiler dwelling whose
|
||||
# existing control can still be improved.
|
||||
return {
|
||||
"sap_main_heating_code": (
|
||||
epc.sap_heating.main_heating_details[0].sap_main_heating_code
|
||||
),
|
||||
"main_heating_control": (
|
||||
epc.sap_heating.main_heating_details[0].main_heating_control
|
||||
),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,5 +13,8 @@
|
|||
"low_energy_lighting": { "unit_cost_per_m2": 8.0 },
|
||||
"high_heat_retention_storage_heaters": { "unit_cost_per_m2": 3500.0 },
|
||||
"air_source_heat_pump": { "unit_cost_per_m2": 12000.0 },
|
||||
"gas_boiler_upgrade": { "unit_cost_per_m2": 3000.0 },
|
||||
"system_tune_up": { "unit_cost_per_m2": 500.0 },
|
||||
"system_tune_up_zoned": { "unit_cost_per_m2": 900.0 },
|
||||
"solar_pv": { "unit_cost_per_m2": 0.0 }
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/domain/modelling/fixtures/boiler_cyl_coal_001431_after.pdf
Normal file
BIN
tests/domain/modelling/fixtures/boiler_cyl_coal_001431_after.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/domain/modelling/fixtures/boiler_cyl_gas_001431_after.pdf
Normal file
BIN
tests/domain/modelling/fixtures/boiler_cyl_gas_001431_after.pdf
Normal file
Binary file not shown.
BIN
tests/domain/modelling/fixtures/boiler_cyl_gas_001431_before.pdf
Normal file
BIN
tests/domain/modelling/fixtures/boiler_cyl_gas_001431_before.pdf
Normal file
Binary file not shown.
BIN
tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_after.pdf
Normal file
BIN
tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_after.pdf
Normal file
Binary file not shown.
BIN
tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_before.pdf
Normal file
BIN
tests/domain/modelling/fixtures/boiler_cyl_lpg_001431_before.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf
Normal file
BIN
tests/domain/modelling/fixtures/tune_up_zoned_001431_after.pdf
Normal file
Binary file not shown.
|
|
@ -755,6 +755,185 @@ def test_gas_boiler_instant_hw_before_baselines() -> None:
|
|||
assert result.sap_score_continuous > 0.0
|
||||
|
||||
|
||||
def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None:
|
||||
# Arrange — a mains-gas wet boiler (SAP code 114) heating an uninsulated
|
||||
# hot-water cylinder (no insulation, no thermostat) re-lodged as a new gas
|
||||
# condensing boiler with a cylinder (SAP code 102, fanned flue), the cylinder
|
||||
# jacketed (insulation type 2 / 80 mm) and given a thermostat. The boiler
|
||||
# upgrade leaves the (already adequate) controls + cylinder size + meter
|
||||
# unchanged. Validates the boiler-with-cylinder option end-state at delta 0.
|
||||
#
|
||||
# NB the absolute SAP on this dwelling is subject to a separate Summary-path
|
||||
# mapper roof-fidelity gap (our calculator reads the roof better-insulated
|
||||
# than Elmhurst, so it scores ~75 where Elmhurst prints 56); the gap is
|
||||
# identical on before + after (the boiler measure never touches the roof), so
|
||||
# it cancels and this pin still proves the overlay applies Elmhurst's exact
|
||||
# heating field-delta. Tracked on the calculator branch, not here.
|
||||
before: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_cyl_gas_001431_before.pdf"
|
||||
)
|
||||
after: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_cyl_gas_001431_after.pdf"
|
||||
)
|
||||
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
_assert_overlay_reproduces_after(before, after, option.overlay)
|
||||
|
||||
|
||||
def test_boiler_combi_overlay_reproduces_the_relodged_after() -> None:
|
||||
# Arrange — a mains-gas combi (SAP code 112, no cylinder) with inadequate
|
||||
# controls (2111 "TRVs and bypass" — no room thermostat, so no boiler
|
||||
# interlock) re-lodged as a new gas condensing combi (code 104, fanned flue)
|
||||
# with full programmer + room thermostat + TRV controls (2106). No cylinder,
|
||||
# so no cylinder components. Validates the combi end-state + the controls-
|
||||
# when-inadequate upgrade at delta 0. (Same Summary-path roof gap as the
|
||||
# with-cylinder pin — it cancels across before/after.)
|
||||
before: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_combi_gas_001431_before.pdf"
|
||||
)
|
||||
after: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_combi_gas_001431_after.pdf"
|
||||
)
|
||||
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
_assert_overlay_reproduces_after(before, after, option.overlay)
|
||||
|
||||
|
||||
def test_oil_combi_overlay_reproduces_the_relodged_after() -> None:
|
||||
# Arrange — an OIL combi (fuel 28, SAP code 130, no cylinder) on a mains-gas
|
||||
# street re-lodged as a gas condensing combi (fuel 28->26, code 104, fanned
|
||||
# flue). Validates the non-gas -> gas conversion: the upgrade targets gas
|
||||
# because a mains-gas connection is present (ADR-0024 revised). Controls are
|
||||
# already adequate (2106), so they are unchanged.
|
||||
before: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_combi_oil_001431_before.pdf"
|
||||
)
|
||||
after: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_combi_oil_001431_after.pdf"
|
||||
)
|
||||
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
_assert_overlay_reproduces_after(before, after, option.overlay)
|
||||
|
||||
|
||||
def test_boiler_with_already_insulated_cylinder_overlay_reproduces_the_relodged_after() -> None:
|
||||
# Arrange — a gas boiler heating an ALREADY-jacketed cylinder (insulation
|
||||
# type 2 / 80 mm) with no thermostat, re-lodged as a new gas condensing
|
||||
# boiler (code 102) with a cylinder thermostat added. Validates the cylinder
|
||||
# path's skip-jacket branch (the 80 mm jacket is not re-applied) while the
|
||||
# thermostat is still added. (Sourced from an LPG re-lodgement; the Summary
|
||||
# mapper reads its fuel as mains gas — fuel 26 — so this exercises the gas
|
||||
# cylinder path, not a true LPG conversion. The LPG fuel-mapping gap is a
|
||||
# separate mapper-front concern.)
|
||||
before: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_cyl_lpg_001431_before.pdf"
|
||||
)
|
||||
after: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_cyl_lpg_001431_after.pdf"
|
||||
)
|
||||
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
_assert_overlay_reproduces_after(before, after, option.overlay)
|
||||
|
||||
|
||||
def test_coal_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None:
|
||||
# Arrange — a SOLID-FUEL (coal) boiler (fuel 11, SAP code 153) heating a
|
||||
# cylinder, on a mains-gas street, re-lodged as a gas condensing boiler
|
||||
# (fuel 11->26, code 102, fanned flue + boiler flue type 2). Exercises the
|
||||
# non-gas -> gas conversion for a solid-fuel boiler AND the new
|
||||
# `boiler_flue_type` end-state (coal's before lodged none; every other cert
|
||||
# already lodged flue type 2). The cylinder is already 80 mm insulated so the
|
||||
# jacket is skipped; only the thermostat is added.
|
||||
#
|
||||
# The relodged after predates the user-locked "always add a cylinder
|
||||
# thermostat when absent" rule, so it stale-lodged thermostat 'N'; the test
|
||||
# corrects it to the rule's end-state 'Y' (the same correction the gas
|
||||
# with-cylinder after received by re-lodging). Controls are already adequate
|
||||
# (2106), so they are unchanged.
|
||||
before: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_cyl_coal_001431_before.pdf"
|
||||
)
|
||||
after: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_cyl_coal_001431_after.pdf"
|
||||
)
|
||||
after.sap_heating.cylinder_thermostat = "Y"
|
||||
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
_assert_overlay_reproduces_after(before, after, option.overlay)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"before_fixture, after_fixture, measure_type",
|
||||
[
|
||||
# The system tune-up keeps the existing boiler and forces the heating
|
||||
# controls to a fixed end-state (standard 2106 / zone 2110) ABSOLUTELY —
|
||||
# proven by reproducing each common after from two different starting
|
||||
# controls (2101 "no control" and 2113 "room thermostat and TRVs") — plus
|
||||
# the conditional cylinder jacket + thermostat (both befores are
|
||||
# uninsulated / un-thermostatted, so both fire).
|
||||
(
|
||||
"tune_up_from_2101_001431_before.pdf",
|
||||
"tune_up_standard_001431_after.pdf",
|
||||
"system_tune_up",
|
||||
),
|
||||
(
|
||||
"tune_up_from_2113_001431_before.pdf",
|
||||
"tune_up_standard_001431_after.pdf",
|
||||
"system_tune_up",
|
||||
),
|
||||
(
|
||||
"tune_up_from_2101_001431_before.pdf",
|
||||
"tune_up_zoned_001431_after.pdf",
|
||||
"system_tune_up_zoned",
|
||||
),
|
||||
(
|
||||
"tune_up_from_2113_001431_before.pdf",
|
||||
"tune_up_zoned_001431_after.pdf",
|
||||
"system_tune_up_zoned",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_system_tune_up_overlay_reproduces_the_relodged_after(
|
||||
before_fixture: str, after_fixture: str, measure_type: str
|
||||
) -> None:
|
||||
# Arrange
|
||||
before: EpcPropertyData = parse_recommendation_summary(before_fixture)
|
||||
after: EpcPropertyData = parse_recommendation_summary(after_fixture)
|
||||
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == measure_type
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
_assert_overlay_reproduces_after(before, after, option.overlay)
|
||||
|
||||
|
||||
# --- Solar PV cascade pins (ADR-0026) -------------------------------------
|
||||
#
|
||||
# The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp,
|
||||
|
|
|
|||
|
|
@ -250,3 +250,229 @@ def test_existing_heat_pump_yields_no_ashp_bundle() -> 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 (Table 4b code 114) heating an uninsulated, un-
|
||||
thermostatted hot-water cylinder — the boiler-with-cylinder dwelling."""
|
||||
return parse_recommendation_summary("boiler_cyl_gas_001431_before.pdf")
|
||||
|
||||
|
||||
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_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_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
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ _EXPECTED_VALUES = {
|
|||
"mechanical_ventilation",
|
||||
"high_heat_retention_storage_heaters",
|
||||
"air_source_heat_pump",
|
||||
"gas_boiler_upgrade",
|
||||
"system_tune_up",
|
||||
"system_tune_up_zoned",
|
||||
"solar_pv",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ _GENERATOR_MEASURE_TYPES = (
|
|||
"low_energy_lighting",
|
||||
"high_heat_retention_storage_heaters",
|
||||
"air_source_heat_pump",
|
||||
"gas_boiler_upgrade",
|
||||
"system_tune_up",
|
||||
"system_tune_up_zoned",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ from harness.report import (
|
|||
format_report_markdown,
|
||||
parity_report_for,
|
||||
)
|
||||
from tests.domain.modelling._elmhurst_recommendation import (
|
||||
parse_recommendation_summary,
|
||||
)
|
||||
|
||||
_GOLDEN = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
|
|
@ -99,6 +102,44 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None:
|
|||
}
|
||||
|
||||
|
||||
def test_gas_boiler_upgrade_surfaces_its_eligibility_triggers() -> None:
|
||||
# No golden API cert selects the boiler upgrade (it competes with — and on
|
||||
# houses loses to — the ASHP bundle within the one heating Recommendation),
|
||||
# so the trigger branch is exercised directly, like the cert_to_inputs unit
|
||||
# tests of internal helpers.
|
||||
from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Arrange — a mains-gas wet boiler (SAP code 114) with a hot-water cylinder:
|
||||
# the boiler-upgrade eligibility attributes the report should explain.
|
||||
epc = parse_recommendation_summary("boiler_cyl_gas_001431_before.pdf")
|
||||
|
||||
# Act
|
||||
triggers = _triggers_for(epc, "gas_boiler_upgrade")
|
||||
|
||||
# Assert — the wet-boiler SAP code, the mains-gas connection that makes the
|
||||
# gas end-state installable, and the cylinder that shapes the bundle.
|
||||
assert triggers == {
|
||||
"sap_main_heating_code": 114,
|
||||
"mains_gas": True,
|
||||
"has_hot_water_cylinder": True,
|
||||
}
|
||||
|
||||
|
||||
def test_system_tune_up_surfaces_its_eligibility_triggers() -> None:
|
||||
# Like the boiler-upgrade trigger, no golden cert selects a tune-up, so the
|
||||
# branch is covered directly.
|
||||
from harness.report import _triggers_for # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Arrange — a wet boiler (SAP code 102) with "no control" (2101): the wet-
|
||||
# boiler code and the improvable control are what the report should explain.
|
||||
epc = parse_recommendation_summary("tune_up_from_2101_001431_before.pdf")
|
||||
|
||||
# Act / Assert — both tune-up measure types surface the same eligibility.
|
||||
expected = {"sap_main_heating_code": 102, "main_heating_control": 2101}
|
||||
assert _triggers_for(epc, "system_tune_up") == expected
|
||||
assert _triggers_for(epc, "system_tune_up_zoned") == expected
|
||||
|
||||
|
||||
def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None:
|
||||
# Arrange
|
||||
path: Path = _GOLDEN / f"{_WITHIN_TOLERANCE}.json"
|
||||
|
|
|
|||
|
|
@ -168,6 +168,30 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
|
|||
is_active=True,
|
||||
description="LED bulb",
|
||||
),
|
||||
MaterialRow(
|
||||
id=6,
|
||||
type="gas_boiler_upgrade",
|
||||
total_cost=3000.0,
|
||||
cost_unit="gbp_per_unit",
|
||||
is_active=True,
|
||||
description="Gas condensing boiler",
|
||||
),
|
||||
MaterialRow(
|
||||
id=7,
|
||||
type="system_tune_up",
|
||||
total_cost=500.0,
|
||||
cost_unit="gbp_per_unit",
|
||||
is_active=True,
|
||||
description="Heating controls + cylinder tune-up",
|
||||
),
|
||||
MaterialRow(
|
||||
id=8,
|
||||
type="system_tune_up_zoned",
|
||||
total_cost=900.0,
|
||||
cost_unit="gbp_per_unit",
|
||||
is_active=True,
|
||||
description="Zoned heating controls + cylinder tune-up",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue