feat(modelling): solid-fuel(coal)->gas boiler upgrade + boiler_flue_type end-state

Pin the coal-boiler-with-cylinder upgrade and add the `boiler_flue_type`
end-state field. A solid-fuel (coal) boiler (fuel 11, SAP code 153) on a
mains-gas street converts to a gas condensing boiler (fuel 11->26, code 102) —
the non-gas->gas path for a solid-fuel system, eligible because code 153 is in
the wet-boiler solid-fuel range 151-161 and mains gas is present.

New `boiler_flue_type` HeatingOverlay field, routed to main_heating_details[0]
and set to 2 (room-sealed/balanced) on both boiler shapes: every relodged after
lodges flue type 2, but coal's before lodged none. The field is SAP-inert (the
cascade score is unchanged by it), so it is written purely for end-state
fidelity — the overlay now represents the installed condensing boiler's flue.
Validated via the overlay-equality unit tests.

The coal after predates the user-locked "always add a cylinder thermostat when
absent" rule, so it stale-lodged thermostat 'N'; the pin corrects it to the
rule's end-state 'Y' in-test (the gas with-cylinder after got the same
correction by re-lodging). The cylinder is already 80 mm insulated, so the
jacket is skipped and only the thermostat is added; controls (2106) are
unchanged. Cascade-pinned delta 0 (SAP/CO2/PE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-10 08:27:07 +00:00
parent 7bc9797a26
commit 2413bc87da
7 changed files with 43 additions and 0 deletions

View file

@ -132,6 +132,9 @@ _REGULAR_GAS_BOILER_SAP_CODE = 102
_COMBI_GAS_BOILER_SAP_CODE = 104 _COMBI_GAS_BOILER_SAP_CODE = 104
# Water-heating code 901 — hot water from the main heating system. # Water-heating code 901 — hot water from the main heating system.
_WATER_FROM_MAIN_SYSTEM_CODE = 901 _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 # 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). # boiler control up to full programmer + room thermostat + TRVs (code 2106).
@ -328,6 +331,7 @@ def _boiler_combi_overlay(epc: EpcPropertyData) -> HeatingOverlay:
heat_emitter_type=_RADIATOR_EMITTER, heat_emitter_type=_RADIATOR_EMITTER,
sap_main_heating_code=_COMBI_GAS_BOILER_SAP_CODE, sap_main_heating_code=_COMBI_GAS_BOILER_SAP_CODE,
fan_flue_present=True, fan_flue_present=True,
boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE,
main_heating_control=_upgraded_boiler_control(main), main_heating_control=_upgraded_boiler_control(main),
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE, water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
water_heating_fuel=_MAINS_GAS_FUEL, water_heating_fuel=_MAINS_GAS_FUEL,
@ -356,6 +360,7 @@ def _boiler_cylinder_overlay(epc: EpcPropertyData) -> HeatingOverlay:
heat_emitter_type=_RADIATOR_EMITTER, heat_emitter_type=_RADIATOR_EMITTER,
sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE, sap_main_heating_code=_REGULAR_GAS_BOILER_SAP_CODE,
fan_flue_present=True, fan_flue_present=True,
boiler_flue_type=_CONDENSING_BOILER_FLUE_TYPE,
main_heating_control=_upgraded_boiler_control(main), main_heating_control=_upgraded_boiler_control(main),
water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE, water_heating_code=_WATER_FROM_MAIN_SYSTEM_CODE,
water_heating_fuel=_MAINS_GAS_FUEL, water_heating_fuel=_MAINS_GAS_FUEL,

View file

@ -71,6 +71,7 @@ _MAIN_HEATING_FIELDS: tuple[str, ...] = (
"main_heating_index_number", "main_heating_index_number",
"main_heating_category", "main_heating_category",
"fan_flue_present", "fan_flue_present",
"boiler_flue_type",
) )
_SAP_HEATING_FIELDS: tuple[str, ...] = ( _SAP_HEATING_FIELDS: tuple[str, ...] = (
"water_heating_code", "water_heating_code",

View file

@ -122,6 +122,10 @@ class HeatingOverlay:
# upgrade sets this True (SAP 10.2 Table 4f flue-fan electricity + the # upgrade sets this True (SAP 10.2 Table 4f flue-fan electricity + the
# Table 4b condensing-boiler seasonal-efficiency basis depend on it). # Table 4b condensing-boiler seasonal-efficiency basis depend on it).
fan_flue_present: Optional[bool] = None 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) # sap_heating (top-level)
water_heating_code: Optional[int] = None water_heating_code: Optional[int] = None
water_heating_fuel: Optional[int] = None water_heating_fuel: Optional[int] = None

View file

@ -856,6 +856,37 @@ def test_boiler_with_already_insulated_cylinder_overlay_reproduces_the_relodged_
_assert_overlay_reproduces_after(before, after, option.overlay) _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)
# --- Solar PV cascade pins (ADR-0026) ------------------------------------- # --- Solar PV cascade pins (ADR-0026) -------------------------------------
# #
# The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp, # The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp,

View file

@ -280,6 +280,7 @@ def test_gas_boiler_with_cylinder_dwelling_yields_a_boiler_upgrade_bundle() -> N
heat_emitter_type=1, heat_emitter_type=1,
sap_main_heating_code=102, sap_main_heating_code=102,
fan_flue_present=True, fan_flue_present=True,
boiler_flue_type=2,
water_heating_code=901, water_heating_code=901,
water_heating_fuel=26, water_heating_fuel=26,
cylinder_insulation_type=2, cylinder_insulation_type=2,
@ -386,6 +387,7 @@ def test_gas_combi_dwelling_yields_a_combi_boiler_upgrade_bundle() -> None:
heat_emitter_type=1, heat_emitter_type=1,
sap_main_heating_code=104, sap_main_heating_code=104,
fan_flue_present=True, fan_flue_present=True,
boiler_flue_type=2,
main_heating_control=2106, main_heating_control=2106,
water_heating_code=901, water_heating_code=901,
water_heating_fuel=26, water_heating_fuel=26,