slice 11b: PV battery, wind turbine, energy source flags

Nine more energy-source features land: has_pv_battery,
pv_battery_count, pv_battery_capacity_kwh (count × per-unit
capacity from pv_batteries.pv_battery, nullable when count=0),
has_wind_turbine, wind_turbine_count, mains_gas (the dominant
fuel-deduction signal), and the three smart-meter / export
booleans (electricity_smart_meter_present, gas_smart_meter_present,
is_dwelling_export_capable).

Closes the PV/solar feature group started in slice 11a.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-16 16:07:17 +00:00
parent 706d1b5b66
commit 559a2128b9
3 changed files with 192 additions and 6 deletions

View file

@ -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,

View file

@ -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 = [

View file

@ -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.