mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
69fdbf9f1d
commit
2e351be957
2 changed files with 98 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue