slice S-B13: skip cylinder + primary HW losses for instantaneous systems

Water heating codes 907 (single-point gas) and 909 (electric instantaneous)
describe no-cylinder, point-of-use systems with no primary circuit.
The predicted_hot_water_kwh model was adding 366 kWh cylinder-storage
loss + 245 kWh primary-pipework loss on top of useful demand for these
certs — over-counting HW by 600+ kWh.

Discovered hand-tracing cert 2903-8339 (11m² Top-floor flat studio,
water_heating_code=909, actual SAP 75, predicted 55).

100-cert parity probe:
  MAE 4.53 → 4.48   (-0.05)
  RMSE 5.96 → 5.81
  bias -0.57 → -0.52

Smaller MAE delta than S-B12 because instantaneous-HW certs are a
smaller subset, but the affected dwellings are exactly the worst-
residual tail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 16:02:51 +00:00
parent 1a6996abbb
commit d004dc3f5c

View file

@ -128,6 +128,12 @@ _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
# Water-heating codes for instantaneous (no-cylinder) systems — SAP §4
# Appendix J skips cylinder-storage + primary-pipework losses for these
# because there's no cylinder and no primary circuit.
_INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909})
@dataclass(frozen=True)
class PriceTable:
"""Seam between the spec-correct SAP 10.2/10.3 Table 12 prices and
@ -534,13 +540,22 @@ def cert_to_inputs(
main_category=main_category,
main_fuel=main_fuel,
)
is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES
hw_kwh = predicted_hot_water_kwh(
total_floor_area_m2=epc.total_floor_area_m2,
seasonal_efficiency_water=water_eff,
cylinder_size=_int_or_none(epc.sap_heating.cylinder_size),
cylinder_insulation_thickness_mm=epc.sap_heating.cylinder_insulation_thickness_mm,
cylinder_insulation_type=_int_or_none(epc.sap_heating.cylinder_insulation_type),
age_band=primary_age,
# Instantaneous HW systems (codes 907/909) have no cylinder and
# no primary circuit — pass None for both to suppress storage
# and primary losses in the demand model.
cylinder_size=None if is_instantaneous else _int_or_none(epc.sap_heating.cylinder_size),
cylinder_insulation_thickness_mm=(
None if is_instantaneous else epc.sap_heating.cylinder_insulation_thickness_mm
),
cylinder_insulation_type=(
None if is_instantaneous
else _int_or_none(epc.sap_heating.cylinder_insulation_type)
),
age_band=None if is_instantaneous else primary_age,
has_wwhrs=False,
has_solar_water_heating=epc.solar_water_heating,
)