Slice S0380.140: §4 cylinder storage loss — extractor picks up §16 thermostat lodging + Table 2b note b restricts ×0.9 to boiler/warm-air/HP systems

Two compounding bugs were over-counting the SAP 10.2 §4 (56)m cylinder
storage loss by ~76 kWh/yr across all 17 cylinder-with-immersion
corpus variants (cascade HW kWh 2460.40 vs worksheet 2384.12):

(1) **Extractor gap.** Elmhurst Summary §15.1 "Hot Water Cylinder"
    block lodges `Cylinder Size` / `Insulation Thickness` but NOT
    `Cylinder Thermostat`. The thermostat is lodged separately in
    §16 "Recommendations" as `Cylinder thermostat (Already installed)`.
    The extractor only searched §15.1, so `cylinder_thermostat`
    resolved to None for every variant on property 001431. The
    cascade then defaulted `has_cylinder_thermostat=False`, applying
    SAP 10.2 Table 2b's ×1.3 "no thermostat" multiplier.

(2) **Cascade spec gap.** `_separately_timed_dhw` returned True for
    any cylinder-lodged cert regardless of HW fuel. Per SAP 10.2
    Table 2b note b) (PDF p.159):

    > "Multiply Temperature Factor by 0.9 if there is separate time
    >  control of domestic hot water (boiler systems, warm air systems
    >  and heat pump systems)"

    Electric immersion is NOT in the bracketed list — the ×0.9
    reduction is restricted to boiler / warm-air / HP systems. Pre-
    slice the cascade over-applied ×0.9 on electric-immersion certs.

Combined, the cascade computed TF = 0.60 × 1.3 × 0.9 = 0.702 vs the
worksheet's TF = 0.60 (base — thermostat present, immersion exempt).
After both fixes the cascade HW kWh matches the worksheet's (64) at
1e-3 precision (2384.116 vs 2384.12).

Corpus impact (16 cylinder-with-immersion variants on 18-hour meter):

| variant      | SAP_c shift | Cost shift |
|--------------|------------:|-----------:|
| electric 1   | -0.20 →   -0.06 |  -£3.34 |
| electric 2   | -1.27 →   +0.47 |  -£4.44 |
| electric 3   | +2.42 →   +2.55 |  -£2.91 |
| electric 5   | -0.06 →   +0.07 |  -£3.06 |
| electric 6   | +1.19 →   +1.33 |  -£3.20 |
| electric 7   | +1.14 →   +1.29 |  -£3.35 |
| electric 8   | -0.41 →   -0.26 |  -£3.50 |
| electric 9   | -0.24 →   -0.12 |  -£2.91 |
| solid fuel 4-11 | -0.45..-0.09 → -0.29..+0.10 | -£3 to -£4 |

The HW kWh line closes cleanly; some SAP residuals sign-flip slightly
because the cascade's now-correct HW kWh exposes the SH+Sec demand
mismatch for storage heaters (electric 3/6/7 — open driver is the
Table 11 `main_heating_category=None` default for codes 401/402,
queued for a mapper-side slice).

Tests:
- new AAA test `test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b`
- 16 corpus pins re-tightened (8 electric + 8 solid fuel)

Extended handover suite: 883 pass (was 882; +1 new test), 0 fail.
Pyright net-zero on touched files (43 → 43 errors, all pre-existing).

Per [[feedback-spec-citation-in-commits]] +
[[feedback-spec-floor-skepticism]] (the "HW +76 kWh uniform overcount"
across 17 variants traced to TWO spec-citable defaults the cascade
was getting wrong, not a precision floor).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 19:03:58 +00:00
parent c4db37db19
commit 068088bc2f
4 changed files with 137 additions and 20 deletions

View file

@ -1344,6 +1344,19 @@ class ElmhurstSiteNotesExtractor:
if cylinder_thermostat_raw is not None if cylinder_thermostat_raw is not None
else None else None
) )
# Fallback: Elmhurst Summary §16 "Recommendations" block carries
# existing fittings as `<feature> (Already installed)` lines.
# When §15.1 doesn't lodge "Cylinder Thermostat" directly, treat
# the "Cylinder thermostat (Already installed)" recommendation
# line as confirmation that the thermostat is present (per
# S0380.140 corpus probe — all 41 variants on property 001431
# lodge this in §16 but none in §15.1, so the §15.1-only lookup
# returned None and the cascade defaulted `has_cylinder_thermostat
# = False`, mis-applying SAP 10.2 Table 2b's ×1.3 "no thermostat"
# multiplier).
if cylinder_thermostat is None:
if "Cylinder thermostat (Already installed)" in self._lines:
cylinder_thermostat = True
return WaterHeating( return WaterHeating(
water_heating_code=self._str_val("Water Heating Code"), water_heating_code=self._str_val("Water Heating Code"),
water_heating_sap_code=self._int_val("Water Heating SapCode"), water_heating_sap_code=self._int_val("Water Heating SapCode"),

View file

@ -196,16 +196,38 @@ class _CorpusExpectation:
# = Table 11 Cat 7). Total absolute SAP residual across the cluster # = Table 11 Cat 7). Total absolute SAP residual across the cluster
# went from 10.10 to 5.46. _RDSAP_DEFINITELY_OFF_PEAK frozenset was # went from 10.10 to 5.46. _RDSAP_DEFINITELY_OFF_PEAK frozenset was
# deleted (dead code; canonical dispatch covers it). # deleted (dead code; canonical dispatch covers it).
#
# Slice S0380.140 fixed the §4 worksheet (56)m cylinder storage loss
# cascade. Two compounding bugs were over-counting (56)m by ~76 kWh/yr
# across all 17 cylinder-with-immersion corpus variants:
# (1) the Elmhurst Summary §16 "Recommendations" block lodges the
# cylinder thermostat as "Cylinder thermostat (Already
# installed)" — but the extractor only looked in §15.1 for the
# label "Cylinder Thermostat", so the field was None for every
# variant on property 001431. The cascade defaulted
# `has_cylinder_thermostat=False`, mis-applying SAP 10.2 Table
# 2b's ×1.3 "no thermostat" multiplier;
# (2) `_separately_timed_dhw` returned True for any cylinder cert,
# but Table 2b note b restricts the ×0.9 separately-timed
# multiplier to "boiler systems, warm air systems and heat
# pump systems" — electric immersion is not in the list.
# Combined, the cascade computed TF = 0.60 × 1.3 × 0.9 = 0.702 vs
# the worksheet's TF = 0.60 (base — thermostat present, immersion
# exempt from ×0.9). After both fixes the cascade HW kWh matches the
# worksheet's (64) at 1e-3 (2384.116 vs 2384.12). Cost shifts -£3..-£6
# per affected variant, SAP residuals shift ±0.15 across 16 variants;
# the SH+Sec demand mismatch for electric 3/6/7 (Table 11 fraction
# for codes 401/402) remains the open driver of those SAP residuals.
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017), _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017),
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.2021, expected_cost_resid_gbp=+4.6562, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+164.9052), _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0573, expected_cost_resid_gbp=+1.3188, expected_co2_resid_kg=+8.0120, expected_pe_resid_kwh=+94.4789),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-1.2714, expected_cost_resid_gbp=+29.2944, expected_co2_resid_kg=+94.4364, expected_pe_resid_kwh=+970.7570), _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+0.4737, expected_cost_resid_gbp=-10.9153, expected_co2_resid_kg=+10.9544, expected_pe_resid_kwh=+100.9401),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.4189, expected_cost_resid_gbp=-55.7339, expected_co2_resid_kg=-112.3439, expected_pe_resid_kwh=-1059.2875), _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+2.5452, expected_cost_resid_gbp=-58.6455, expected_co2_resid_kg=-117.8401, expected_pe_resid_kwh=-1121.9666),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-0.0579, expected_cost_resid_gbp=+1.3337, expected_co2_resid_kg=-5.3096, expected_pe_resid_kwh=-95.6333), _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+0.0747, expected_cost_resid_gbp=-1.7232, expected_co2_resid_kg=-11.0752, expected_pe_resid_kwh=-161.0345),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.1888, expected_cost_resid_gbp=-27.3926, expected_co2_resid_kg=-50.0685, expected_pe_resid_kwh=-494.3960), _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+1.3278, expected_cost_resid_gbp=-30.5954, expected_co2_resid_kg=-56.1047, expected_pe_resid_kwh=-562.5298),
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.1449, expected_cost_resid_gbp=-26.3805, expected_co2_resid_kg=-31.5507, expected_pe_resid_kwh=-427.5932), _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+1.2903, expected_cost_resid_gbp=-29.7300, expected_co2_resid_kg=-37.8591, expected_pe_resid_kwh=-498.4709),
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.4086, expected_cost_resid_gbp=+9.4133, expected_co2_resid_kg=+18.2051, expected_pe_resid_kwh=+199.7233), _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-0.2568, expected_cost_resid_gbp=+5.9163, expected_co2_resid_kg=+11.6231, expected_pe_resid_kwh=+126.0896),
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.2444, expected_cost_resid_gbp=+5.6333, expected_co2_resid_kg=+11.1781, expected_pe_resid_kwh=+154.0936), _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-0.1181, expected_cost_resid_gbp=+2.7217, expected_co2_resid_kg=+5.6819, expected_pe_resid_kwh=+91.4145),
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023), _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023),
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919), _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919),
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
@ -221,14 +243,14 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# control-type gaps — separate slices. # control-type gaps — separate slices.
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624), _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624),
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983), _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983),
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.4528, expected_cost_resid_gbp=+10.4331, expected_co2_resid_kg=-78.9461, expected_pe_resid_kwh=+151.1685), _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.2919, expected_cost_resid_gbp=+6.7262, expected_co2_resid_kg=-68.4116, expected_pe_resid_kwh=+89.7782),
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.3350, expected_cost_resid_gbp=+7.7205, expected_co2_resid_kg=-52.5294, expected_pe_resid_kwh=+160.0328), _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.1655, expected_cost_resid_gbp=+3.8136, expected_co2_resid_kg=-44.3197, expected_pe_resid_kwh=+92.8384),
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-0.0902, expected_cost_resid_gbp=+2.0800, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778), _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+0.0281, expected_cost_resid_gbp=-0.6473, expected_co2_resid_kg=+0.6642, expected_pe_resid_kwh=+44.7851),
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0025, expected_cost_resid_gbp=-0.0583, expected_co2_resid_kg=-91.3569, expected_pe_resid_kwh=+44.3084), _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0994, expected_cost_resid_gbp=-2.3310, expected_co2_resid_kg=-75.1034, expected_pe_resid_kwh=+16.7917),
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.2280, expected_cost_resid_gbp=+5.2530, expected_co2_resid_kg=+26.9399, expected_pe_resid_kwh=+87.6830), _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.0804, expected_cost_resid_gbp=+1.8511, expected_co2_resid_kg=+18.0444, expected_pe_resid_kwh=+45.1812),
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.3344, expected_cost_resid_gbp=+7.7031, expected_co2_resid_kg=+28.0233, expected_pe_resid_kwh=+154.9673), _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.1956, expected_cost_resid_gbp=+4.5065, expected_co2_resid_kg=+19.6820, expected_pe_resid_kwh=+92.8981),
_CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.2932, expected_cost_resid_gbp=+6.7559, expected_co2_resid_kg=+25.7581, expected_pe_resid_kwh=+119.8372), _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.1605, expected_cost_resid_gbp=+3.6988, expected_co2_resid_kg=+17.7916, expected_pe_resid_kwh=+66.5227),
_CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.4180, expected_cost_resid_gbp=+9.6325, expected_co2_resid_kg=+32.7399, expected_pe_resid_kwh=+170.5611), _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.2633, expected_cost_resid_gbp=+6.0671, expected_co2_resid_kg=+23.5398, expected_pe_resid_kwh=+104.1723),
) )

View file

@ -3432,21 +3432,35 @@ def _separately_timed_dhw(
"""SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature """SAP 10.2 Table 2b note b) (PDF p.159): "Multiply Temperature
Factor by 0.9 if there is separate time control of domestic hot Factor by 0.9 if there is separate time control of domestic hot
water (boiler systems, warm air systems and heat pump systems)". water (boiler systems, warm air systems and heat pump systems)".
RdSAP §3 default: when a hot-water cylinder is lodged, DHW timing The spec restricts the ×0.9 reduction to those three system types
is separate from space heat the cylinder is heated on its own electric immersion DHW is NOT in the list, so the ×0.9 multiplier
programmer / overnight boost regardless of which heat generator must NOT apply when the water-heating fuel is electric (whether
(boiler, HP, or combi-acting-as-boiler) feeds it. on a standard meter or off-peak immersion timer).
RdSAP §3 default: when a hot-water cylinder is lodged AND the
cylinder is fed by a boiler / warm-air / HP, DHW timing is separate
from space heat the cylinder is heated on its own programmer /
overnight boost regardless of which heat generator feeds it.
Combi-only dwellings (no cylinder) skip the multiplier DHW is Combi-only dwellings (no cylinder) skip the multiplier DHW is
instantaneous and shares the boiler's space-heating cycle, so instantaneous and shares the boiler's space-heating cycle, so
there's no separate timer. Heat pumps (cat 4) keep their existing there's no separate timer. Heat pumps (cat 4) keep their existing
always-True default for the HP-without-cylinder edge case the always-True default for the HP-without-cylinder edge case the
earlier cohort calibration was sized around. earlier cohort calibration was sized around.
Pre-S0380.140 this returned True for any cylinder-lodged cert
regardless of HW fuel, which over-applied the ×0.9 multiplier on
electric-immersion certs. Combined with the cascade's
`cylinder_thermostat is None False` fallback (over-applying ×1.3),
these compounded to TF=0.702 vs the worksheet's TF=0.60, over-
counting (56)m storage loss by ~76 kWh/yr × 17 corpus variants.
""" """
if main is None: if main is None:
return False return False
if main.main_heating_category == 4: if main.main_heating_category == 4:
return True return True
if _is_electric_water(epc.sap_heating.water_heating_fuel):
return False
return bool(epc.has_hot_water_cylinder) return bool(epc.has_hot_water_cylinder)

View file

@ -49,6 +49,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
_responsiveness, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage]
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage] _secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
_separately_timed_dhw, # pyright: ignore[reportPrivateUsage]
_space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage] _tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage]
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage] _water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
@ -1430,6 +1431,73 @@ def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None:
assert _is_off_peak_meter("Unknown", fuel_is_electric=False) is False assert _is_off_peak_meter("Unknown", fuel_is_electric=False) is False
def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b() -> None:
# Arrange — SAP 10.2 Table 2b note b) (PDF p.159) restricts the
# `×0.9 separately-timed` temperature-factor multiplier to "boiler
# systems, warm air systems and heat pump systems". Electric
# immersion DHW is NOT in this list — its `separately-timed` schedule
# is implicit in the off-peak tariff and doesn't earn the ×0.9
# storage-loss reduction. Pre-S0380.140 `_separately_timed_dhw`
# returned True for every cylinder-lodged cert regardless of HW fuel,
# which over-applied the ×0.9 multiplier on electric-immersion certs.
# Combined with the cascade's `cylinder_thermostat is None → False`
# fallback that simultaneously over-applied ×1.3, these compounded
# to TF=0.702 (≈0.60×1.3×0.9) when the worksheet uses TF=0.60 (base
# — has thermostat, not separately timed for immersion). The
# cylinder-storage-loss (56)m line over-counted by ~76 kWh/yr across
# the 17 cylinder-with-immersion variants of the corpus.
main_gas_boiler = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=26, # mains gas
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2, # gas boiler
sap_main_heating_code=102,
)
cylinder_with_gas_boiler_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
sap_heating=make_sap_heating(
main_heating_details=[main_gas_boiler],
water_heating_fuel=26, # gas → boiler-fed HW
water_heating_code=901,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=38,
),
)
cylinder_with_immersion_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
sap_heating=make_sap_heating(
main_heating_details=[main_gas_boiler],
water_heating_fuel=30, # standard electricity → immersion
water_heating_code=903,
cylinder_size=2,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=38,
),
)
# Act
sep_boiler_fed = _separately_timed_dhw(
cylinder_with_gas_boiler_epc, main_gas_boiler,
)
sep_immersion = _separately_timed_dhw(
cylinder_with_immersion_epc, main_gas_boiler,
)
# Assert — gas-boiler-fed cylinder keeps the ×0.9 multiplier (boiler
# system per Table 2b note b); electric-immersion cylinder does not.
assert sep_boiler_fed is True
assert sep_immersion is False
def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None: def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None:
# Arrange — an electric storage heater (SAP code 401) on an 18-hour # Arrange — an electric storage heater (SAP code 401) on an 18-hour
# tariff. `_table_12a_system_for_main` returns None for storage # tariff. `_table_12a_system_for_main` returns None for storage