Slice S0380.177: oil 6 boiler interlock from room thermostat absence

oil 6 (B30K standard liquid-fuel boiler, Table 4b code 126 winter 80 /
summer 68) lodges Main Heating Controls Sap code 2101 ("No time or
thermostatic control of room temperature") WITH a cylinder thermostat.
The cascade's `no_interlock` gate only checked the cylinder thermostat,
so oil 6 kept raw efficiency despite the P960 worksheet header lodging
"Boiler Interlock: No".

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." Control
code 2101 (and 2102 "Programmer, no room thermostat") provides no room
thermostat — the two Table 4e Group 1 rows carrying the "+0.6 °C /
Table 4c(2)" annotation — so the boiler is NOT interlocked regardless
of the cylinderstat. SAP 10.2 Table 4c(2) (PDF p.169) "No thermostatic
control of room temperature – regular boiler" then deducts 5pp from
BOTH the Space and DHW seasonal efficiency.

Three changes in cert_to_inputs.py:
- new `_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES = {2101, 2102}`;
- `no_interlock` now ORs room-thermostat absence with the existing
  stored-HW cylinderstat-absence test (the RdSAP §3 conjunction);
- the Space -5pp leg fires for Table 4b non-PCDB boilers (code
  101-141), not only PCDB-record boilers; the DHW leg is gated on a
  cylinder being present (Table 4c(2) combi DHW = 0).

Result for oil 6: space fuel (211) = 13446.3457 EXACT, HW fuel (219) =
4099.5872 EXACT. ΔSAP +3.0518 → +0.0782, Δcost -£69.79 → -£1.68,
ΔCO2 -240.66 → -1.71, ΔPE -1112.66 → -18.61.

The spec-correct fix exposes a single residual cause (per
[[feedback-software-no-special-handling]]): the central heating pump
(230c) — cascade reads pump_age=2 → Table 4f 41 kWh but ws (230c) =
53.3 kWh. The 12.3 kWh gap fully accounts for the residual across all
three metrics; pinned as the S0380.178 forcing function.

All other 40 corpus variants + 858 section pins + 6 U985 fixtures
unchanged (2101/2102 boiler codes appear only on oil 6). Pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 14:13:51 +00:00 committed by Jun-te Kim
parent a862111795
commit 546bca3277
2 changed files with 111 additions and 5 deletions

View file

@ -448,7 +448,29 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='oil 3', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 4', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+3.0518, expected_cost_resid_gbp=-69.7943, expected_co2_resid_kg=-240.6595, expected_pe_resid_kwh=-1112.6558),
# Slice S0380.177 closed oil 6 (B30K, Table 4b regular boiler code
# 126) main heating + HW efficiency via the SAP 10.2 Table 4c(2)
# (PDF p.169) "No thermostatic control of room temperature regular
# boiler" -5pp adjustment. The cert lodges control code 2101 (no
# room thermostat) WITH a cylinder thermostat; per RdSAP 10 §3 (PDF
# p.57) boiler interlock needs BOTH a room thermostat AND a cylinder
# thermostat, so the 2101 control means NO interlock despite the
# cylinderstat (P960 header "Boiler Interlock: No"). Pre-slice the
# `no_interlock` gate only checked the cylinder thermostat, so oil 6
# kept raw efficiency: space 0.80 vs ws (210) 0.75, HW (217)m summer
# 68 vs ws 63. Post-slice space fuel (211) = 13446.3457 EXACT and HW
# fuel (219) = 4099.5872 EXACT. ΔSAP +3.0518 → +0.0782; Δcost
# -£69.79 → -£1.68; ΔCO2 -240.66 → -1.71; ΔPE -1112.66 → -18.61.
#
# The residual that remains is a SINGLE distinct cause the interlock
# fix exposed: the central heating pump (230c). Cascade reads
# `central_heating_pump_age=2` → Table 4f 41 kWh, but ws (230c) =
# 53.3 kWh (non-standard — not a Table 4f age value of 41/115/165;
# likely a lodged pump power). The 12.3 kWh gap fully explains the
# residual: cost 12.3 x 0.1367 = £1.68, CO2 12.3 x 0.1387 = 1.71 kg,
# PE 12.3 x 1.5128 = 18.61 kWh. Pinned as the next-slice forcing
# function (S0380.178 central-heating-pump 53.3 kWh).
_CorpusExpectation(variant='oil 6', block='11a', expected_sap_resid=+0.0782, expected_cost_resid_gbp=-1.6814, expected_co2_resid_kg=-1.7061, expected_pe_resid_kwh=-18.6074),
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000),
_CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000),
@ -838,6 +860,36 @@ def test_heating_systems_corpus_residual_matches_pin(
)
def test_oil_6_no_room_thermostat_applies_table_4c2_minus_5pp_space_efficiency() -> None:
# Arrange — oil 6 (B30K standard liquid-fuel boiler, Table 4b code
# 126 winter 80 / summer 68) lodges "Main Heating Controls Sap: SAP
# code 2101, No time or thermostatic control of room temperature"
# WITH a cylinder thermostat present. 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." Control 2101 provides no
# room thermostat, so the boiler is NOT interlocked despite the
# cylinder thermostat. SAP 10.2 Table 4c(2) (PDF p.169) "No
# thermostatic control of room temperature regular boiler" deducts
# 5pp from BOTH the space and DHW seasonal efficiency. The worksheet
# confirms it: P960 header "Boiler Interlock: No"; (210) space
# efficiency = 75.0000 = 80 - 5; (217)m summer = 63.0000 = 68 - 5.
summary_pdf, _ = _variant_paths('oil 6')
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act — run the rating cascade and read the resolved space efficiency.
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Assert — Table 4b 80% winter less the Table 4c(2) -5pp interlock
# penalty = 75% (matches worksheet (210)).
assert abs(inputs.main_heating_efficiency - 0.75) <= 1e-9, (
f"oil 6 space efficiency {inputs.main_heating_efficiency:.4f} "
f"!= 0.75 (Table 4b 0.80 - Table 4c(2) 0.05 interlock penalty)"
)
@pytest.mark.skipif(
not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE,
reason="all blocked variants have been unblocked (latest: S0380.170)",

View file

@ -1022,6 +1022,25 @@ _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.
_BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES: Final[frozenset[int]] = frozenset(
{2101, 2102}
)
# SAP 10.2 Table 4e (PDF p.171-173) — "Temperature adjustment, °C"
# column. Spec verbatim (p.170): "3. The 'Temperature adjustment'
# modifies the mean internal temperature and is added to worksheet
@ -5551,11 +5570,33 @@ def cert_to_inputs(
# cylinder + Cylinder Stat: No) closes 65% → 60% — matches
# worksheet (210) exactly. Cert 000565 closes WH 79% → 74%
# unchanged from S0380.79.
no_interlock = (
# RdSAP 10 §3 (PDF p.57): a gas/liquid-fuel boiler is interlocked iff
# it has BOTH a room thermostat AND (for stored hot water) a cylinder
# thermostat. Two independent ways to lose interlock:
# (a) no room thermostat — control code 2101 / 2102 (Table 4e
# Group 1 "no thermostatic control of room temperature"), e.g.
# oil 6 (B30K, code 2101; P960 header "Boiler Interlock: No"
# despite "Cylinder Stat: Yes");
# (b) stored HW from the boiler with no cylinder thermostat.
# Either triggers the Table 4c(2) (PDF p.169) -5pp seasonal-
# efficiency adjustment. The DHW leg is additionally gated on a
# cylinder being present (regular boiler — Table 4c(2) "no
# thermostatic control / no interlock combi" takes DHW 0).
no_room_thermostat = (
main is not None
and main.main_heating_control
in _BOILER_NO_ROOM_THERMOSTAT_CONTROL_CODES
)
no_stored_hw_interlock = (
epc.has_hot_water_cylinder
and epc.sap_heating.cylinder_thermostat != "Y"
)
if no_interlock and water_pcdb_main is not None:
no_interlock = no_room_thermostat or no_stored_hw_interlock
if (
no_interlock
and water_pcdb_main is not None
and epc.has_hot_water_cylinder
):
water_eff -= 0.05
# Resolve the (winter, summer) seasonal efficiency pair that feeds
# the SAP 10.2 Appendix D §D2.1 (2) Equation D1 monthly cascade.
@ -5593,7 +5634,17 @@ def cert_to_inputs(
eq_d1_winter_summer_pct = table_4b_seasonal_efficiencies_pct(
main.sap_main_heating_code
)
if no_interlock and pcdb_main is not None:
# Space leg of the Table 4c(2) adjustment — applies to PCDB-record
# boilers AND Table 4b non-PCDB boilers (code 101-141), regular and
# combi alike (both take Space -5). oil 6 (Table 4b code 126, pcdb_
# main None) reaches the penalty only via the Table 4b branch.
if no_interlock and (
pcdb_main is not None
or (
main is not None
and main.sap_main_heating_code in _TABLE_4B_CODE_RANGE
)
):
eff -= 0.05
# SAP 10.2 §9.4.11 -5pp interlock is applied to the Eq D1 OUTPUT
# via `_apply_water_efficiency`'s `interlock_penalty_pp` kwarg —
@ -5602,7 +5653,10 @@ def cert_to_inputs(
# in η; the worksheet's (217)m for pcdb 1 matches the post-Eq-D1
# form. See `_apply_water_efficiency` docstring + S0380.165 commit.
eq_d1_interlock_penalty_pp = (
5.0 if no_interlock and eq_d1_winter_summer_pct is not None
5.0
if no_interlock
and eq_d1_winter_summer_pct is not None
and epc.has_hot_water_cylinder
else 0.0
)
# SAP 10.2 Appendix N3.6 + N3.7(a) — when an HP cert lodges a PCDB