fix(control): no boiler interlock for TRVs+bypass controls 2107/2111 (SAP §9.4.11)

`_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES` held only {2101, 2102} — it was
keyed off the Table 4e "+0.6 °C" annotation rather than the actual interlock
criterion. SAP 10.2 §9.4.11 (PDF p.66): "A boiler system with no room
thermostat (or a device equivalent in this context, such as a flow switch or
boiler energy manager) ... must be considered as having no interlock", and
"TRVs alone ... do not perform the boiler interlock function". A fixed bypass
likewise provides no interlock (it keeps water circulating when TRVs close).

So control 2107 ("Programmer, TRVs and bypass") and 2111 ("TRVs and bypass")
lack interlock and must take the Table 4c(2) −5pp Space+DHW seasonal-efficiency
adjustment and the Table 4f footnote a) ×1.3 circulation-pump uplift — both of
which they previously missed. (2108 flow switch / 2109 boiler energy manager
carry interlock-equivalent devices → excluded; 2103-2106/2113 have a room
thermostat.) All affected certs are cat-2 gas boilers, where §9.4.11 applies.

Eval: 909 computed, 45.3% → 46.9% within 0.5 (+14 certs: 412 → 426), mean|err|
1.659 → 1.633. Bucket means corrected: control 2107 +1.50 → +0.32 (n=38),
2111 +1.48 → +0.16 (n=4). 32 improved / 10 regressed (all small; the six that
crossed out of ±0.5 were coincidentally-accurate offsetting-error certs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 13:17:15 +00:00
parent 560c912c0b
commit 5e7ef5c7ff
2 changed files with 66 additions and 16 deletions

View file

@ -385,7 +385,9 @@ def _table_4f_circulation_pump_kwh(main: Optional[MainHeatingDetail]) -> float:
2 41 kWh (2013 or later)
Table 4f footnote a) then multiplies the row by 1.3 when the room
thermostat is absent (control code 2101 / 2102).
thermostat is absent the same "no room thermostat" criterion as the
interlock rule, i.e. `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES`
(2101 / 2102 / 2107 / 2111; bypass and TRVs are not a room thermostat).
"""
if not _is_wet_boiler_main(main):
return 0.0
@ -1267,22 +1269,32 @@ _CONTROL_TYPE_BY_CODE: Final[dict[int, int]] = {
}
# SAP 10.2 Table 4e Group 1 (PDF p.171) — boiler control codes providing
# NO thermostatic control of room temperature, i.e. no room thermostat
# ("No time or thermostatic control of room temperature" 2101 /
# "Programmer, no room thermostat" 2102 — the two Group-1 rows carrying
# the "+0.6 °C / Table 4c(2)" annotation). Per RdSAP 10 §3 (PDF p.57)
# boiler interlock is "assumed present if there is a room thermostat and
# (for stored hot water systems heated by the boiler) a cylinder
# thermostat. Otherwise not interlocked." A gas/liquid-fuel boiler under
# one of these controls therefore has NO boiler interlock regardless of
# the cylinder thermostat, triggering the Table 4c(2) (PDF p.169) "No
# thermostatic control of room temperature regular boiler" -5pp Space
# + DHW seasonal-efficiency adjustment. The combi rows of Table 4c(2)
# take Space -5 / DHW 0; the DHW leg is gated separately on a cylinder
# being present (regular boiler) at the call site.
# SAP 10.2 Table 4e Group 1 (PDF p.171) — boiler control codes with NO
# boiler interlock because they lack a room thermostat (or an equivalent
# device). SAP 10.2 §9.4.11 (PDF p.66) is explicit: "A boiler system with
# no room thermostat (or a device equivalent in this context, such as a
# flow switch or boiler energy manager), even if there is a cylinder
# thermostat, must be considered as having no interlock", and "TRVs alone
# (other than some communicating TRVs) do not perform the boiler interlock
# function". A *fixed bypass* likewise provides no interlock — it exists to
# keep water circulating when the TRVs close. The Group-1 rows without a
# room thermostat / flow switch / boiler energy manager are therefore:
# 2101 "No time or thermostatic control of room temperature"
# 2102 "Programmer, no room thermostat"
# 2107 "Programmer, TRVs and bypass" ← bypass ≠ interlock
# 2111 "TRVs and bypass" ← bypass ≠ interlock
# (2108 "Programmer, TRVs and flow switch" and 2109 "… boiler energy
# manager" carry an interlock-equivalent device, so they are INTERLOCKED
# and excluded; 2103-2106/2113 all carry a room thermostat.) Each of these
# triggers the Table 4c(2) (PDF p.169) "No thermostatic control of room
# temperature regular boiler" -5pp Space + DHW seasonal-efficiency
# adjustment. The combi rows of Table 4c(2) take Space -5 / DHW 0; the DHW
# leg is gated separately on a cylinder being present at the call site.
# NB this is the interlock criterion only — the separate "+0.6 °C" Table 4e
# temperature adjustment applies to 2101/2102 alone (it lives in
# `_CONTROL_TEMPERATURE_ADJUSTMENT_BY_CODE`, where 2107/2111 stay at 0.0).
_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES: Final[frozenset[int]] = frozenset(
{2101, 2102}
{2101, 2102, 2107, 2111}
)

View file

@ -4720,6 +4720,44 @@ def test_table_4c_no_boiler_interlock_applies_minus_5_dhw_adjustment_when_cylind
)
def test_controls_2107_2111_count_as_no_room_thermostat_per_sap_9_4_11() -> None:
# Arrange — SAP 10.2 §9.4.11 (PDF p.66): a boiler with no room
# thermostat (or an equivalent device — flow switch / boiler energy
# manager) has no interlock; "TRVs alone ... do not perform the boiler
# interlock function" and a fixed bypass exists precisely to keep water
# circulating when the TRVs close. Control 2107 ("Programmer, TRVs and
# bypass") and 2111 ("TRVs and bypass") therefore carry the same
# no-room-thermostat treatment as 2101/2102 — including the Table 4f
# footnote a) ×1.3 circulation-pump uplift — which they previously
# missed (the set held only 2101/2102). Control 2106 ("Programmer, room
# thermostat and TRVs") HAS a room thermostat → interlock → no uplift.
# A 2013+ wet gas boiler pumps 41 kWh (Table 4f); ×1.3 = 53.3.
import dataclasses
from domain.sap10_calculator.rdsap.cert_to_inputs import _table_4f_circulation_pump_kwh # pyright: ignore[reportPrivateUsage]
base = _gas_boiler_detail() # cat-2 wet boiler
no_stat_2107 = dataclasses.replace(
base, main_heating_control=2107, central_heating_pump_age=2,
)
no_stat_2111 = dataclasses.replace(
base, main_heating_control=2111, central_heating_pump_age=2,
)
with_stat_2106 = dataclasses.replace(
base, main_heating_control=2106, central_heating_pump_age=2,
)
# Act
pump_2107 = _table_4f_circulation_pump_kwh(no_stat_2107) # pyright: ignore[reportPrivateUsage]
pump_2111 = _table_4f_circulation_pump_kwh(no_stat_2111) # pyright: ignore[reportPrivateUsage]
pump_2106 = _table_4f_circulation_pump_kwh(with_stat_2106) # pyright: ignore[reportPrivateUsage]
# Assert — bypass/TRVs-only controls get the ×1.3 uplift; 2106 does not.
assert abs(pump_2107 - 41.0 * 1.3) <= 1e-9
assert abs(pump_2111 - 41.0 * 1.3) <= 1e-9
assert abs(pump_2106 - 41.0) <= 1e-9
def test_sap_9_4_11_no_boiler_interlock_applies_minus_5_pcdb_space_heating_when_main_is_gas_oil_boiler_with_cylinder_no_thermostat() -> None:
"""SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock":