Slice 102d: primary circuit loss via SAP 10.2 Table 3 with PCDB vessel gate

SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary circuit
loss for cylinders heated indirectly through primary pipework:
  (59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 − p)} × h + 0.0263]

Inputs:
  p  pipework insulation fraction — Table 3 rows: 0.0 uninsulated,
     0.1 first 1 m, 0.3 all accessible, 1.0 fully insulated. RdSAP §3
     default table (PDF p.56) supplies p by construction age band:
     bands A-J → 0.0, K, L, M → 1.0.
  h  hours per day of primary circulation, winter / summer split:
     • no cylinder thermostat               → 11 / 3
     • thermostat, NOT separately timed     →  5 / 3
     • thermostat, separately timed         →  3 / 3
     ("Use summer value for June, July, August and September and
     winter value for other months" — spec p.159 footer.)

Spec p.159 lists the zero-loss configurations:
  - electric immersion heater
  - combi boiler
  - CPSU
  - thermal store within single casing
  - separate boiler + thermal store within 1.5 m insulated pipe
  - direct-acting electric boiler
  - heat pump from PCDB with HW vessel integral to package
The cohort gate is now PCDB-aware: HP main + PCDB Table 362 record
`hw_vessel_mode != 1` (i.e. non-integral) → primary loss applies. All
7 cohort ASHPs lodge `hw_vessel_mode = 2` (separate and specified)
per Table 362 records 104568 (Mitsubishi) and 102421 (Daikin).

Cert 0380 (band D → p=0.0; cylinder thermostat + separately-timed →
h=3 / 3) lands (59)Jan = 31 × 14 × (0.0245 × 3 + 0.0263) = 43.3132
kWh/month (test pinned at 1e-4 vs cert's dr87 worksheet).

Cumulative cert 0380 API state:
  HW kWh/yr 431.4 → 653.1 (target 878, slice 102e closes via η_water)
  SAP    92.3 → 91.2  (delta to worksheet 88.51 now +2.73, was +3.75)

Cohort regression: cert 0390-2954 (oil boiler + cylinder, age F →
band A-J p=0.0) now picks up ~516 kWh/yr primary loss, tightening PE
residual -27.50 → -26.01 and CO2 -2.66 → -2.52 (improvements). The
higher HW fuel shifts SAP residual -6 → -7. Re-pinned with slice-102d
note. Closed combi boiler certs (001479, 0330, 9501) unaffected:
has_hot_water_cylinder=false gates the primary-loss override to None.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 12:14:05 +00:00
parent 5b78a1e2c8
commit c4a1045c8f
4 changed files with 264 additions and 8 deletions

View file

@ -68,8 +68,14 @@ from domain.sap10_ml.sap_efficiencies import (
water_heating_efficiency as _legacy_water_heating_efficiency,
)
from domain.sap10_calculator.calculator import CalculatorInputs
from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record
from domain.sap10_calculator.tables.pcdb.parser import GasOilBoilerRecord
from domain.sap10_calculator.tables.pcdb import (
gas_oil_boiler_record,
heat_pump_record,
)
from domain.sap10_calculator.tables.pcdb.parser import (
GasOilBoilerRecord,
HeatPumpRecord,
)
from domain.sap10_calculator.tables.pcdb.postcode_weather import (
PostcodeClimate,
postcode_climate,
@ -143,11 +149,14 @@ from domain.sap10_calculator.worksheet.ventilation import (
ventilation_from_inputs,
)
from domain.sap10_calculator.worksheet.water_heating import (
PIPEWORK_INSULATED_FULLY,
PIPEWORK_INSULATED_UNINSULATED,
TABLE_J1_TCOLD_FROM_MAINS_C,
WaterHeatingResult,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
cylinder_storage_loss_monthly_kwh,
primary_loss_monthly_kwh,
water_efficiency_monthly_via_equation_d1,
water_heating_from_cert,
)
@ -1882,6 +1891,62 @@ def _separately_timed_dhw(main: Optional[MainHeatingDetail]) -> bool:
return False
# RdSAP §3 default table (PDF p.56) — "Insulation of primary pipework":
# age bands A-J → none (p=0.0); age bands K, L, M → full (p=1.0). The
# default applies when the cert does not lodge an explicit insulation
# fraction — which is the modal case for the Open EPC API (no field).
_PIPEWORK_FULL_INSULATION_AGE_BANDS: Final[frozenset[str]] = frozenset(
{"K", "L", "M"}
)
def _pipework_insulation_fraction_table_3(primary_age: Optional[str]) -> float:
"""RdSAP §3 default for primary pipework insulation by age band.
Bands K, L, M (post-2007) 1.0 fully insulated; A-J 0.0
uninsulated. Unknown age band defaults to 0.0 (the conservative
older-stock assumption matching cert 0380's worksheet 'Uninsulated
primary pipework' lodgement).
"""
if primary_age in _PIPEWORK_FULL_INSULATION_AGE_BANDS:
return PIPEWORK_INSULATED_FULLY
return PIPEWORK_INSULATED_UNINSULATED
def _primary_loss_applies(
main: Optional[MainHeatingDetail],
cylinder_present: bool,
hp_record: Optional[HeatPumpRecord],
) -> bool:
"""SAP 10.2 Table 3 (PDF p.159) zero-loss configurations — primary
loss only fires when a cylinder is present AND the lodgement falls
outside the zero list. The cohort path: heat-pump main heating with
a separate (not integral) vessel per the PCDB Table 362 record.
Combi boilers, CPSUs, thermal stores within 1.5 m insulated pipe,
direct-acting electric boilers, electric immersion heaters, and
HPs with `hw_vessel_mode = 1` (integral) all skip the loss. For
cohort coverage we model two paths:
- HP with PCDB record: gate on `hp_record.hw_vessel_mode != 1`
- Boiler (cat 1, 2) with cylinder: primary loss applies (the
cascade's pre-slice-102d behaviour was zero, masking ~516
kWh/yr on certs with cylinders).
"""
if not cylinder_present:
return False
if main is None:
return False
if main.main_heating_category == 4:
if hp_record is None:
# No PCDB record → assume separate-vessel (conservative; the
# zero-loss "integral vessel" branch requires explicit PCDB
# confirmation per spec).
return True
# Spec p.159: zero for "Heat pump from PCDB with hot water vessel
# integral to package". Vessel mode 1 = integral.
return hp_record.hw_vessel_mode != 1
return main.main_heating_category in {1, 2}
def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> bool:
"""Gate for the Table 3a keep-hot 600 kWh/yr fall-through per SAP 10.2
§4 line 7702. Returns True only when the main heating system is in the
@ -1942,6 +2007,10 @@ def _water_heating_worksheet_and_gains(
# combi systems, so the override is only built when the cert explicitly
# lodges a cylinder.
storage_loss_override = _cylinder_storage_loss_override(epc, main)
# SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) — primary circuit loss
# (59)m. Only fires for indirect cylinders; HPs with integral
# vessels and combi boilers are in the spec's zero list.
primary_loss_override = _primary_loss_override(epc, main, primary_age)
wh_result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
@ -1950,12 +2019,41 @@ def _water_heating_worksheet_and_gains(
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
solar_storage_monthly_kwh_override=storage_loss_override,
primary_loss_monthly_kwh_override=primary_loss_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
return wh_result, wh_result.heat_gains_monthly_kwh
def _primary_loss_override(
epc: EpcPropertyData,
main: Optional[MainHeatingDetail],
primary_age: Optional[str],
) -> Optional[tuple[float, ...]]:
"""Resolve (59)m for `water_heating_from_cert` from the cert + PCDB
Table 362 record (for HP mains). Returns None when primary loss does
not apply (combi boiler, integral-vessel HP, no cylinder, etc.) so
the cascade keeps its zero default. Pipework insulation fraction p
comes from RdSAP §3 age-band default (no API field); circulation
hours h come from Table 3 keyed on cylinder thermostat + separately-
timed-DHW lodgement.
"""
cylinder_present = bool(epc.has_hot_water_cylinder)
hp_record: Optional[HeatPumpRecord] = None
if main is not None and main.main_heating_index_number is not None:
hp_record = heat_pump_record(main.main_heating_index_number)
if not _primary_loss_applies(main, cylinder_present, hp_record):
return None
return primary_loss_monthly_kwh(
pipework_insulation_fraction=_pipework_insulation_fraction_table_3(
primary_age
),
has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y",
separately_timed_dhw=_separately_timed_dhw(main),
)
def _cylinder_storage_loss_override(
epc: EpcPropertyData,
main: Optional[MainHeatingDetail],

View file

@ -1086,6 +1086,82 @@ def test_cert_with_hot_water_cylinder_computes_storage_loss_56m_from_sap_tables_
)
def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3() -> None:
"""SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary
circuit loss for an indirect cylinder:
(59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 p)} × h + 0.0263]
where
n_m = days in month,
p = fraction of primary pipework insulated (0.0 uninsulated,
0.1 first 1m, 0.3 all accessible, 1.0 fully insulated),
h = hours per day of circulation (5 winter / 3 summer if
cylinder thermostat present; 3 / 3 if DHW separately
timed; 11 / 3 if no cylinder thermostat).
RdSAP §3 default table (PDF p.56) supplies pipework insulation by
age band: bands A-J none (p=0.0); bands K, L, M full (p=1.0).
Cert 0380 (band D = 1950-1966 p=0.0; cylinder thermostat lodged
+ separately-timed DHW h=3 winter and summer) yields
(59)Jan = 31 × 14 × (0.0245 × 3 + 0.0263)
= 31 × 14 × 0.0998
= 43.3132 kWh/month
matching the cert 0380 dr87 worksheet pin to 4 d.p.
Spec PDF p.159 lists configurations for which the primary loss is
zero ("Combi boiler", "Electric immersion heater", "Heat pump from
PCDB with hot water vessel integral to package", etc.). Cert 0380
uses a heat pump with separate-and-specified vessel
(`hw_vessel_mode = 2` in PCDB Table 362), so the loss applies.
"""
# Arrange — synthetic ASHP cert mirroring cert 0380: cat=4, PCDB
# 104568 (Mitsubishi 5 kW Ecodan, separate-specified vessel),
# cylinder lodged with thermostat, separately-timed DHW, age band D.
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2206,
main_heating_category=4,
sap_main_heating_code=None,
main_heating_index_number=104568,
)
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_building_parts=[make_building_part(construction_age_band="D")],
sap_heating=make_sap_heating(
main_heating_details=[hp_main],
water_heating_code=901,
cylinder_size=3,
cylinder_insulation_type=1,
cylinder_insulation_thickness_mm=50,
cylinder_thermostat="Y",
),
)
# Act
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=1.7,
is_instantaneous=False,
primary_age="D",
pcdb_record=None,
)
# Assert — (59)m Jan matches worksheet at 1e-4.
assert wh_result is not None
expected_jan_kwh = 43.3132
got_jan_kwh = wh_result.primary_loss_monthly_kwh[0]
assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4, (
f"(59)Jan: got {got_jan_kwh!r}, want {expected_jan_kwh!r} per "
f"SAP 10.2 §4 line 7700 + Table 3"
)
def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_line_7702() -> None:
"""SAP 10.2 §4 line 7702 worksheet defines (61)m as 'Combi loss for
each month from Table 3a, 3b or 3c (enter "0" if not a combi

View file

@ -120,16 +120,20 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=-27.5026,
expected_co2_resid_tonnes_per_yr=-2.6570,
expected_sap_resid=-7,
expected_pe_resid_kwh_per_m2=-26.0093,
expected_co2_resid_tonnes_per_yr=-2.5211,
notes=(
"Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges "
"has_draught_lobby=true and a 160 L factory-insulated cylinder. "
"Slice 97 added glazing_type=2 — windows now drop to spec U=2.0, "
"widening PE → -28.68 and CO2 → -2.76. Slice 102b then applied "
"SAP 10.2 Tables 2/2a/2b cylinder storage loss (~432 kWh/yr), "
"tightening PE -28.68 → -27.50 and CO2 -2.76 → -2.66."
"tightening PE -28.68 → -27.50 and CO2 -2.76 → -2.66. Slice 102d "
"then added SAP 10.2 Table 3 primary circuit loss (~516 kWh/yr "
"uninsulated, age band F → A-J default p=0.0), tightening PE "
"-27.50 → -26.01, CO2 -2.66 → -2.52, and shifting SAP residual "
"-6 → -7 (cost of the higher HW fuel)."
),
),
_GoldenExpectation(

View file

@ -59,6 +59,7 @@ class WaterHeatingResult:
energy_content_monthly_kwh: tuple[float, ...]
distribution_loss_monthly_kwh: tuple[float, ...]
solar_storage_monthly_kwh: tuple[float, ...] # (57)m — Tables 2/2a/2b
primary_loss_monthly_kwh: tuple[float, ...] # (59)m — Table 3
combi_loss_monthly_kwh: tuple[float, ...]
total_demand_monthly_kwh: tuple[float, ...]
output_monthly_kwh: tuple[float, ...]
@ -487,6 +488,76 @@ def cylinder_temperature_factor_table_2b(
return factor
# SAP 10.2 Table 3 (PDF p.159) — primary circuit loss for boilers and
# heat pumps connected to a hot water cylinder via insulated or
# uninsulated primary pipework. The spec lists the zero-loss
# configurations explicitly (combi boilers, integral-vessel heat pumps,
# CPSUs, thermal stores within 1.5 m insulated pipe, etc.); callers
# must gate this helper on those exemptions.
PIPEWORK_INSULATED_UNINSULATED: Final[float] = 0.0
PIPEWORK_INSULATED_FIRST_METRE: Final[float] = 0.1
PIPEWORK_INSULATED_ALL_ACCESSIBLE: Final[float] = 0.3
PIPEWORK_INSULATED_FULLY: Final[float] = 1.0
# Per Table 3 hours-per-day table: 5 winter / 3 summer if cylinder
# thermostat present and water heating not separately timed; 3 / 3 if
# cylinder thermostat present AND separately timed; 11 / 3 if no
# cylinder thermostat. "Use summer value for June, July, August and
# September and winter value for other months."
_SUMMER_MONTH_INDICES: Final[tuple[int, ...]] = (5, 6, 7, 8) # Jun..Sep
def primary_circuit_hours_per_day_table_3(
*,
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
) -> tuple[float, float]:
"""SAP 10.2 Table 3 (PDF p.159) — hours of primary circulation per
day, returned as `(winter_hours, summer_hours)`:
no thermostat (11, 3)
thermostat, not separately timed ( 5, 3)
thermostat, separately timed ( 3, 3)
"""
if not has_cylinder_thermostat:
return (11.0, 3.0)
if separately_timed_dhw:
return (3.0, 3.0)
return (5.0, 3.0)
def primary_loss_monthly_kwh(
*,
pipework_insulation_fraction: float,
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (59)m via Table 3 (PDF p.159):
(59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 p)} × h + 0.0263]
where p is the fraction of primary pipework insulated and h is the
hours of primary circulation per day (winter / summer split per
`primary_circuit_hours_per_day_table_3`).
Returns 12 monthly values in calendar order Jan..Dec. Callers must
gate this helper on the spec's zero-loss configurations
(combi boilers, integral-vessel HPs, CPSUs, thermal stores 1.5 m
insulated pipe, etc.) the formula assumes the configuration
incurs the loss.
"""
p = pipework_insulation_fraction
pipework_term = 0.0091 * p + 0.0245 * (1.0 - p)
winter_h, summer_h = primary_circuit_hours_per_day_table_3(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
)
return tuple(
n * 14.0 * (
pipework_term * (summer_h if m in _SUMMER_MONTH_INDICES else winter_h)
+ 0.0263
)
for m, n in enumerate(_DAYS_IN_MONTH)
)
def cylinder_storage_loss_monthly_kwh(
*,
volume_l: float,
@ -728,6 +799,7 @@ def water_heating_from_cert(
low_water_use: bool,
combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
solar_storage_monthly_kwh_override: Optional[tuple[float, ...]] = None,
primary_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
electric_shower_monthly_kwh_override: Optional[tuple[float, ...]] = None,
has_electric_shower: bool = False,
electric_shower_count: int = 0,
@ -813,11 +885,16 @@ def water_heating_from_cert(
if solar_storage_monthly_kwh_override is not None
else zero12
)
primary_loss = (
primary_loss_monthly_kwh_override
if primary_loss_monthly_kwh_override is not None
else zero12
)
total_demand = total_water_heating_demand_monthly_kwh(
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
solar_storage_monthly_kwh=solar_storage,
primary_loss_monthly_kwh=zero12,
primary_loss_monthly_kwh=primary_loss,
combi_loss_monthly_kwh=combi,
)
output = output_from_water_heater_monthly_kwh(
@ -845,7 +922,7 @@ def water_heating_from_cert(
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
solar_storage_monthly_kwh=solar_storage,
primary_loss_monthly_kwh=zero12,
primary_loss_monthly_kwh=primary_loss,
combi_loss_monthly_kwh=combi,
electric_shower_monthly_kwh=electric_shower,
)
@ -856,6 +933,7 @@ def water_heating_from_cert(
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
solar_storage_monthly_kwh=solar_storage,
primary_loss_monthly_kwh=primary_loss,
combi_loss_monthly_kwh=combi,
total_demand_monthly_kwh=total_demand,
output_monthly_kwh=output,