diff --git a/packages/domain/src/domain/ml/demand.py b/packages/domain/src/domain/ml/demand.py index c8b2c1f3..738eca74 100644 --- a/packages/domain/src/domain/ml/demand.py +++ b/packages/domain/src/domain/ml/demand.py @@ -99,11 +99,25 @@ def _default_occupants_sap_j(total_floor_area_m2: float) -> float: def predicted_hot_water_kwh( total_floor_area_m2: Optional[float], seasonal_efficiency_water: float, + *, + cylinder_size: Optional[int] = None, + cylinder_insulation_thickness_mm: Optional[int] = None, + cylinder_insulation_type: Optional[int] = None, + age_band: Optional[str] = None, + has_wwhrs: bool = False, + has_solar_water_heating: bool = False, ) -> float: - """Annual delivered hot-water kWh (SAP10.2 Appendix J simplified). + """Annual delivered hot-water kWh per SAP10.2 Appendix J (slice 17b). - Uses default occupancy from TFA, daily volume 25*N+36 litres, delta-T - 55 - 12 = 43 K, no FGHRS / WWHRS adjustment. + Components (all kWh useful, sum then divided by efficiency for delivered): + useful_demand = 4.18 * Vd * 43 * 365 / 3600 (Vd in litres/day) + distribution_loss = useful_demand * 0.15 + storage_loss = volume * insulation_factor * 365 * 0.6 + primary_loss(age) = 245 (A-J) or 60 (K-M) + wwhrs_credit = useful_demand * 0.12 if has_wwhrs + solar_hw_credit = 250 if has_solar_water_heating + + Defaults follow RdSAP10 §11 / Table 29 for missing cylinder fields. """ if total_floor_area_m2 is None or total_floor_area_m2 <= 0: return 0.0 @@ -111,11 +125,94 @@ def predicted_hot_water_kwh( return 0.0 n = _default_occupants_sap_j(total_floor_area_m2) vd_litres = 25.0 * n + 36.0 - # 4.18 kJ/(kg K) * litres/day * delta-K * days = kJ/yr; /3600 -> kWh/yr. useful_kwh = 4.18 * vd_litres * (55.0 - 12.0) * 365.0 / 3600.0 - # Add ~10% distribution + storage losses (SAP10.2 §L Table 3a typical). - useful_with_losses = useful_kwh * 1.10 - return useful_with_losses / seasonal_efficiency_water + distribution_loss = useful_kwh * 0.15 + storage_loss = _cylinder_storage_loss_kwh( + cylinder_size=cylinder_size, + cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, + cylinder_insulation_type=cylinder_insulation_type, + age_band=age_band, + ) + primary_loss = _primary_circuit_loss_kwh(age_band) + wwhrs_credit = useful_kwh * 0.12 if has_wwhrs else 0.0 + solar_credit = 250.0 if has_solar_water_heating else 0.0 + total_useful = max( + 0.0, + useful_kwh + distribution_loss + storage_loss + primary_loss - wwhrs_credit - solar_credit, + ) + return total_useful / seasonal_efficiency_water + + +# SAP10.2 cylinder volume by RdSAP10 size code (Table 28). +_CYLINDER_VOLUME_L: Final[dict[int, float]] = {1: 110.0, 2: 160.0, 3: 210.0} + +# SAP10.2 Table 2 storage loss factor (kWh / litre / day) by insulation +# thickness in mm. Lower number = better insulation. +_STORAGE_LOSS_FACTOR: Final[dict[int, float]] = { + 0: 0.0203, # uninsulated -> high loss + 12: 0.0152, # 12 mm jacket + 25: 0.0078, # 25 mm foam + 38: 0.0056, + 50: 0.0043, + 80: 0.0025, + 100: 0.0022, + 150: 0.0014, + 200: 0.0011, +} + +# RdSAP10 Table 29 cylinder-insulation default by age band when unknown: +# A-F -> 12 mm jacket, G-H -> 25 mm foam, I-M -> 38 mm foam. +_AGE_TO_DEFAULT_CYLINDER_INS_MM: Final[dict[str, int]] = { + "A": 12, "B": 12, "C": 12, "D": 12, "E": 12, "F": 12, + "G": 25, "H": 25, + "I": 38, "J": 38, "K": 38, "L": 38, "M": 38, +} + + +def _cylinder_storage_loss_kwh( + cylinder_size: Optional[int], + cylinder_insulation_thickness_mm: Optional[int], + cylinder_insulation_type: Optional[int], + age_band: Optional[str], +) -> float: + """Annual cylinder storage loss (kWh useful, before efficiency division). + + Returns 0 when no cylinder is described AND age_band is unknown (assume + instantaneous / combi without storage). Heated-space modifier 0.6. + """ + if cylinder_size is None and age_band is None: + return 0.0 + volume = _CYLINDER_VOLUME_L.get(cylinder_size or 1, 110.0) + thickness = cylinder_insulation_thickness_mm + if thickness is None and age_band is not None: + thickness = _AGE_TO_DEFAULT_CYLINDER_INS_MM.get(age_band.upper()) + if thickness is None: + thickness = 38 + factor = _nearest_storage_loss_factor(thickness) + heated_space_modifier = 0.6 + return volume * factor * 365.0 * heated_space_modifier + + +def _nearest_storage_loss_factor(thickness_mm: int) -> float: + """Pick the SAP10.2 Table 2 row with thickness closest <= the supplied + value. For thicknesses below 12 mm, uses the uninsulated 0-row.""" + candidates = sorted(_STORAGE_LOSS_FACTOR.keys()) + chosen = candidates[0] + for t in candidates: + if t <= thickness_mm: + chosen = t + return _STORAGE_LOSS_FACTOR[chosen] + + +def _primary_circuit_loss_kwh(age_band: Optional[str]) -> float: + """Annual primary-pipework loss (kWh useful) by age band. + + RdSAP10 Table 29: pre-2007 (A-J) no primary insulation -> 245 kWh/yr; + K, L, M -> full insulation -> 60 kWh/yr. Unknown -> 245. + """ + if age_band is None: + return 245.0 + return 60.0 if age_band.upper() in ("K", "L", "M") else 245.0 def predicted_lighting_kwh( diff --git a/packages/domain/src/domain/ml/tests/test_demand.py b/packages/domain/src/domain/ml/tests/test_demand.py index 10df0804..2e6482d0 100644 --- a/packages/domain/src/domain/ml/tests/test_demand.py +++ b/packages/domain/src/domain/ml/tests/test_demand.py @@ -62,6 +62,99 @@ def test_predicted_hot_water_returns_zero_for_unspecified_floor_area() -> None: assert predicted_hot_water_kwh(total_floor_area_m2=None, seasonal_efficiency_water=0.84) == 0.0 +def test_predicted_hot_water_kwh_adds_storage_loss_when_cylinder_described() -> None: + # Arrange — SAP10.2 Appendix J / Table 2: cylinder storage loss adds to + # the delivered DHW load. For a 110L cylinder with 38 mm foam (typical + # post-1992) the loss factor is 0.0056 kWh/L/day; annual loss in heated + # space = 110 * 0.0056 * 365 * 0.6 = 135 kWh useful -> delivered loss + # /efficiency. Same home without cylinder description gets the simple + # formula (no storage term). + + # Act + with_cylinder = predicted_hot_water_kwh( + total_floor_area_m2=80.0, + seasonal_efficiency_water=0.84, + cylinder_size=1, + cylinder_insulation_thickness_mm=38, + cylinder_insulation_type=2, # foam + ) + without_cylinder = predicted_hot_water_kwh( + total_floor_area_m2=80.0, + seasonal_efficiency_water=0.84, + ) + + # Assert + assert with_cylinder > without_cylinder + # storage_loss = 110 * 0.0056 * 365 * 0.6 / 0.84 ≈ 161 kWh delivered. + assert (with_cylinder - without_cylinder) == pytest.approx(161.0, abs=15.0) + + +def test_predicted_hot_water_kwh_lower_storage_loss_for_thicker_insulation() -> None: + # Arrange — same cylinder size, 12mm jacket vs 100mm foam. + + # Act + jacket = predicted_hot_water_kwh( + total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, + cylinder_size=1, cylinder_insulation_thickness_mm=12, cylinder_insulation_type=1, + ) + foam_100mm = predicted_hot_water_kwh( + total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, + cylinder_size=1, cylinder_insulation_thickness_mm=100, cylinder_insulation_type=2, + ) + + # Assert + assert jacket > foam_100mm + + +def test_predicted_hot_water_kwh_drops_with_wwhrs() -> None: + # Arrange — WWHRS recovers ~15% of bath energy. + + # Act + no_wwhrs = predicted_hot_water_kwh( + total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, has_wwhrs=False, + ) + with_wwhrs = predicted_hot_water_kwh( + total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, has_wwhrs=True, + ) + + # Assert + assert with_wwhrs < no_wwhrs + + +def test_predicted_hot_water_kwh_drops_with_solar_water_heating() -> None: + # Arrange — solar HW saves ~250 kWh/yr (SAP10.2 Appendix G simplified). + + # Act + no_solar = predicted_hot_water_kwh( + total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, has_solar_water_heating=False, + ) + with_solar = predicted_hot_water_kwh( + total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, has_solar_water_heating=True, + ) + + # Assert + assert with_solar < no_solar + + +def test_predicted_hot_water_kwh_uses_age_band_default_when_insulation_unspecified() -> None: + # Arrange — RdSAP10 Table 29: A-F -> 12mm jacket; G-H -> 25mm foam; I-M -> 38mm foam. + # Age G cylinder with no explicit insulation should default to 25mm foam, + # giving a lower loss than age A (12mm jacket). + + # Act + age_a = predicted_hot_water_kwh( + total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, + cylinder_size=1, age_band="A", + ) + age_g = predicted_hot_water_kwh( + total_floor_area_m2=80.0, seasonal_efficiency_water=0.84, + cylinder_size=1, age_band="G", + ) + + # Assert + assert age_g < age_a + + def test_predicted_hot_water_typical_uk_home_falls_in_sensible_range() -> None: # Arrange — 80 m^2 home, gas-combi efficiency. diff --git a/packages/domain/src/domain/ml/tests/test_transform.py b/packages/domain/src/domain/ml/tests/test_transform.py index acd635e7..20734af8 100644 --- a/packages/domain/src/domain/ml/tests/test_transform.py +++ b/packages/domain/src/domain/ml/tests/test_transform.py @@ -36,7 +36,7 @@ def test_transform_advertises_version_and_target_columns() -> None: # Assert assert isinstance(schema, TransformSchema) - assert schema.transform_version == "2.1.0" + assert schema.transform_version == "2.2.0" assert schema.transform_version == EpcMlTransform.VERSION assert set(schema.target_columns.keys()) == set(_EXPECTED_TARGET_DTYPES.keys()) for target_name, expected_dtype in _EXPECTED_TARGET_DTYPES.items(): diff --git a/packages/domain/src/domain/ml/transform.py b/packages/domain/src/domain/ml/transform.py index ec08a575..a3a72821 100644 --- a/packages/domain/src/domain/ml/transform.py +++ b/packages/domain/src/domain/ml/transform.py @@ -901,7 +901,7 @@ class EpcMlTransform: Version 0.1.0 — schema contract only; feature columns added in subsequent slices. """ - VERSION: str = "2.1.0" + VERSION: str = "2.2.0" def schema(self) -> TransformSchema: """The cross-repo ML data contract. @@ -969,9 +969,19 @@ class EpcMlTransform: region_code=epc.region_code, seasonal_efficiency_main=space_eff, ) + cylinder_size_val = heating_aggregates.get("cylinder_size") + cylinder_ins_thk = heating_aggregates.get("cylinder_insulation_thickness_mm") + cylinder_ins_type = heating_aggregates.get("cylinder_insulation_type") + main_age = building_part_aggregates.get("main_dwelling_construction_age_band") pred_hw_kwh = predicted_hot_water_kwh( total_floor_area_m2=epc.total_floor_area_m2, seasonal_efficiency_water=water_eff, + cylinder_size=cylinder_size_val if isinstance(cylinder_size_val, int) else None, + cylinder_insulation_thickness_mm=cylinder_ins_thk if isinstance(cylinder_ins_thk, int) else None, + cylinder_insulation_type=cylinder_ins_type if isinstance(cylinder_ins_type, int) else None, + age_band=main_age if isinstance(main_age, str) else None, + has_wwhrs=bool(epc.sap_heating.number_baths_wwhrs and epc.sap_heating.number_baths_wwhrs > 0), + has_solar_water_heating=epc.solar_water_heating, ) pred_light_kwh = predicted_lighting_kwh( total_floor_area_m2=epc.total_floor_area_m2,