S0380.224: compute storage loss for loose-jacket cylinders (Table 2 Note 1)

`_cylinder_storage_loss_override` returned None for any cylinder whose
cylinder_insulation_type wasn't 1 (factory), so a loose-jacket cylinder
(code 2, RdSAP 10 field 7-11) fell to the cascade's zero-storage-loss
combi/instantaneous default — its real storage loss vanished. SAP 10.2
Table 2 Note 1 gives loose jacket a SEPARATE, ~2× higher loss factor
(L = 0.005 + 1.76/(t+12.8) vs factory 0.005 + 0.55/(t+4)); the
cylinder_storage_loss_factor_table_2 helper already implements it — only
the dispatch was missing.

Fix: a `_cylinder_storage_loss_insulation_label` resolver maps the lodged
code to the Table 2 branch (1 → factory_insulated, 2 → loose_jacket;
None/0/unknown → None, keeping the conservative no-loss default). The
override and the HW storage call now route through it instead of
hardcoding "factory_insulated".

Evidence + validation: a random 2026 register sample has 22 loose-jacket
certs that over-predicted SAP by +2.29 mean (18/22 too high, 1/22 within
0.5) — the exact signature of under-counted HW storage loss. After the
fix their mean error collapses to +0.45 and 11/22 land within 0.5, with
ZERO regression across the worksheet-validated cohort (§4 + golden suite
2394 passed — no validated cert lodges loose jacket, so none shifts).
Also unblocks the §10.7 A-F no-water-heating default (next slice) which
needs the loose-jacket branch. cert_to_inputs.py pyright unchanged at 32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 16:19:35 +00:00
parent 69fdbf9f1d
commit 2e351be957
2 changed files with 98 additions and 2 deletions

View file

@ -4582,6 +4582,27 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
# from the ASHP cohort (all 7 certs lodge code 1, worksheet shows
# "Foam" → factory-applied per SAP 10.2 Table 2 Note 2).
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
# RdSAP 10 field 7-11 (cylinder insulation type) — code 2 = loose jacket,
# which SAP 10.2 Table 2 Note 1 gives a SEPARATE (higher) loss factor
# L = 0.005 + 1.76 / (t + 12.8) vs the factory L = 0.005 + 0.55 / (t+4).
_CYLINDER_INSULATION_TYPE_LOOSE_JACKET: Final[int] = 2
def _cylinder_storage_loss_insulation_label(
insulation_type: "int | str | None",
) -> Optional[Literal["factory_insulated", "loose_jacket"]]:
"""Map the lodged cylinder_insulation_type code to the SAP 10.2
Table 2 loss-factor branch. Code 1 factory-insulated, code 2
loose jacket. Any other value (None / 0 / unknown) None so the
caller keeps the conservative no-storage-loss default rather than
guessing a loss branch. Accepts the int / digit-string / None shapes
`cylinder_insulation_type` arrives in across the two front-ends."""
code = _int_or_none(insulation_type)
if code == _CYLINDER_INSULATION_TYPE_FACTORY:
return "factory_insulated"
if code == _CYLINDER_INSULATION_TYPE_LOOSE_JACKET:
return "loose_jacket"
return None
# 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
@ -5556,14 +5577,17 @@ def _cylinder_storage_loss_override(
volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
if volume_l is None:
return None
if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY:
insulation_label = _cylinder_storage_loss_insulation_label(
sh.cylinder_insulation_type
)
if insulation_label is None:
return None
thickness_mm = sh.cylinder_insulation_thickness_mm
if thickness_mm is None:
return None
storage_56m = cylinder_storage_loss_monthly_kwh(
volume_l=volume_l,
insulation_type="factory_insulated",
insulation_type=insulation_label,
thickness_mm=float(thickness_mm),
has_cylinder_thermostat=sh.cylinder_thermostat == "Y",
# SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the

View file

@ -3960,6 +3960,78 @@ def test_air_source_heat_pump_pcdb_104568_derives_apm_efficiencies_per_sap_app_n
)
def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_branch() -> None:
"""SAP 10.2 Table 2 (PDF p.158) Note 1 gives a SEPARATE storage loss
factor for a loose-jacket cylinder: L = 0.005 + 1.76 / (t + 12.8),
~2× the factory-insulated L = 0.005 + 0.55 / (t + 4.0) at the same
thickness. The EPB API lodges cylinder_insulation_type=2 = loose
jacket (1 = factory-applied). Before this fix
`_cylinder_storage_loss_override` returned None for every non-factory
type, so a loose-jacket cylinder fell to the zero-storage-loss combi
default a systematic HW under-count (a 2026 register sample of 22
such certs over-predicted SAP by +2.29 mean). The override must route
insulation_type=2 to the Table 2 loose-jacket branch.
"""
# Arrange — identical to the factory storage-loss test but
# cylinder_insulation_type=2 (loose jacket) instead of 1.
from domain.sap10_calculator.worksheet.water_heating import (
cylinder_storage_loss_factor_table_2,
cylinder_temperature_factor_table_2b,
cylinder_volume_factor_table_2a,
)
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,
)
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()],
sap_heating=make_sap_heating(
main_heating_details=[hp_main],
water_heating_code=901,
cylinder_size=3, # Medium → 160 L
cylinder_insulation_type=2, # loose jacket
cylinder_insulation_thickness_mm=50,
cylinder_thermostat="Y",
),
)
# Expected (56)m Jan from the Table 2 loose-jacket branch (same V /
# VF / TF as the factory test — only the loss factor L differs).
loss_factor = cylinder_storage_loss_factor_table_2(
insulation_type="loose_jacket", thickness_mm=50.0
)
vol_factor = cylinder_volume_factor_table_2a(160.0)
temp_factor = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=True, separately_timed_dhw=True
)
expected_jan_kwh = 160.0 * loss_factor * vol_factor * temp_factor * 31
# 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 — non-None (was the zero-loss default) and equal to the
# loose-jacket branch, distinctly larger than the factory 36.9530.
assert wh_result is not None
got_jan_kwh = wh_result.solar_storage_monthly_kwh[0]
assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4
assert got_jan_kwh > 36.9530 # loose jacket loses more than factory
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: