diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 9e189860..5ab3334d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index e58f9713..899a99f5 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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: