fix(water-heating): use lodged cylinder_heat_loss declared-loss factor

The gov API lodges a manufacturer's declared cylinder loss factor
(kWh/day) in `sap_heating.cylinder_heat_loss`, in which case it leaves
the cylinder volume / insulation type / thickness None. That field was
undeclared on the 21.0.x schemas, so `from_dict` dropped it — then
`_cylinder_storage_loss_override` hit its insulation-None / volume-None
guards and returned None, dropping the §4 storage loss ENTIRELY. The
dwelling over-rated (the declared loss is typically ~1.5 kWh/day ≈
550 kWh/yr).

SAP 10.2 §4 branch a) (PDF p.136): when the declared loss factor is
known, storage loss (50) = (48) declared loss × (49) Table-2b
temperature factor — replacing the Table 2 V×L×VF computation.

- declare `cylinder_heat_loss` on RdSapSchema21_0_0/21_0_1.SapHeating +
  EpcPropertyData.SapHeating; thread through the 21.0.x mappers.
- `cylinder_storage_loss_monthly_kwh` gains `declared_loss_kwh_per_day`:
  when set, combined_55 = declared × TF (volume/insulation unused).
- `_cylinder_storage_loss_override` resolves the declared loss BEFORE the
  insulation/volume guards (the gov omits those when the loss is lodged).

12 /tmp certs carry it (mean |err| 3.00 -> 2.51; the clean ones close
hard, e.g. 2360 2.65 -> 0.30, 0245 2.25 -> 0.53). Corpus within-0.5
67.0% -> 67.3% (MAE 1.025 -> 1.020); /tmp 71.2% -> 71.4% (0.889 ->
0.882). Worksheet harness 47/47; regression = only the 3 pre-existing
fails; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 05:27:47 +00:00
parent 7cfd54129b
commit b55b969b84
8 changed files with 139 additions and 8 deletions

View file

@ -153,6 +153,12 @@ class SapHeating:
None # int from API; str from site notes
)
cylinder_insulation_thickness_mm: Optional[int] = None
# SAP 10.2 §4 branch a) — manufacturer's declared cylinder loss factor
# (kWh/day). When present, `_cylinder_storage_loss_override` uses it
# directly (× Table-2b temperature factor) in place of the Table 2
# V×L×VF computation; the gov lodges it instead of cylinder volume /
# insulation, so it must be read or the storage loss is dropped.
cylinder_heat_loss: Optional[float] = None
# SAP10 hot-water demand inputs from sap_heating.
number_baths: Optional[int] = None
number_baths_wwhrs: Optional[int] = None

View file

@ -1588,6 +1588,7 @@ class EpcPropertyDataMapper:
== "true",
cylinder_size=schema.sap_heating.cylinder_size,
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
cylinder_heat_loss=schema.sap_heating.cylinder_heat_loss,
water_heating_code=schema.sap_heating.water_heating_code,
water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type,
@ -1902,6 +1903,7 @@ class EpcPropertyDataMapper:
== "true",
cylinder_size=schema.sap_heating.cylinder_size,
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
cylinder_heat_loss=schema.sap_heating.cylinder_heat_loss,
water_heating_code=schema.sap_heating.water_heating_code,
water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type,

View file

@ -435,6 +435,28 @@ class TestFromRdSapSchema21_0_1:
# Assert
assert result.sap_building_parts[0].rafter_insulation_thickness == "225mm"
def test_cylinder_heat_loss_threaded(
self, schema: RdSapSchema21_0_1
) -> None:
# Arrange — the gov API lodges the manufacturer's declared cylinder
# loss factor (kWh/day) in `sap_heating.cylinder_heat_loss` (SAP
# 10.2 §4 branch a). Previously undeclared → `from_dict` dropped it
# and the §4 storage loss fell to None → the dwelling over-rated.
import dataclasses
patched = dataclasses.replace(
schema,
sap_heating=dataclasses.replace(
schema.sap_heating, cylinder_heat_loss=1.72
),
)
# Act
result = EpcPropertyDataMapper.from_rdsap_schema_21_0_1(patched)
# Assert
assert result.sap_heating.cylinder_heat_loss == 1.72
# --- property flags ---
def test_solar_water_heating(self, result: EpcPropertyData) -> None:

View file

@ -79,6 +79,11 @@ class SapHeating:
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
# only when `cylinder_size` is the "Exact" descriptor (code 6).
cylinder_size_measured: Optional[int] = None
# SAP 10.2 §4 branch a) (PDF p.136) — the manufacturer's declared
# cylinder loss factor (kWh/day). When lodged it replaces the Table 2
# V×L×VF storage-loss computation. Previously undeclared → dropped by
# `from_dict`, so the storage loss fell through to None.
cylinder_heat_loss: Optional[float] = None
@dataclass

View file

@ -84,6 +84,12 @@ class SapHeating:
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
# only when `cylinder_size` is the "Exact" descriptor (code 6).
cylinder_size_measured: Optional[int] = None
# SAP 10.2 §4 branch a) (PDF p.136) — the manufacturer's declared
# cylinder loss factor (kWh/day). When lodged it replaces the Table 2
# V×L×VF storage-loss computation (the gov leaves volume/insulation
# None in that case). Previously undeclared → dropped by `from_dict`,
# so the storage loss fell through to None and the dwelling over-rated.
cylinder_heat_loss: Optional[float] = None
@dataclass

View file

@ -3992,6 +3992,22 @@ def _int_or_none(value: object) -> Optional[int]:
return value if isinstance(value, int) else None
def _float_or_none(value: object) -> Optional[float]:
"""Coerce a lodged numeric (int / float / numeric string) to float,
else None. Used for measured overrides like the cylinder declared
loss factor (`cylinder_heat_loss`, kWh/day)."""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return None
return None
def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float:
"""RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from
the MAIN building's wall construction.
@ -6489,7 +6505,31 @@ def _cylinder_storage_loss_override(
if not epc.has_hot_water_cylinder:
return None
sh = epc.sap_heating
# SAP 10.2 §4 branch a) (PDF p.136) — a lodged manufacturer's declared
# cylinder loss factor (kWh/day, gov-API `cylinder_heat_loss`) replaces
# the Table 2 V×L×VF computation. It does NOT need the insulation
# type / thickness / volume (which the gov leaves None precisely
# because the declared loss is lodged instead), so resolve it BEFORE
# those guards — otherwise the storage loss is dropped entirely and the
# dwelling over-rates (the declared-loss is typically ~1.5 kWh/day ≈
# 550 kWh/yr). The Table-2b temperature factor still applies (49)→(50).
declared_loss = _float_or_none(getattr(sh, "cylinder_heat_loss", None))
volume_l = _cylinder_volume_l_from_code(epc)
if declared_loss is not None:
storage_56m = cylinder_storage_loss_monthly_kwh(
volume_l=volume_l or 0.0,
insulation_type="factory_insulated", # unused in the declared branch
thickness_mm=0.0, # unused in the declared branch
has_cylinder_thermostat=_cylinder_thermostat_present(epc, main),
separately_timed_dhw=_table_2b_note_b_multiplier_applies(epc, main),
declared_loss_kwh_per_day=declared_loss,
)
# (57)m solar adjustment only when solar HW + a resolvable volume.
if not epc.solar_water_heating or volume_l is None:
return storage_56m
vs_l = round(volume_l * _COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION)
factor = (volume_l - vs_l) / volume_l
return tuple(s * factor for s in storage_56m)
if volume_l is None:
return None
insulation_label = _cylinder_storage_loss_insulation_label(

View file

@ -628,10 +628,20 @@ def cylinder_storage_loss_monthly_kwh(
thickness_mm: float,
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
declared_loss_kwh_per_day: Optional[float] = None,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136):
(54) = V × L × VF × TF (Table 2 absence-of-declared-loss branch)
(55) = (54) (no manufacturer's declared loss)
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136).
Two branches, selected by whether the manufacturer's declared loss
factor is lodged:
a) declared loss known (`declared_loss_kwh_per_day` set):
(50) = (48) declared loss (kWh/day) × (49) Table-2b temperature factor
`volume_l` / `insulation_type` / `thickness_mm` are unused.
b) declared loss not known (the default):
(54) = (47) V × (51) L × (52) VF × (53) TF
(55) = (50) or (54)
(56)m = (55) × n_m (n_m = days in month)
Returns 12 monthly values in calendar order Jan..Dec. The cert's
@ -639,15 +649,21 @@ def cylinder_storage_loss_monthly_kwh(
solar storage is present in the vessel callers handling solar
storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`.
"""
L = cylinder_storage_loss_factor_table_2(
insulation_type=insulation_type, thickness_mm=thickness_mm,
)
VF = cylinder_volume_factor_table_2a(volume_l)
TF = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
)
combined_55 = volume_l * L * VF * TF
if declared_loss_kwh_per_day is not None:
# SAP 10.2 §4 (PDF p.136) branch a) — the lodged manufacturer's
# declared loss (kWh/day) replaces the Table 2 V×L×VF computation;
# the Table-2b temperature factor still applies (line (49)→(50)).
combined_55 = declared_loss_kwh_per_day * TF
else:
L = cylinder_storage_loss_factor_table_2(
insulation_type=insulation_type, thickness_mm=thickness_mm,
)
VF = cylinder_volume_factor_table_2a(volume_l)
combined_55 = volume_l * L * VF * TF
return tuple(combined_55 * n for n in _DAYS_IN_MONTH)

View file

@ -676,6 +676,40 @@ def test_water_efficiency_monthly_via_equation_d1_weights_winter_summer_per_mont
assert monthly[0] == pytest.approx(num / denom, abs=1e-6)
def test_cylinder_storage_loss_uses_declared_loss_factor_times_temp_factor() -> None:
# Arrange — SAP 10.2 §4 branch a) (PDF p.136): when the manufacturer's
# declared cylinder loss factor (kWh/day) is lodged, storage loss
# (50) = (48) declared × (49) Table-2b temperature factor — replacing
# the Table 2 V×L×VF computation. Volume / insulation are unused.
from domain.sap10_calculator.worksheet.water_heating import (
cylinder_storage_loss_monthly_kwh,
cylinder_temperature_factor_table_2b,
)
declared = 1.72
tf: float = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=True, separately_timed_dhw=False,
)
# Act
result = cylinder_storage_loss_monthly_kwh(
volume_l=110.0, insulation_type="factory_insulated", thickness_mm=0.0,
has_cylinder_thermostat=True, separately_timed_dhw=False,
declared_loss_kwh_per_day=declared,
)
# Same declared loss with a different volume / insulation must give the
# same result — they are not consulted in the declared branch.
result_other_geometry = cylinder_storage_loss_monthly_kwh(
volume_l=300.0, insulation_type="loose_jacket", thickness_mm=50.0,
has_cylinder_thermostat=True, separately_timed_dhw=False,
declared_loss_kwh_per_day=declared,
)
# Assert — January (31 days) = declared × TF × 31; geometry-invariant.
assert abs(result[0] - declared * tf * 31) <= 1e-9
assert result == result_other_geometry
def test_000474_cert_to_inputs_hot_water_kwh_closes_within_1pct_post_slice_2() -> None:
"""Cert-round-trip conformance: 000474 mid-terrace combi-gas (PDF
HW fuel = 2291.78 kWh/yr). Slice 1 closed Σ(61) via PCDB Table 3b