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 committed by Jun-te Kim
parent c60a2ddc17
commit e2b0c940ba
4 changed files with 137 additions and 20 deletions

View file

@ -1344,6 +1344,19 @@ class ElmhurstSiteNotesExtractor:
if cylinder_thermostat_raw is not 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(
water_heating_code=self._str_val("Water Heating Code"),
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
# went from 10.10 to 5.46. _RDSAP_DEFINITELY_OFF_PEAK frozenset was
# 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, ...] = (
_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 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 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 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 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 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 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 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 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=+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.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.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.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.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.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.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='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),
@ -221,14 +243,14 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# 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 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 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 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 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 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 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 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 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 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.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.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.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.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.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.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.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
Factor by 0.9 if there is separate time control of domestic hot
water (boiler systems, warm air systems and heat pump systems)".
RdSAP §3 default: when a hot-water cylinder is lodged, DHW timing
is separate from space heat the cylinder is heated on its own
programmer / overnight boost regardless of which heat generator
(boiler, HP, or combi-acting-as-boiler) feeds it.
The spec restricts the ×0.9 reduction to those three system types
electric immersion DHW is NOT in the list, so the ×0.9 multiplier
must NOT apply when the water-heating fuel is electric (whether
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
instantaneous and shares the boiler's space-heating cycle, so
there's no separate timer. Heat pumps (cat 4) keep their existing
always-True default for the HP-without-cylinder edge case the
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:
return False
if main.main_heating_category == 4:
return True
if _is_electric_water(epc.sap_heating.water_heating_fuel):
return False
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]
_responsiveness, # 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]
_tariff_high_low_rates_p_per_kwh, # 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
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:
# Arrange — an electric storage heater (SAP code 401) on an 18-hour
# tariff. `_table_12a_system_for_main` returns None for storage