mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): gas combi boiler upgrade + controls-when-inadequate
Extend the gas-boiler-upgrade Option to combi (no-cylinder) dwellings and add the controls upgrade shared by both boiler shapes. A dwelling has a cylinder or it does not, so the one `gas_boiler_upgrade` Option is shaped per dwelling: - no cylinder -> a gas condensing combi (Table 4b code 104), no cylinder fields touched; - a cylinder -> a regular boiler (code 102) heating it, with the conditional cylinder jacket/thermostat (slice 1). Controls: bring an inadequate boiler control up to full programmer + room thermostat + TRVs (SAP 10.2 Table 4e Group 1 code 2106). "Inadequate" = the Group-1 codes with NO room thermostat (2101, 2102, 2107, 2108, 2109, 2111) — these lack boiler interlock (Table 4c(2) / footnote c) p.171), so adding a room thermostat genuinely improves SAP. Room-thermostatted (2103/2104/2105/2106/2113) or better zone controls (2110/2112) are left unchanged — never downgraded, so no phantom uplift. The with-cylinder cert (control 2106) is therefore untouched and its pin still holds at delta 0. Validated by the combi before/after re-lodgement (cert 001431, gas boiler upgrade - no cylinder): control 2111 "TRVs and bypass" -> 2106, fan flue False->True, SAP code 112 -> 104. Cascade-pinned delta 0 (SAP/CO2/PE). Removed the slice-1 placeholder test asserting no boiler Option fires without a cylinder (the combi Option now correctly fires there). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
31c74ab500
commit
63dd69ff8b
5 changed files with 149 additions and 35 deletions
|
|
@ -126,11 +126,26 @@ _ASHP_OVERLAY = HeatingOverlay(
|
|||
_MAINS_GAS_FUEL = 26
|
||||
# Table 4a heat-emitter code for radiators (the wet-distribution end-state).
|
||||
_RADIATOR_EMITTER = 1
|
||||
# Table 4b SAP main-heating code for a regular gas boiler heating a cylinder.
|
||||
# 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
|
||||
|
||||
# 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}
|
||||
)
|
||||
|
||||
# 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,
|
||||
|
|
@ -241,7 +256,7 @@ def recommend_heating(
|
|||
if ashp_option is not None:
|
||||
options.append(ashp_option)
|
||||
|
||||
boiler_option = _boiler_upgrade_with_cylinder_option(epc, products)
|
||||
boiler_option = _boiler_upgrade_option(epc, products)
|
||||
if boiler_option is not None:
|
||||
options.append(boiler_option)
|
||||
|
||||
|
|
@ -250,26 +265,34 @@ def recommend_heating(
|
|||
return Recommendation(surface=_HEATING_SURFACE, options=tuple(options))
|
||||
|
||||
|
||||
def _boiler_upgrade_with_cylinder_option(
|
||||
def _boiler_upgrade_option(
|
||||
epc: EpcPropertyData, products: ProductRepository
|
||||
) -> Optional[MeasureOption]:
|
||||
"""The gas-condensing-boiler-with-cylinder bundle: a new regular gas boiler
|
||||
(Table 4b code 102, fanned flue) for a dwelling whose existing wet boiler
|
||||
heats a hot-water cylinder, plus the conditional cylinder fixes (a jacket
|
||||
when under-insulated, a thermostat when absent). Offered only where a
|
||||
mains-gas connection makes the gas end-state installable (ADR-0024 revised)."""
|
||||
"""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
|
||||
if not epc.has_hot_water_cylinder:
|
||||
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=(
|
||||
"Replace the boiler with a gas condensing boiler and insulate and "
|
||||
"thermostat the hot-water cylinder"
|
||||
),
|
||||
overlay=EpcSimulation(heating=_boiler_cylinder_overlay(epc)),
|
||||
description=description,
|
||||
overlay=EpcSimulation(heating=overlay),
|
||||
cost=Cost(
|
||||
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
|
||||
),
|
||||
|
|
@ -294,13 +317,32 @@ def _boiler_upgrade_eligible(epc: EpcPropertyData) -> bool:
|
|||
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,
|
||||
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, 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, heating controls, and meter are left unchanged."""
|
||||
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):
|
||||
|
|
@ -314,6 +356,7 @@ def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay:
|
|||
heat_emitter_type=_RADIATOR_EMITTER,
|
||||
sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE,
|
||||
fan_flue_present=True,
|
||||
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,
|
||||
|
|
@ -329,6 +372,20 @@ def _cylinder_under_insulated(thickness_mm: Optional[int]) -> bool:
|
|||
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,
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -785,6 +785,30 @@ def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None:
|
|||
_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)
|
||||
|
||||
|
||||
# --- Solar PV cascade pins (ADR-0026) -------------------------------------
|
||||
#
|
||||
# The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp,
|
||||
|
|
|
|||
|
|
@ -328,22 +328,6 @@ def test_boiler_upgrade_skips_thermostat_when_already_present() -> None:
|
|||
assert overlay.cylinder_insulation_type == 2
|
||||
|
||||
|
||||
def test_no_cylinder_dwelling_yields_no_boiler_with_cylinder_bundle() -> None:
|
||||
# Arrange — a wet gas boiler with no hot-water cylinder (a combi); the with-
|
||||
# cylinder option does not apply (the combi option lands in a later slice).
|
||||
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
|
||||
baseline.has_hot_water_cylinder = 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 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.
|
||||
|
|
@ -376,3 +360,52 @@ def test_off_gas_boiler_yields_no_gas_boiler_upgrade() -> 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,
|
||||
main_heating_control=2106,
|
||||
water_heating_code=901,
|
||||
water_heating_fuel=26,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue