fix(water-heating): assume cylinder thermostat present for electric/immersion/heat-network DHW (SAP 9.4.9)

SAP 10.2 §9.4.9 (PDF p.32) verbatim: "A cylinder thermostat should be
assumed to be present when the domestic hot water is obtained from a heat
network, an immersion heater, a thermal store, a combi boiler or a CPSU."
RdSAP 10 Table 29 (p.56) points the no-access default at this rule.

The storage-loss Table 2b temperature factor previously read only the
lodged `cylinder_thermostat` ("Y") — so an unlodged thermostat always took
the ×1.3 absent-penalty, over-stating storage loss by 30%. New
`_cylinder_thermostat_present` assumes it present when DHW is from a heat
network, WHC 903 (immersion), or a direct-acting electric boiler (SAP code
191 — electric-resistance, immersion-equivalent).

Found via the worksheet-folder harness: cert 2474-3059-4202-4496-3200
(Summary path: WHC 901, main SAP 191, electric, no lodged cylinder stat)
diverged −1.86 from its dr87 worksheet. The worksheet lodges (53)
temperature factor 0.6000 (present) and "add cylinder thermostat (SAP
increase too small)" — already assumed present. Fix lands HW output (64)
2701.99 → 2323.88, EXACT to the worksheet; 2474 −1.86 → −0.87 (residual is
a separate space-demand fabric thread). No other worksheet in the 47-cert
harness moved.

API eval within-0.5 56.9% → 57.6%; mean|err| 1.197 → 1.185; signed
−0.202 → −0.165. Regression green (only pre-existing fails); goldens +
heating corpus unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-10 21:01:05 +00:00
parent 00921f71e8
commit 3cb2711418
2 changed files with 92 additions and 1 deletions

View file

@ -6174,6 +6174,42 @@ def _primary_loss_override(
return base
def _cylinder_thermostat_present(
epc: EpcPropertyData, main: Optional[MainHeatingDetail],
) -> bool:
"""Whether a cylinder thermostat is present for the Table 2b temperature
factor (absent ×1.3 penalty on the storage loss).
A lodged "Y" wins. Otherwise SAP 10.2 §9.4.9 (PDF p.32) verbatim: "A
cylinder thermostat should be assumed to be present when the domestic
hot water is obtained from a heat network, an immersion heater, a
thermal store, a combi boiler or a CPSU." RdSAP 10 Table 29 (PDF p.56)
points the no-access default at this rule. So a cylinder heated by an
immersion (WHC 903), a direct-acting electric boiler (SAP code 191
electric-resistance, immersion-equivalent), or a heat network gets the
base Table 2b factor (no absent-thermostat ×1.3).
Cert 2474 (Summary path: WHC 901, main SAP 191, electric, no lodged
cylinder thermostat) is the case: the dr87 worksheet lodges (53)
temperature factor 0.6000 (thermostat present), and the "add cylinder
thermostat" recommendation reads "SAP increase too small" because it
is already assumed present. Without this the cascade applied ×1.3 and
over-stated storage loss by ~378 kWh/yr (SAP 1.86)."""
if epc.sap_heating.cylinder_thermostat == "Y":
return True
dhw_main = _water_heating_main(epc)
if _is_heat_network_main(dhw_main):
return True
if epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION:
return True
if (
dhw_main is not None
and dhw_main.sap_main_heating_code == _DIRECT_ACTING_ELECTRIC_BOILER_CODE
):
return True
return False
def _cylinder_storage_loss_override(
epc: EpcPropertyData,
main: Optional[MainHeatingDetail],
@ -6217,7 +6253,7 @@ def _cylinder_storage_loss_override(
volume_l=volume_l,
insulation_type=insulation_label,
thickness_mm=float(thickness_mm),
has_cylinder_thermostat=sh.cylinder_thermostat == "Y",
has_cylinder_thermostat=_cylinder_thermostat_present(epc, main),
# SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the
# ×0.9 multiplier to boiler / warm-air / heat-pump systems —
# community heating excluded. Gate via the dedicated helper so

View file

@ -49,6 +49,7 @@ from domain.sap10_calculator.exceptions import (
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
_apply_heat_network_hiu_default_store, # pyright: ignore[reportPrivateUsage]
_cylinder_thermostat_present, # pyright: ignore[reportPrivateUsage]
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage]
_heat_network_community_fuel_code, # pyright: ignore[reportPrivateUsage]
@ -365,6 +366,60 @@ def test_heat_network_primary_loss_uses_p1_h3_all_months_per_table_3() -> None:
assert abs(sum(wh_result.primary_loss_monthly_kwh) - expected_primary_kwh) <= 1e-6
def test_cylinder_thermostat_assumed_present_per_sap_9_4_9() -> None:
# Arrange — SAP 10.2 §9.4.9 (PDF p.32): "A cylinder thermostat should be
# assumed to be present when the domestic hot water is obtained from a
# heat network, an immersion heater, a thermal store, a combi boiler or
# a CPSU." A direct-acting electric boiler (SAP code 191) heats the
# cylinder by electric resistance (immersion-equivalent) → assumed
# present even with no lodged cylinder thermostat. Reproduces cert
# 2474-3059-4202-4496-3200 (Summary path: WHC 901, main SAP 191).
direct_electric = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=30, # standard electricity
heat_emitter_type=0,
emitter_temperature="NA",
main_heating_control=2106,
main_heating_category=2,
sap_main_heating_code=191,
)
part = make_building_part(construction_age_band="D")
epc_191 = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[part],
sap_heating=make_sap_heating(
main_heating_details=[direct_electric],
water_heating_code=901, # from main, no lodged cylinder stat
),
)
# A gas boiler is NOT in the §9.4.9 list — its cylinder keeps the
# absent-thermostat default (Table 2b ×1.3) when none is lodged.
gas_boiler = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=26,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2,
sap_main_heating_code=102,
)
epc_gas = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[part],
sap_heating=make_sap_heating(
main_heating_details=[gas_boiler], water_heating_code=901,
),
)
# Act / Assert — 191 electric DHW assumes present; gas boiler does not.
assert _cylinder_thermostat_present(epc_191, direct_electric) is True
assert _cylinder_thermostat_present(epc_gas, gas_boiler) is False
def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None:
# Arrange — heat-network main (Table 4a code 301 = community heating,
# category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution