Merge pull request #1436 from Hestia-Homes/fix/cylinder-uninsulated-storage-loss

Uninsulated / unknown-insulation cylinders no longer drop the SAP storage loss
This commit is contained in:
Jun-te Kim 2026-07-02 17:55:02 +01:00 committed by GitHub
commit 99ab1ca88f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 265 additions and 1 deletions

View file

@ -5879,6 +5879,10 @@ _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
# 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
# RdSAP 10 field 7-11 code 0 = NO insulation. SAP 10.2 Table 2 has no
# separate uninsulated row — the loose-jacket formula at t = 0 IS the
# uninsulated factor (L = 0.005 + 1.76 / 12.8 = 0.1425 kWh/L/day).
_CYLINDER_INSULATION_TYPE_NONE: Final[int] = 0
def _cylinder_storage_loss_insulation_label(
@ -7106,9 +7110,38 @@ def _cylinder_storage_loss_override(
insulation_label = _cylinder_storage_loss_insulation_label(
sh.cylinder_insulation_type
)
thickness_mm: Optional[float] = (
float(sh.cylinder_insulation_thickness_mm)
if sh.cylinder_insulation_thickness_mm is not None
else None
)
# SAP 10.2 Table 2: an uninsulated cylinder takes the loose-jacket
# formula at t = 0 — the WORST loss SAP knows, never the zero-loss
# combi default (which over-rates the dwelling). Two lodgement shapes
# describe it: the EPB API's insulation type 0, and the Elmhurst
# site-notes path's type None + explicit 0 mm ("No Insulation" maps to
# no material; the lodged thickness carries the signal — see
# `_ELMHURST_CYLINDER_NO_INSULATION_LABELS` in the mapper).
if _int_or_none(sh.cylinder_insulation_type) == _CYLINDER_INSULATION_TYPE_NONE:
insulation_label, thickness_mm = "loose_jacket", 0.0
if insulation_label is None and thickness_mm == 0.0:
insulation_label = "loose_jacket"
# RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
# accessible": a lodged cylinder with NO insulation data at all takes
# the age-band default — the same row the §10.7 no-water-heating
# default uses. An ABSENT thickness means not accessible (Table 29);
# a lodged 0 mm means uninsulated (handled above). When the age band
# itself is unresolvable, fall through to the legacy None (zero loss)
# rather than raising mid-rebaseline.
if insulation_label is None and thickness_mm is None:
band = (_dwelling_age_band(epc) or "")[:1].upper()
table_29_default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band)
if table_29_default is not None:
default_code, default_mm = table_29_default
insulation_label = _cylinder_storage_loss_insulation_label(default_code)
thickness_mm = float(default_mm)
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(

View file

@ -6365,6 +6365,237 @@ def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_bra
assert got_jan_kwh > 36.9530 # loose jacket loses more than factory
def test_uninsulated_cylinder_computes_storage_loss_via_table2_jacket_t0() -> None:
"""SAP 10.2 Table 2 (PDF p.158): an UNINSULATED cylinder takes the
loose-jacket loss factor at t = 0 L = 0.005 + 1.76 / 12.8 = 0.1425
kWh/L/day, the worst storage loss SAP knows. The EPB API lodges
cylinder_insulation_type=0 = no insulation (1 = factory, 2 = loose
jacket) with no thickness field. Before this fix
`_cylinder_storage_loss_override` returned None for type 0, so the
worksheet kept the zero-storage-loss combi default and the dwelling
OVER-RATED the opposite of what "no insulation" means. Portfolio
814's audit (2026-07-03) found all 26 such properties over-rated
+1..+18 SAP vs lodged (avg +12.7 for type 0); same-lodged WD5 0DP
neighbours (pids 742215/742216, both lodged 54, identical building
parts) split effective 50 vs 69 because one lodged a 12 mm jacket and
the other lodged no insulation.
"""
# Arrange — identical to the loose-jacket storage-loss test but
# cylinder_insulation_type=0 (none) and no lodged thickness.
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=0, # no insulation
cylinder_insulation_thickness_mm=None,
cylinder_thermostat="Y",
),
)
# Expected (56)m Jan from the Table 2 loose-jacket branch at t=0 —
# the SAP uninsulated-cylinder factor (same V / VF / TF as the
# loose-jacket test; only the thickness differs).
loss_factor = cylinder_storage_loss_factor_table_2(
insulation_type="loose_jacket", thickness_mm=0.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 combi default) and equal to
# the loose-jacket branch at t=0; an uninsulated cylinder must lose
# MORE than the 50 mm loose-jacket case, never less.
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
jacket_50mm_jan_kwh = (
160.0
* cylinder_storage_loss_factor_table_2(
insulation_type="loose_jacket", thickness_mm=50.0
)
* vol_factor
* temp_factor
* 31
)
assert got_jan_kwh > jacket_50mm_jan_kwh
def test_elmhurst_no_insulation_cylinder_thickness_zero_takes_uninsulated_loss() -> None:
"""The Elmhurst site-notes mapper lodges a "No Insulation" cylinder as
`cylinder_insulation_type=None` + `cylinder_insulation_thickness_mm=0`
per its own comment "the lodged §15.1 Insulation Thickness (0 mm)
carries the storage-loss signal the cascade's SAP 10.2 Table 2
dispatch needs" (`_ELMHURST_CYLINDER_NO_INSULATION_LABELS`,
datatypes/epc/domain/mapper.py). The cascade must honour that signal:
a lodged 0 mm with no insulation type is the uninsulated cylinder
Table 2 loose-jacket branch at t = 0 not the zero-loss combi
default it previously fell to (the type-None guard returned None
before the thickness was ever read).
"""
# Arrange — same dwelling as the type-0 test; only the lodgement
# shape differs (type None + explicit 0 mm, the Elmhurst path).
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=None, # Elmhurst "No Insulation"
cylinder_insulation_thickness_mm=0,
cylinder_thermostat="Y",
),
)
expected_jan_kwh = (
160.0
* cylinder_storage_loss_factor_table_2(
insulation_type="loose_jacket", thickness_mm=0.0
)
* cylinder_volume_factor_table_2a(160.0)
* cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=True, separately_timed_dhw=True
)
* 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 — identical series to the gov-API type-0 lodgement: the two
# front-ends describe the same uninsulated cylinder.
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
def test_cylinder_with_unknown_insulation_defaults_per_table_29_age_band() -> None:
"""RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
accessible": when a cylinder is lodged but its insulation is unknown
(type absent AND no thickness ADR-0028 records ~308/1000 17.0 certs
omitting `cylinder_insulation_type`), the age-band default applies:
bands A-F 12 mm loose jacket, G/H 25 mm factory, I-M 38 mm
factory. This is the same Table 29 row the §10.7 no-water-heating
default already uses. Previously the cascade returned None (zero
storage loss), silently treating an unknown cylinder as a combi.
Distinct from the uninsulated case: a lodged 0 mm means NO insulation
(t = 0), an absent thickness means NOT ACCESSIBLE (Table 29).
"""
# Arrange — same dwelling, insulation fields both absent; the
# fixture's building part lodges construction_age_band "B" → the
# Table 29 A-F row (12 mm loose jacket).
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()], # age band B
sap_heating=make_sap_heating(
main_heating_details=[hp_main],
water_heating_code=901,
cylinder_size=3, # Medium → 160 L
cylinder_insulation_type=None, # not lodged
cylinder_insulation_thickness_mm=None, # not lodged
cylinder_thermostat="Y",
),
)
expected_jan_kwh = (
160.0
* cylinder_storage_loss_factor_table_2(
insulation_type="loose_jacket", thickness_mm=12.0
)
* cylinder_volume_factor_table_2a(160.0)
* cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=True, separately_timed_dhw=True
)
* 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 — the Table 29 band-B default (12 mm loose jacket), not the
# zero-loss combi default and not the harsher uninsulated t=0.
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
def test_no_water_heating_default_age_a_to_f_uses_12mm_loose_jacket_per_table_29() -> None:
"""RdSAP 10 §10.7 + Table 29 (PDF p.55-56): when no water heating
system is lodged, the default cylinder takes the age-band insulation,