Slice S0380.179: RdSAP §10.7 electric-immersion default for no-system certs

Closes the "no system" corpus variant fully (ΔSAP +1.18 → <1e-4 on all
four metrics).

The cert lodges §15.0 "Water Heating Code: NON / SapCode 999" and §15.1
"Hot Water Cylinder Present: No". Per RdSAP 10 §10.7 (PDF p.55) "No
water heating system" verbatim: "the calculation is done for an
electric immersion heater. If the electric meter is dual the immersion
heater is also dual, but is a single immersion otherwise... for a
cylinder defined by the first row of Table 28 (110 litres) and the
first row of Table 29." Table 29 row 1 gives age-band cylinder
insulation (age G -> 25 mm foam) and assumes a cylinder thermostat
present for immersion-heated DHW.

The BRE-approved Elmhurst engine confirms the substitution: the P960
worksheet header lodges "WHS: 903 Electric immersion, Single", a 110 L
cylinder, and storage loss (56) = 594.32 kWh/yr, so HW (64) = (45)
1935.37 + 594.32 = 2529.6927.

Pre-slice the cascade trusted the lodged "no cylinder" -> added no
storage loss and a spurious Table 3a keep-hot combi loss; the wrong HW
heat-gains also propagated through §5/§7, over-stating the base MIT by
+0.25 K and space fuel by +228 kWh. New
`_apply_rdsap_no_water_heating_system_default(epc)` rebinds the epc at
the top of cert_to_inputs (the demand cascade delegates here too) when
water_heating_code == 999, injecting WHC 903 + electricity fuel +
110 L cylinder + Table 29 insulation + assumed cylinder thermostat.
This closes HW fuel AND the downstream space residual in one move.

Age bands A-F (12 mm loose jacket) raise UnmappedSapCode — no corpus
member exercises that and the Table 2 loss-factor dispatch only has the
factory-foam path plumbed. Gate is keyed on code 999, unique to "no
system" in the corpus; 40 other variants + 858 section pins + 6 U985
fixtures unchanged. 936 pass; pyright net-zero 32 -> 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 14:44:45 +00:00
parent c054d71284
commit f2062a2fbe
2 changed files with 117 additions and 2 deletions

View file

@ -511,7 +511,21 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# residual (SAP +1.18, cost £27 / CO2 50 / PE 562) — likely a
# cascade-side §A.2.2 efficiency or tariff-routing gap; pinned as
# forcing function for follow-up.
_CorpusExpectation(variant='no system', block='11a', expected_sap_resid=+1.1783, expected_cost_resid_gbp=-27.1485, expected_co2_resid_kg=-49.8272, expected_pe_resid_kwh=-562.4367),
# Slice S0380.179 closed `no system` via RdSAP 10 §10.7 (PDF p.55)
# "No water heating system": the cert lodges §15.0 water code 999
# (NON) + §15.1 "Cylinder Present: No", but per spec the calculation
# is done for an electric immersion heater on a Table 28 row-1 110 L
# cylinder with Table 29 row-1 age-band insulation (25 mm foam at age
# G). The P960 worksheet header confirms the engine's substitution
# (WHS 903 Single immersion, 110 L). Pre-slice the cascade trusted
# the lodged "no cylinder" → no storage loss (56) + a spurious Table
# 3a combi loss, and the wrong HW heat-gains propagated through §5/§7
# to over-state the base MIT (+0.25 K), over-stating space fuel by
# +228 kWh. `_apply_rdsap_no_water_heating_system_default` injects
# the default cylinder before the section cascades, closing HW fuel
# (219) 1935.37 → 2529.69 EXACT AND the space residual in one move.
# ΔSAP +1.18 → <1e-4, all four metrics EXACT.
_CorpusExpectation(variant='no system', 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),
# Slice S0380.170 unblocked the 5 community-heating variants. Per
# SAP 10.2 Table 12 (PDF p.189) the heat-network fuel code comes
# from the §14.1 Community Heat Source × Community Fuel Type pair:
@ -920,6 +934,35 @@ def test_oil_6_absent_room_thermostat_applies_table_4f_pump_1_3_multiplier() ->
)
def test_no_system_assumes_rdsap_10_7_electric_immersion_default_cylinder() -> None:
# Arrange — the "no system" cert lodges §15.0 "Water Heating Code:
# NON / SapCode 999" and §15.1 "Hot Water Cylinder Present: No". Per
# RdSAP 10 §10.7 (PDF p.55) "No water heating system" verbatim: "the
# calculation is done for an electric immersion heater... for a
# cylinder defined by the first row of Table 28 (110 litres) and the
# first row of Table 29." The BRE-approved Elmhurst engine confirms
# it — the P960 worksheet header lodges "WHS: 903 Electric immersion,
# Single", a 110 L cylinder, and Table 29 age-band insulation (the
# corpus property is age G -> 25 mm foam), giving storage loss (56) =
# 594.32 kWh/yr. Worksheet HW (64) = (45) 1935.37 + (56) 594.32 =
# 2529.6927. Pre-slice the cascade trusted the lodged "no cylinder"
# so it added no storage loss (and a spurious Table 3a combi loss).
summary_pdf, _ = _variant_paths('no system')
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 HW fuel kWh.
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
# Assert — HW fuel = (45) + Table 29 110 L / 25 mm-foam storage loss
# = 2529.6927 (matches worksheet (64)/(219)).
assert abs(inputs.hot_water_kwh_per_yr - 2529.6927) <= 1e-3, (
f"no system HW {inputs.hot_water_kwh_per_yr:.4f} kWh != 2529.6927 "
f"(RdSAP 10 §10.7 electric-immersion default 110 L cylinder)"
)
@pytest.mark.skipif(
not _BLOCKED_BY_MISSING_MAIN_FUEL_TYPE,
reason="all blocked variants have been unblocked (latest: S0380.170)",

View file

@ -50,7 +50,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification
from __future__ import annotations
import math
from dataclasses import dataclass
from dataclasses import dataclass, replace
from decimal import ROUND_HALF_UP, Decimal
from typing import Callable, Final, Literal, Optional
@ -4142,6 +4142,72 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
# "Foam" → factory-applied per SAP 10.2 Table 2 Note 2).
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
# RdSAP 10 §10.7 (PDF p.55) "No water heating system": SAP water-heating
# code 999 (Elmhurst §15.0 "NON") signals that no DHW system was
# identified. Per spec the calculation is then done for an electric
# immersion heater + a cylinder defined by the first row of Table 28
# (110 litres) and the first row of Table 29 (age-band insulation).
_WHC_NO_WATER_HEATING_SYSTEM: Final[int] = 999
# Table 28 row 1 "Inaccessible — otherwise: 110 litres" → SAP cylinder
# size code 2 (Normal, 110 L). The immersion is single unless the meter
# is dual; the corpus "no system" cert's worksheet header lodges
# "Immersion Heater Type: Single" so the single-immersion path is used.
_CYLINDER_SIZE_CODE_NORMAL_110L: Final[int] = 2
# RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
# accessible" — the §10.7 default cylinder uses the age-band insulation,
# same rule as the inaccessible-cylinder path: A-F → 12 mm loose jacket
# (not yet plumbed — strict-raise), G/H → 25 mm foam, I-M → 38 mm foam.
_TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE: Final[dict[str, int]] = {
"G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38,
}
def _apply_rdsap_no_water_heating_system_default(
epc: EpcPropertyData,
) -> EpcPropertyData:
"""RdSAP 10 §10.7 (PDF p.55) — when no water heating system is
identified (`water_heating_code == 999`), substitute the spec
default: an electric immersion heater (single dual handling not
yet exercised) on a Table 28 row-1 110 L cylinder with Table 29
row-1 age-band insulation and an assumed cylinder thermostat
(Table 29: "A cylinder thermostat should be assumed to be present
when DHW is from ... an immersion heater ...").
Returns `epc` unchanged when a real water heating system is lodged.
Otherwise returns a copy with `has_hot_water_cylinder=True` and the
`sap_heating` fields the WHC-903 electric-immersion + cylinder
cascade reads, so every downstream gate (storage loss, combi-loss
suppression, primary loss) sees the spec default. This mirrors the
Elmhurst engine's worksheet header for the corpus "no system" cert
(WHS 903, Single immersion, 110 L cylinder, 25 mm foam at age G).
Raises `UnmappedSapCode` for age bands A-F (12 mm loose jacket)
no corpus member exercises that combination and the SAP 10.2 Table 2
loss-factor dispatch only has the factory-foam path plumbed.
"""
if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM:
return epc
age_band = (
epc.sap_building_parts[0].construction_age_band
if epc.sap_building_parts else None
)
band = (age_band or "")[:1].upper()
thickness_mm = _TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE.get(band)
if thickness_mm is None:
raise UnmappedSapCode(
"rdsap_10_7_default_cylinder_insulation_age_band", age_band
)
sap_heating = replace(
epc.sap_heating,
water_heating_code=_WHC_ELECTRIC_IMMERSION,
water_heating_fuel=_STANDARD_ELECTRICITY_FUEL_CODE,
cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L,
cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY,
cylinder_insulation_thickness_mm=thickness_mm,
cylinder_thermostat="Y",
)
return replace(epc, has_hot_water_cylinder=True, sap_heating=sap_heating)
# SAP 10.2 Table 4a solid-fuel boiler sub-rows (PDF p.163) — independent
# boilers (151, 153, 155, 159), open-fire + back boiler (156), closed
@ -5483,6 +5549,12 @@ def cert_to_inputs(
parity validation now relies on the Validation Cohort filter
(inspection_date 2025-07-01) rather than a per-cert price
override."""
# RdSAP 10 §10.7 (PDF p.55) — substitute the electric-immersion +
# default-cylinder assumption before any section cascade runs when no
# water heating system is lodged (code 999). Rebinding `epc` here
# means every downstream helper sees the spec default; the demand
# cascade reuses this entry point so it is covered too.
epc = _apply_rdsap_no_water_heating_system_default(epc)
dim = dimensions_from_cert(epc)
# SAP §3 heat transmission + §2 ventilation cascades — see the
# respective `_from_cert` helpers for cert→inputs mapping rules.