diff --git a/packages/domain/src/domain/ml/tests/_fixtures.py b/packages/domain/src/domain/ml/tests/_fixtures.py index 51cde6dc..df64c43d 100644 --- a/packages/domain/src/domain/ml/tests/_fixtures.py +++ b/packages/domain/src/domain/ml/tests/_fixtures.py @@ -18,6 +18,8 @@ from datatypes.epc.domain.epc_property_data import ( PhotovoltaicArray, PhotovoltaicSupply, PhotovoltaicSupplyNoneOrNoDetails, + PvBatteries, + PvBattery, RenewableHeatIncentive, SapBuildingPart, SapEnergySource, @@ -25,6 +27,7 @@ from datatypes.epc.domain.epc_property_data import ( SapHeating, SapRoomInRoof, SapWindow, + WindTurbineDetails, WindowTransmissionDetails, ) @@ -212,6 +215,13 @@ def make_minimal_sap10_epc( sap_heating: Optional[SapHeating] = None, photovoltaic_arrays: Optional[list[PhotovoltaicArray]] = None, photovoltaic_supply_percent_roof_area: Optional[int] = None, + mains_gas: bool = True, + electricity_smart_meter_present: bool = False, + gas_smart_meter_present: bool = False, + is_dwelling_export_capable: bool = False, + pv_battery_count: int = 0, + pv_battery_capacity_per_unit_kwh: Optional[float] = None, + wind_turbines_count: int = 0, ) -> EpcPropertyData: """Construct a minimal valid SAP10 EpcPropertyData with parametrisable targets.""" return EpcPropertyData( @@ -234,14 +244,14 @@ def make_minimal_sap10_epc( ), sap_windows=list(sap_windows) if sap_windows is not None else [], sap_energy_source=SapEnergySource( - mains_gas=True, + mains_gas=mains_gas, meter_type="Single", - pv_battery_count=0, - wind_turbines_count=0, - gas_smart_meter_present=False, - is_dwelling_export_capable=False, + pv_battery_count=pv_battery_count, + wind_turbines_count=wind_turbines_count, + gas_smart_meter_present=gas_smart_meter_present, + is_dwelling_export_capable=is_dwelling_export_capable, wind_turbines_terrain_type="Suburban", - electricity_smart_meter_present=False, + electricity_smart_meter_present=electricity_smart_meter_present, photovoltaic_arrays=list(photovoltaic_arrays) if photovoltaic_arrays is not None else None, @@ -254,6 +264,20 @@ def make_minimal_sap10_epc( if photovoltaic_supply_percent_roof_area is not None else None ), + pv_batteries=( + PvBatteries( + pv_battery=PvBattery( + battery_capacity=pv_battery_capacity_per_unit_kwh + ) + ) + if pv_battery_capacity_per_unit_kwh is not None + else None + ), + wind_turbine_details=( + WindTurbineDetails(hub_height=5.0, rotor_diameter=2.0) + if wind_turbines_count > 0 + else None + ), ), sap_building_parts=list(sap_building_parts) if sap_building_parts is not None else [], solar_water_heating=solar_water_heating, diff --git a/packages/domain/src/domain/ml/tests/test_transform.py b/packages/domain/src/domain/ml/tests/test_transform.py index c358893b..3853a051 100644 --- a/packages/domain/src/domain/ml/tests/test_transform.py +++ b/packages/domain/src/domain/ml/tests/test_transform.py @@ -942,6 +942,104 @@ def test_to_row_treats_zero_percent_roof_area_as_no_pv() -> None: assert row["pv_percent_roof_area"] is None +_ENERGY_SOURCE_FEATURES_NULLABLE: dict[str, tuple[type, bool, bool]] = { + # name → (dtype, nullable, categorical) + "has_pv_battery": (bool, False, False), + "pv_battery_count": (int, False, False), + "pv_battery_capacity_kwh": (float, True, False), + "has_wind_turbine": (bool, False, False), + "wind_turbine_count": (int, False, False), + "mains_gas": (bool, False, False), + "electricity_smart_meter_present": (bool, False, False), + "gas_smart_meter_present": (bool, False, False), + "is_dwelling_export_capable": (bool, False, False), +} + + +def test_schema_advertises_energy_source_features() -> None: + # Arrange + transform = EpcMlTransform() + + # Act + schema = transform.schema() + + # Assert + for name, (expected_dtype, expected_nullable, expected_categorical) in ( + _ENERGY_SOURCE_FEATURES_NULLABLE.items() + ): + assert name in schema.feature_columns, name + column = schema.feature_columns[name] + assert column.dtype is expected_dtype, name + assert column.nullable is expected_nullable, name + assert column.categorical is expected_categorical, name + + +def test_to_row_extracts_pv_battery_and_capacity() -> None: + # Arrange — two batteries of 5.0 kWh each + epc = make_minimal_sap10_epc( + energy_rating_current=82, + pv_battery_count=2, + pv_battery_capacity_per_unit_kwh=5.0, + ) + transform = EpcMlTransform() + + # Act + row = transform.to_row(epc) + + # Assert + assert row["has_pv_battery"] is True + assert row["pv_battery_count"] == 2 + assert row["pv_battery_capacity_kwh"] == pytest.approx(10.0) + + +def test_to_row_returns_no_pv_battery_when_count_zero() -> None: + # Arrange — no battery + epc = make_minimal_sap10_epc(energy_rating_current=82) + transform = EpcMlTransform() + + # Act + row = transform.to_row(epc) + + # Assert + assert row["has_pv_battery"] is False + assert row["pv_battery_count"] == 0 + assert row["pv_battery_capacity_kwh"] is None + + +def test_to_row_flags_wind_turbine() -> None: + # Arrange + epc = make_minimal_sap10_epc(energy_rating_current=82, wind_turbines_count=1) + transform = EpcMlTransform() + + # Act + row = transform.to_row(epc) + + # Assert + assert row["has_wind_turbine"] is True + assert row["wind_turbine_count"] == 1 + + +def test_to_row_extracts_energy_source_booleans() -> None: + # Arrange — gas + electricity smart meters, export capable + epc = make_minimal_sap10_epc( + energy_rating_current=82, + mains_gas=True, + electricity_smart_meter_present=True, + gas_smart_meter_present=True, + is_dwelling_export_capable=True, + ) + transform = EpcMlTransform() + + # Act + row = transform.to_row(epc) + + # Assert + assert row["mains_gas"] is True + assert row["electricity_smart_meter_present"] is True + assert row["gas_smart_meter_present"] is True + assert row["is_dwelling_export_capable"] is True + + def test_to_row_area_weights_window_u_value_and_solar_transmittance() -> None: # Arrange — two windows with transmission details; one without. sap_windows = [ diff --git a/packages/domain/src/domain/ml/transform.py b/packages/domain/src/domain/ml/transform.py index 9aa9a595..eeef45d4 100644 --- a/packages/domain/src/domain/ml/transform.py +++ b/packages/domain/src/domain/ml/transform.py @@ -380,6 +380,43 @@ _FEATURE_COLUMNS: dict[str, ColumnSpec] = { dtype=int, nullable=True, description="Percent of roof covered by PV — populated only when capacity_source = 'estimated_from_roof_area'.", ), + # PV battery, wind turbine, energy source flags + "has_pv_battery": ColumnSpec( + dtype=bool, nullable=False, + description="True if the property has at least one PV battery.", + ), + "pv_battery_count": ColumnSpec( + dtype=int, nullable=False, description="Number of PV batteries." + ), + "pv_battery_capacity_kwh": ColumnSpec( + dtype=float, nullable=True, + description=( + "Total PV battery capacity (kWh) — pv_battery_count × per-unit capacity " + "from sap_energy_source.pv_batteries. Null when count=0." + ), + ), + "has_wind_turbine": ColumnSpec( + dtype=bool, nullable=False, + description="True if the property has at least one wind turbine.", + ), + "wind_turbine_count": ColumnSpec( + dtype=int, nullable=False, description="Number of wind turbines." + ), + "mains_gas": ColumnSpec( + dtype=bool, nullable=False, + description="Property is connected to mains gas (strong fuel-deduction signal).", + ), + "electricity_smart_meter_present": ColumnSpec( + dtype=bool, nullable=False, + description="Electricity smart meter installed.", + ), + "gas_smart_meter_present": ColumnSpec( + dtype=bool, nullable=False, description="Gas smart meter installed." + ), + "is_dwelling_export_capable": ColumnSpec( + dtype=bool, nullable=False, + description="Dwelling has an export-capable connection (eligible for SEG).", + ), } @@ -458,6 +495,7 @@ class EpcMlTransform: building_part_aggregates = _building_part_aggregates(epc.sap_building_parts) heating_aggregates = _heating_aggregates(epc.sap_heating) pv_aggregates = _pv_aggregates(epc.sap_energy_source) + energy_source_other = _energy_source_other_aggregates(epc.sap_energy_source) return { # Features — geometry "total_floor_area_m2": epc.total_floor_area_m2, @@ -496,6 +534,8 @@ class EpcMlTransform: **heating_aggregates, # Features — PV (capacity source + array aggregates by SAP octant) **pv_aggregates, + # Features — battery, wind turbine, mains gas + smart meter flags + **energy_source_other, # Targets "sap_score": epc.energy_rating_current, "co2_emissions": epc.co2_emissions_current, @@ -569,6 +609,30 @@ def _pv_aggregates(es: SapEnergySource) -> dict[str, Any]: return aggregates +def _energy_source_other_aggregates(es: SapEnergySource) -> dict[str, Any]: + """Pull battery, wind turbine, and household energy source flags. + + Battery capacity multiplies pv_battery_count by the per-unit capacity carried + on pv_batteries.pv_battery; null when no battery is present. + """ + battery_capacity_kwh: Optional[float] = None + if es.pv_battery_count > 0 and es.pv_batteries is not None: + battery_capacity_kwh = ( + es.pv_battery_count * es.pv_batteries.pv_battery.battery_capacity + ) + return { + "has_pv_battery": es.pv_battery_count > 0, + "pv_battery_count": es.pv_battery_count, + "pv_battery_capacity_kwh": battery_capacity_kwh, + "has_wind_turbine": es.wind_turbines_count > 0, + "wind_turbine_count": es.wind_turbines_count, + "mains_gas": es.mains_gas, + "electricity_smart_meter_present": es.electricity_smart_meter_present, + "gas_smart_meter_present": es.gas_smart_meter_present, + "is_dwelling_export_capable": es.is_dwelling_export_capable, + } + + def _heating_aggregates(sap_heating: SapHeating) -> dict[str, Any]: """Aggregate sap_heating into 15 heating-feature columns.