diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 8d61a2ef..1472963d 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0b71a1a9..9f75847d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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, diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index ba955f63..04073801 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -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: diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 3aa30c1a..d74cd3ca 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 3a21c47b..12628a7b 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index d60daefd..f4a86295 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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( diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index 56eb0cd9..bf0dd475 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -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) diff --git a/tests/domain/sap10_calculator/worksheet/test_water_heating.py b/tests/domain/sap10_calculator/worksheet/test_water_heating.py index 81086c46..5a96deb5 100644 --- a/tests/domain/sap10_calculator/worksheet/test_water_heating.py +++ b/tests/domain/sap10_calculator/worksheet/test_water_heating.py @@ -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