From 6dd03a930c10ffdcec5296a417020c4902ea7da7 Mon Sep 17 00:00:00 2001 From: VladIftime <49650168+VladIftime@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:24:38 +0100 Subject: [PATCH] Initial implementation of the No Control type (#17) * Initial implementation Signed-off-by: Vlad Iftime * No control type example Signed-off-by: Vlad Iftime * NoControll test for the Flask Scheduler as well Signed-off-by: Vlad Iftime * small fixes Signed-off-by: Vlad Iftime * small fixes Signed-off-by: Vlad Iftime --------- Signed-off-by: Vlad Iftime --- .../profile_steering/common/device_plan.py | 5 +- .../device_planner/nocontrol/__init__.py | 19 + .../nocontrol/conversion_utils.py | 150 +++++++ .../nocontrol/proposal_without_improvement.py | 37 ++ .../nocontrol/s2_nocontrol_device_planner.py | 126 ++++++ .../nocontrol/s2_nocontrol_device_state.py | 41 ++ .../nocontrol/s2_nocontrol_plan.py | 9 + .../examples/example_schedule_frbc.py | 66 +-- .../examples/example_schedule_nocontrol.py | 384 ++++++++++++++++++ .../profile_steering/planning_service_impl.py | 15 + flexmeasures_s2/scheduler/scheduler_flask.py | 4 + flexmeasures_s2/scheduler/schedulers.py | 15 + flexmeasures_s2/scheduler/tests/test_frbc.py | 24 -- 13 files changed, 836 insertions(+), 59 deletions(-) create mode 100644 flexmeasures_s2/profile_steering/device_planner/nocontrol/__init__.py create mode 100644 flexmeasures_s2/profile_steering/device_planner/nocontrol/conversion_utils.py create mode 100644 flexmeasures_s2/profile_steering/device_planner/nocontrol/proposal_without_improvement.py create mode 100644 flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_device_planner.py create mode 100644 flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_device_state.py create mode 100644 flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_plan.py create mode 100644 flexmeasures_s2/profile_steering/examples/example_schedule_nocontrol.py delete mode 100644 flexmeasures_s2/scheduler/tests/test_frbc.py diff --git a/flexmeasures_s2/profile_steering/common/device_plan.py b/flexmeasures_s2/profile_steering/common/device_plan.py index 8474b81..7e9c1ba 100644 --- a/flexmeasures_s2/profile_steering/common/device_plan.py +++ b/flexmeasures_s2/profile_steering/common/device_plan.py @@ -1,3 +1,4 @@ +from typing import Optional from flexmeasures_s2.profile_steering.common.pydantic_base import FlexMeasuresBaseModel from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile @@ -13,5 +14,5 @@ class DevicePlan(FlexMeasuresBaseModel): device_name: str connection_id: str energy_profile: JouleProfile - fill_level_profile: SoCProfile - instruction_profile: S2FrbcInstructionProfile + fill_level_profile: Optional[SoCProfile] = None + instruction_profile: Optional[S2FrbcInstructionProfile] = None diff --git a/flexmeasures_s2/profile_steering/device_planner/nocontrol/__init__.py b/flexmeasures_s2/profile_steering/device_planner/nocontrol/__init__.py new file mode 100644 index 0000000..42bd487 --- /dev/null +++ b/flexmeasures_s2/profile_steering/device_planner/nocontrol/__init__.py @@ -0,0 +1,19 @@ +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_device_state import ( + S2NoControlDeviceState, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_device_planner import ( + S2NoControlDevicePlanner, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_plan import ( + S2NoControlPlan, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.proposal_without_improvement import ( + ProposalWithoutImprovement, +) + +__all__ = [ + "S2NoControlDeviceState", + "S2NoControlDevicePlanner", + "S2NoControlPlan", + "ProposalWithoutImprovement", +] diff --git a/flexmeasures_s2/profile_steering/device_planner/nocontrol/conversion_utils.py b/flexmeasures_s2/profile_steering/device_planner/nocontrol/conversion_utils.py new file mode 100644 index 0000000..ed1978a --- /dev/null +++ b/flexmeasures_s2/profile_steering/device_planner/nocontrol/conversion_utils.py @@ -0,0 +1,150 @@ +from datetime import datetime +from typing import List, Optional +from s2python.common import PowerForecast, PowerForecastElement, CommodityQuantity +from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile +from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata + + +def get_expected_electrical_power(element: PowerForecastElement) -> float: + """ + Get the expected electrical power from a PowerForecastElement. + Sums up power values for electrical commodity quantities. + + Args: + element: PowerForecastElement to extract power from + + Returns: + Expected power in watts + """ + expected_power = 0.0 + for power_value in element.power_values: + commodity = power_value.commodity_quantity + if commodity in ( + CommodityQuantity.ELECTRIC_POWER_L1, + CommodityQuantity.ELECTRIC_POWER_L2, + CommodityQuantity.ELECTRIC_POWER_L3, + CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC, + ): + expected_power += power_value.value_expected + return expected_power + + +def find_starting_index_for(forecast: PowerForecast, profile_start: datetime) -> int: + """ + Find the starting index in the forecast that corresponds to the profile_start time. + + Args: + forecast: PowerForecast to search + profile_start: Start time of the profile + + Returns: + Index of the forecast element that starts at or before profile_start + """ + current_time = forecast.start_time + for i, element in enumerate(forecast.elements): + if current_time >= profile_start: + return i + current_time += element.duration.to_timedelta() + return len(forecast.elements) + + +def convert_power_forecast( + forecast: PowerForecast, profile_metadata: ProfileMetadata +) -> JouleProfile: + """ + Convert a PowerForecast to a JouleProfile. + + Args: + forecast: PowerForecast to convert + profile_metadata: Metadata describing the target profile + + Returns: + JouleProfile with energy values in joules + """ + forecast_start = forecast.start_time + current_forecast_element_index: int + current_profile_step_index: int + + if forecast_start > profile_metadata.profile_start: + current_forecast_element_index = 0 + current_profile_step_index = profile_metadata.get_starting_step_nr( + forecast_start + ) + else: + current_forecast_element_index = find_starting_index_for( + forecast, profile_metadata.profile_start + ) + current_profile_step_index = 0 + + joule_profile_elements: List[Optional[int]] = [0] * profile_metadata.nr_of_timesteps + + if ( + 0 <= current_profile_step_index < profile_metadata.nr_of_timesteps + and 0 <= current_forecast_element_index < len(forecast.elements) + ): + current_profile_step_start = profile_metadata.get_profile_start_at_timestep( + current_profile_step_index + ) + current_forecast_element = forecast.elements[current_forecast_element_index] + current_forecast_element_start = forecast_start + for i in range(current_forecast_element_index): + current_forecast_element_start += forecast.elements[ + i + ].duration.to_timedelta() + + current_forecast_element_end = ( + current_forecast_element_start + + current_forecast_element.duration.to_timedelta() + ) + + while ( + current_forecast_element_index < len(forecast.elements) + and current_profile_step_index < profile_metadata.nr_of_timesteps + ): + current_forecast_element = forecast.elements[current_forecast_element_index] + current_profile_step_end = ( + current_profile_step_start + profile_metadata.timestep_duration + ) + + start_of_fit = max( + current_profile_step_start, current_forecast_element_start + ) + end_of_fit: datetime + profile_fit_index = current_profile_step_index + + if current_forecast_element_end >= current_profile_step_end: + current_profile_step_index += 1 + if current_profile_step_index < profile_metadata.nr_of_timesteps: + current_profile_step_start = current_profile_step_end + + if current_profile_step_end >= current_forecast_element_end: + end_of_fit = current_forecast_element_end + current_forecast_element_index += 1 + if current_forecast_element_index < len(forecast.elements): + current_forecast_element_start = current_forecast_element_end + next_element = forecast.elements[current_forecast_element_index] + current_forecast_element_end = ( + current_forecast_element_start + + next_element.duration.to_timedelta() + ) + else: + end_of_fit = current_profile_step_end + current_forecast_element_start = current_profile_step_end + + overlap_seconds = (end_of_fit - start_of_fit).total_seconds() + if overlap_seconds > 0: + expected_power = get_expected_electrical_power(current_forecast_element) + joule_in_fit = int(round(expected_power * overlap_seconds)) + current_value = joule_profile_elements[profile_fit_index] + if current_value is None: + joule_profile_elements[profile_fit_index] = joule_in_fit + else: + joule_profile_elements[profile_fit_index] = ( + current_value + joule_in_fit + ) + + return JouleProfile( + profile_start=profile_metadata.profile_start, + timestep_duration=profile_metadata.timestep_duration, + elements=joule_profile_elements, + ) diff --git a/flexmeasures_s2/profile_steering/device_planner/nocontrol/proposal_without_improvement.py b/flexmeasures_s2/profile_steering/device_planner/nocontrol/proposal_without_improvement.py new file mode 100644 index 0000000..19e0074 --- /dev/null +++ b/flexmeasures_s2/profile_steering/device_planner/nocontrol/proposal_without_improvement.py @@ -0,0 +1,37 @@ +from flexmeasures_s2.profile_steering.common.proposal import Proposal +from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile +from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile +from flexmeasures_s2.profile_steering.device_planner.device_planner_abstract import ( + DevicePlanner, +) + + +class ProposalWithoutImprovement(Proposal): + def __init__(self, plan: JouleProfile, origin: DevicePlanner): + null_target = TargetProfile( + plan.metadata.profile_start, + plan.metadata.timestep_duration, + [TargetProfile.NULL_ELEMENT] * plan.metadata.nr_of_timesteps, + ) + null_joule_profile = JouleProfile( + plan.metadata.profile_start, + plan.metadata.timestep_duration, + [0] * plan.metadata.nr_of_timesteps, + ) + super().__init__( + global_diff_target=null_target, + diff_to_congestion_max=null_joule_profile, + diff_to_congestion_min=null_joule_profile, + proposed_plan=plan, + old_plan=plan, + origin=origin, + ) + + def get_global_improvement_value(self) -> float: + return 0.0 + + def get_congestion_improvement_value(self) -> float: + return 0.0 + + def get_cost_improvement_value(self) -> float: + return 0.0 diff --git a/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_device_planner.py b/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_device_planner.py new file mode 100644 index 0000000..eac803a --- /dev/null +++ b/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_device_planner.py @@ -0,0 +1,126 @@ +from datetime import datetime +from typing import Optional +from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile +from flexmeasures_s2.profile_steering.common.proposal import Proposal +from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile +from flexmeasures_s2.profile_steering.common.device_plan import DevicePlan +from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata +from flexmeasures_s2.profile_steering.device_planner.device_planner_abstract import ( + DevicePlanner, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_device_state import ( + S2NoControlDeviceState, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.conversion_utils import ( + convert_power_forecast, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.proposal_without_improvement import ( + ProposalWithoutImprovement, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_plan import ( + S2NoControlPlan, +) + + +class S2NoControlDevicePlanner(DevicePlanner): + def __init__( + self, + device_state: S2NoControlDeviceState, + profile_metadata: ProfileMetadata, + congestion_point_id: str, + ): + self.device_state = device_state + self.profile_metadata = profile_metadata + self._congestion_point_id = congestion_point_id + + if device_state.get_power_forecast() is None: + energy_profile = JouleProfile( + profile_start=profile_metadata.profile_start, + timestep_duration=profile_metadata.timestep_duration, + elements=[0] * profile_metadata.nr_of_timesteps, + ) + else: + energy_profile = convert_power_forecast( + device_state.get_power_forecast(), profile_metadata + ) + self.profile = S2NoControlPlan(energy=energy_profile) + self.accepted_plan: Optional[S2NoControlPlan] = None + + @property + def priority_class(self) -> int: + return self.device_state.priority_class + + @property + def device_id(self) -> str: + return self.device_state.device_id + + @property + def device_name(self) -> str: + return self.device_state.device_name + + @property + def connection_id(self) -> str: + return self.device_state.connection_id + + @property + def congestion_point_id(self) -> str: + return self._congestion_point_id + + def create_initial_planning( + self, plan_due_by_date: datetime, ids: Optional[dict] = None + ) -> S2NoControlPlan: + self.accepted_plan = self.profile + return self.profile + + def create_improved_planning( + self, + difference_profile: TargetProfile, + diff_to_max_value: JouleProfile, + diff_to_min_value: JouleProfile, + plan_due_by_date: datetime, + ) -> Optional[Proposal]: + if self.accepted_plan is None: + raise ValueError("No accepted plan found") + return ProposalWithoutImprovement(self.profile.energy, self) + + def accept_proposal(self, accepted_proposal: Proposal) -> None: + if accepted_proposal.origin != self: + raise ValueError( + f"Planner for '{self.device_id}' received a proposal that he did not send." + ) + + if ( + accepted_proposal.proposed_plan.elements + != accepted_proposal.old_plan.elements + or accepted_proposal.old_plan.elements != self.profile.energy.elements + ): + raise ValueError( + f"Planner for '{self.device_id}' received a proposal that he did not send." + ) + self.accepted_plan = self.profile + + def current_profile(self) -> JouleProfile: + if self.accepted_plan is None: + raise ValueError("No accepted plan found") + return self.accepted_plan.energy + + def get_device_plan(self) -> Optional[DevicePlan]: + if self.accepted_plan is None: + return None + return DevicePlan( + device_id=self.device_id, + device_name=self.device_name, + connection_id=self.connection_id, + energy_profile=self.accepted_plan.energy, + fill_level_profile=None, + instruction_profile=None, + ) + + def get_latest_plan(self) -> Optional[S2NoControlPlan]: + return self.profile + + def set_accepted_plan(self, plan: S2NoControlPlan) -> None: + if not isinstance(plan, S2NoControlPlan): + raise TypeError(f"Expected S2NoControlPlan, but got {type(plan)}") + self.accepted_plan = plan + self.profile = plan diff --git a/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_device_state.py b/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_device_state.py new file mode 100644 index 0000000..a81b14f --- /dev/null +++ b/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_device_state.py @@ -0,0 +1,41 @@ +from datetime import datetime +from typing import Optional +from s2python.common import PowerForecast +from s2python.generated.gen_s2 import PowerValue + + +class S2NoControlDeviceState: + def __init__( + self, + device_id: str, + device_name: str, + connection_id: str, + priority_class: int, + timestamp: datetime, + energy_in_current_timestep: PowerValue, + is_online: bool, + power_forecast: Optional[PowerForecast], + ): + self.device_id = device_id + self.device_name = device_name + self.connection_id = connection_id + self.priority_class = priority_class + self.timestamp = timestamp + self.energy_in_current_timestep = energy_in_current_timestep + self.is_online = is_online + self.power_forecast = power_forecast + + def get_device_id(self) -> str: + return self.device_id + + def get_device_name(self) -> str: + return self.device_name + + def get_connection_id(self) -> str: + return self.connection_id + + def get_priority_class(self) -> int: + return self.priority_class + + def get_power_forecast(self) -> Optional[PowerForecast]: + return self.power_forecast diff --git a/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_plan.py b/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_plan.py new file mode 100644 index 0000000..fc66854 --- /dev/null +++ b/flexmeasures_s2/profile_steering/device_planner/nocontrol/s2_nocontrol_plan.py @@ -0,0 +1,9 @@ +from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile + + +class S2NoControlPlan: + def __init__(self, energy: JouleProfile): + self.energy = energy + + def get_energy(self) -> JouleProfile: + return self.energy diff --git a/flexmeasures_s2/profile_steering/examples/example_schedule_frbc.py b/flexmeasures_s2/profile_steering/examples/example_schedule_frbc.py index d5071ae..12538a1 100644 --- a/flexmeasures_s2/profile_steering/examples/example_schedule_frbc.py +++ b/flexmeasures_s2/profile_steering/examples/example_schedule_frbc.py @@ -49,7 +49,7 @@ B = 200 # number of buckets -> todo: vectorize computation of next state from current state S = 30 # number of stratification layers PLANNING_WINDOW = pd.Timedelta("PT24H") -PLANNING_RESOLUTION = pd.Timedelta("PT15M") +PLANNING_RESOLUTION = pd.Timedelta("PT5M") T = PLANNING_WINDOW // PLANNING_RESOLUTION # number of time steps TIMESTEP_DURATION = PLANNING_RESOLUTION / pd.Timedelta( @@ -824,37 +824,37 @@ def test_planning_service_impl_with_ev_device(): print(f"Total number of instructions: {len(all_instructions)}") - # Find instructions with different operation modes than previous instruction - mode_changes = [] - previous_mode = None - - for i, instruction in enumerate(all_instructions): - if hasattr(instruction, "operation_mode"): - current_mode = instruction.operation_mode - if previous_mode is not None and current_mode != previous_mode: - mode_changes.append( - { - "index": i, - "instruction": instruction, - "previous_mode": previous_mode, - "current_mode": current_mode, - "execution_time": getattr(instruction, "execution_time", "N/A"), - "operation_mode_factor": getattr( - instruction, "operation_mode_factor", "N/A" - ), - } - ) - previous_mode = current_mode - - print(f"Found {len(mode_changes)} operation mode changes:") - for change in mode_changes: - print( - f" Instruction {change['index']}: {change['previous_mode']} -> {change['current_mode']}" - ) - print(f" Execution time: {change['execution_time']}") - print(f" Operation mode factor: {change['operation_mode_factor']}") - print(f" Instruction ID: {getattr(change['instruction'], 'id', 'N/A')}") - print() + # Commented out: Find instructions with different operation modes than previous instruction + # mode_changes = [] + # previous_mode = None + + # for i, instruction in enumerate(all_instructions): + # if hasattr(instruction, "operation_mode"): + # current_mode = instruction.operation_mode + # if previous_mode is not None and current_mode != previous_mode: + # mode_changes.append( + # { + # "index": i, + # "instruction": instruction, + # "previous_mode": previous_mode, + # "current_mode": current_mode, + # "execution_time": getattr(instruction, "execution_time", "N/A"), + # "operation_mode_factor": getattr( + # instruction, "operation_mode_factor", "N/A" + # ), + # } + # ) + # previous_mode = current_mode + + # print(f"Found {len(mode_changes)} operation mode changes:") + # for change in mode_changes: + # print( + # f" Instruction {change['index']}: {change['previous_mode']} -> {change['current_mode']}" + # ) + # print(f" Execution time: {change['execution_time']}") + # print(f" Operation mode factor: {change['operation_mode_factor']}") + # print(f" Instruction ID: {getattr(change['instruction'], 'id', 'N/A')}") + # print() # Basic assertion - the energy profile should have the expected number of elements assert len(energy_profile.elements) == target_metadata.nr_of_timesteps @@ -982,7 +982,7 @@ def test_planning_service_impl_with_cost_target(): # Main function if __name__ == "__main__": # Test energy targeting (original functionality) - # test_planning_service_impl_with_ev_device() + test_planning_service_impl_with_ev_device() print("\n" + "=" * 60 + "\n") diff --git a/flexmeasures_s2/profile_steering/examples/example_schedule_nocontrol.py b/flexmeasures_s2/profile_steering/examples/example_schedule_nocontrol.py new file mode 100644 index 0000000..00fc481 --- /dev/null +++ b/flexmeasures_s2/profile_steering/examples/example_schedule_nocontrol.py @@ -0,0 +1,384 @@ +from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile +from datetime import datetime, timedelta, timezone + +# import time +import pandas as pd +import os +import uuid +from s2python.common import PowerForecast, PowerValue, CommodityQuantity +from s2python.common import PowerForecastElement, PowerForecastValue +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_device_state import ( + S2NoControlDeviceState, +) +from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata + +from flexmeasures_s2.scheduler.schedulers import ( + PlanningServiceImpl, + PlanningServiceConfig, + ClusterState, + ClusterTarget, +) +from flexmeasures_s2.scheduler.scheduler_flask import S2FlaskScheduler +from flask import Flask +from unittest.mock import Mock, create_autospec +from flexmeasures import Sensor +import matplotlib.dates as mdates +import matplotlib.pyplot as plt + +D = 3 +PLANNING_WINDOW = pd.Timedelta("PT24H") +PLANNING_RESOLUTION = pd.Timedelta("PT5M") + +T = PLANNING_WINDOW // PLANNING_RESOLUTION +TIMESTEP_DURATION = PLANNING_RESOLUTION / pd.Timedelta("PT1S") + + +def create_simple_power_forecast( + start_time: datetime, nr_of_timesteps: int, timestep_duration: float +) -> PowerForecast: + """ + Create a simple power forecast with varying consumption. + This simulates a device with fixed consumption pattern (e.g., a fridge). + + Args: + start_time: When the forecast starts + nr_of_timesteps: Number of timesteps + timestep_duration: Duration of each timestep in seconds + + Returns: + A PowerForecast with a realistic consumption pattern + """ + elements = [] + + for i in range(nr_of_timesteps): + if i % 12 == 0: + power_watts = 150.0 + elif i % 12 < 3: + power_watts = 150.0 + else: + power_watts = 0.0 + + power_value = PowerForecastValue( + value_expected=power_watts, + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + ) + + element = PowerForecastElement( + duration=int(timestep_duration), power_values=[power_value] + ) + elements.append(element) + + return PowerForecast( + message_id=str(uuid.uuid4()), + start_time=start_time, + elements=elements, + ) + + +def create_nocontrol_device_state( + device_id: str, start_time: datetime, nr_of_timesteps: int, timestep_duration: float +) -> S2NoControlDeviceState: + """ + Create a nocontrol device state (e.g., for a non-controllable load like a fridge). + + Args: + device_id: ID of the device + start_time: When the planning starts + nr_of_timesteps: Number of timesteps + timestep_duration: Duration of each timestep in seconds + + Returns: + An S2NoControlDeviceState with a power forecast + """ + power_forecast = create_simple_power_forecast( + start_time, nr_of_timesteps, timestep_duration + ) + + device_state = S2NoControlDeviceState( + device_id=device_id, + device_name=device_id, + connection_id=device_id + "_connection", + priority_class=1, + timestamp=start_time, + energy_in_current_timestep=PowerValue( + value=0, commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1 + ), + is_online=True, + power_forecast=power_forecast, + ) + return device_state + + +def plot_planning_results( + timestep_duration, + nr_of_timesteps, + predicted_energy_elements, + target_energy_elements, +): + """ + Plots the energy comparison. + + :param timestep_duration: Duration of each timestep. + :param nr_of_timesteps: Number of timesteps. + :param predicted_energy_elements: List of predicted energy values. + :param target_energy_elements: List of target energy values. + """ + timestep_start_times = [ + datetime(1970, 1, 1, tzinfo=timezone.utc) + + timedelta(seconds=i * timestep_duration.total_seconds()) + for i in range(nr_of_timesteps) + ] + + fig, ax1 = plt.subplots(1, 1, figsize=(12, 8)) + + ax1.plot( + timestep_start_times, + predicted_energy_elements, + label="Predicted Energy (NoControl Devices)", + color="green", + ) + ax1.plot( + timestep_start_times, + target_energy_elements, + label="Target Energy", + color="red", + linestyle="dotted", + linewidth=2, + ) + + ax1.set_ylabel("Energy (Joules)") + ax1.set_title("Predicted vs Target Energy - NoControl Devices") + ax1.legend(loc="best") + ax1.grid(True) + + ax1.xaxis.set_major_locator(mdates.MinuteLocator(interval=30)) + ax1.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) + fig.autofmt_xdate() + + plt.tight_layout() + os.makedirs("plots", exist_ok=True) + plt.savefig(f"plots/nocontrol_plot_D={D}_T={T}.png") + # print(f"Plot saved to plots/nocontrol_plot_D={D}_T={T}.png") + + +def get_target_profile_elements(number_of_elements: int): + """Create target profile elements.""" + target_elements = [] + target_elements.extend([0] * 38) + target_elements.extend([8400000] * 62) + target_elements.extend([0] * 45) + target_elements.extend([8400000] * 28) + target_elements.extend([176000000] * 115) + return target_elements[:number_of_elements] + + +def test_planning_service_impl_with_nocontrol_devices(): + """Test the PlanningServiceImpl with nocontrol devices.""" + print("Testing PlanningServiceImpl with nocontrol devices") + print(f"Number of devices: {D}") + print(f"Number of timesteps: {T}") + + target_metadata = ProfileMetadata( + profile_start=datetime(1970, 1, 1, tzinfo=timezone.utc), + timestep_duration=timedelta(seconds=TIMESTEP_DURATION), + nr_of_timesteps=T, + ) + plan_due_by_date = target_metadata.profile_start + timedelta(seconds=10) + target_profile_elements = get_target_profile_elements(T) + + global_target_profile = TargetProfile( + profile_start=target_metadata.profile_start, + timestep_duration=target_metadata.timestep_duration, + elements=target_profile_elements, + ) + + device_states = [ + create_nocontrol_device_state( + f"nocontrol_device_{i + 1}", + target_metadata.profile_start, + T, + TIMESTEP_DURATION, + ) + for i in range(D) + ] + + device_states_dict = { + device_state.device_id: device_state for device_state in device_states + } + + congestion_points_by_connection_id = { + device_state.connection_id: "" for device_state in device_states + } + + cluster_state = ClusterState( + datetime.now(), device_states_dict, congestion_points_by_connection_id + ) + + congestion_point_targets = {} + cluster_target = ClusterTarget( + datetime.now(), + None, + None, + global_target_profile=global_target_profile, + congestion_point_targets=congestion_point_targets, + ) + + config = PlanningServiceConfig( + energy_improvement_criterion=10.0, + cost_improvement_criterion=1.0, + congestion_retry_iterations=10, + multithreaded=False, + ) + + print("Generating plan with PlanningServiceImpl!") + + service = PlanningServiceImpl(config) + + cluster_plan = service.plan( + state=cluster_state, + target=cluster_target, + planning_window=TIMESTEP_DURATION * T, + reason="Testing NoControl planning with PlanningServiceImpl", + plan_due_by_date=plan_due_by_date, + optimize_for_target=True, + max_priority_class=1, + ) + + assert cluster_plan is not None + print("Got cluster plan from PlanningServiceImpl") + + if cluster_plan is None: + return + + device_plans = cluster_plan.get_plan_data().get_device_plans() + energy_profile = cluster_plan.get_joule_profile() + + plot_planning_results( + timestep_duration=timedelta(seconds=TIMESTEP_DURATION), + nr_of_timesteps=T, + predicted_energy_elements=energy_profile.elements, + target_energy_elements=target_profile_elements, + ) + + device_plans = [plan for plan in device_plans if plan is not None] + + assert len(device_plans) > 0 + print(f"Got {len(device_plans)} device plans from PlanningServiceImpl") + + for device_plan in device_plans: + if device_plan: + total_energy = sum( + e for e in device_plan.energy_profile.elements if e is not None + ) + print( + f"Device {device_plan.device_id}: Total energy: {total_energy} Joules" + ) + + assert len(energy_profile.elements) == target_metadata.nr_of_timesteps + print("PlanningServiceImpl test completed successfully!") + + +def test_flask_scheduler_with_nocontrol_devices(): + """Test the S2FlaskScheduler with nocontrol devices.""" + print("\nTesting S2FlaskScheduler with nocontrol devices") + print(f"Number of devices: {D}") + print(f"Number of timesteps: {T}") + + # Create a minimal Flask app for testing + app = Flask(__name__) + app.config["FLEXMEASURES_S2_TARGET_MODE"] = "energy" + app.config["TESTING"] = True + + target_metadata = ProfileMetadata( + profile_start=datetime(1970, 1, 1, tzinfo=timezone.utc), + timestep_duration=timedelta(seconds=TIMESTEP_DURATION), + nr_of_timesteps=T, + ) + + # Create device states + device_states = [ + create_nocontrol_device_state( + f"nocontrol_device_{i + 1}", + target_metadata.profile_start, + T, + TIMESTEP_DURATION, + ) + for i in range(D) + ] + + device_states_dict = { + device_state.device_id: device_state for device_state in device_states + } + + with app.app_context(): + # Create a mock sensor that passes isinstance checks + # Must be created inside app context because Sensor is a SQLAlchemy model + mock_sensor = create_autospec(Sensor, instance=True) + mock_sensor.id = 1 + + # Create scheduler + scheduler = S2FlaskScheduler( + asset_or_sensor=mock_sensor, + start=target_metadata.profile_start, + end=target_metadata.profile_start + + timedelta(seconds=TIMESTEP_DURATION * T), + resolution=timedelta(seconds=TIMESTEP_DURATION), + belief_time=target_metadata.profile_start, + flex_model={}, + flex_context={"target-profile": {}}, + ) + + # Override the create_device_states_from_frbc_data method to return nocontrol devices + def create_nocontrol_device_states(): + return device_states_dict + + scheduler.create_device_states_from_frbc_data = create_nocontrol_device_states + + # Set frbc_device_data to a mock object to avoid early return + scheduler.frbc_device_data = Mock() + + # Override _get_target_elements to use our target profile + target_profile_elements = get_target_profile_elements(T) + + def get_target_elements(num_elements: int): + return target_profile_elements[:num_elements] + + scheduler._get_target_elements = get_target_elements + + print("Generating plan with S2FlaskScheduler!") + + # Compute the schedule + results = scheduler.compute() + + assert results is not None + print(f"Got {len(results)} results from S2FlaskScheduler") + + # Extract energy profiles from results + energy_data_list = [r for r in results if isinstance(r, dict) and "data" in r] + + assert len(energy_data_list) > 0 + print(f"Got {len(energy_data_list)} energy profiles from S2FlaskScheduler") + + # Verify the energy profile + if energy_data_list: + energy_series = energy_data_list[0]["data"] + assert len(energy_series) == T + print(f"Energy profile has {len(energy_series)} timesteps (expected {T})") + + print("S2FlaskScheduler test completed successfully!") + + +if __name__ == "__main__": + print("=" * 80) + print("Running PlanningServiceImpl test") + print("=" * 80) + test_planning_service_impl_with_nocontrol_devices() + + print("\n" + "=" * 80) + print("Running S2FlaskScheduler test") + print("=" * 80) + test_flask_scheduler_with_nocontrol_devices() + + print("\n" + "=" * 80) + print("All tests completed!") + print("=" * 80) diff --git a/flexmeasures_s2/profile_steering/planning_service_impl.py b/flexmeasures_s2/profile_steering/planning_service_impl.py index 823944a..3351d6f 100644 --- a/flexmeasures_s2/profile_steering/planning_service_impl.py +++ b/flexmeasures_s2/profile_steering/planning_service_impl.py @@ -28,6 +28,12 @@ from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state import ( S2FrbcDeviceState, ) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_device_planner import ( + S2NoControlDevicePlanner, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_device_state import ( + S2NoControlDeviceState, +) # Logger setup import logging @@ -174,6 +180,15 @@ def create_controller_tree( congestion_point, ) ) + elif isinstance(device_state, S2NoControlDeviceState): + logger.debug("S2 NoControl planner created!") + cpc.add_device_controller( + S2NoControlDevicePlanner( + device_state, + target.metadata, + congestion_point, + ) + ) # Add other device types here as needed else: logger.warning( diff --git a/flexmeasures_s2/scheduler/scheduler_flask.py b/flexmeasures_s2/scheduler/scheduler_flask.py index d49c727..fce1fbb 100644 --- a/flexmeasures_s2/scheduler/scheduler_flask.py +++ b/flexmeasures_s2/scheduler/scheduler_flask.py @@ -343,6 +343,10 @@ def _convert_cluster_plan_to_instructions(self, cluster_plan: ClusterPlan) -> li try: # Convert device plan to instructions + # Skip if there's no instruction profile (e.g., for nocontrol devices) + if device_plan.instruction_profile is None: + continue + device_instructions = device_plan.instruction_profile.elements frbc_count = sum( diff --git a/flexmeasures_s2/scheduler/schedulers.py b/flexmeasures_s2/scheduler/schedulers.py index e9b8586..38f8fa4 100644 --- a/flexmeasures_s2/scheduler/schedulers.py +++ b/flexmeasures_s2/scheduler/schedulers.py @@ -24,6 +24,12 @@ from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state import ( S2FrbcDeviceState, ) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_device_planner import ( + S2NoControlDevicePlanner, +) +from flexmeasures_s2.profile_steering.device_planner.nocontrol.s2_nocontrol_device_state import ( + S2NoControlDeviceState, +) # Schema imports from flexmeasures_s2.scheduler.schemas import S2FlexModelSchema, TNOFlexContextSchema @@ -173,6 +179,15 @@ def create_controller_tree( congestion_point, ) ) + elif isinstance(device_state, S2NoControlDeviceState): + logger.debug("S2 NoControl planner created!") + cpc.add_device_controller( + S2NoControlDevicePlanner( + device_state, + target.metadata, + congestion_point, + ) + ) # Add other device types here as needed else: logger.warning( diff --git a/flexmeasures_s2/scheduler/tests/test_frbc.py b/flexmeasures_s2/scheduler/tests/test_frbc.py deleted file mode 100644 index a04952d..0000000 --- a/flexmeasures_s2/scheduler/tests/test_frbc.py +++ /dev/null @@ -1,24 +0,0 @@ -import pandas as pd - -from flexmeasures_s2.scheduler.schedulers import S2Scheduler - - -def get_JouleProfileTarget(): - """Simple test helper function to create a target profile for testing.""" - # TODO: Implement proper target profile creation - return {"test": "target"} - - -def test_s2_frbc_scheduler(setup_frbc_asset): - scheduler = S2Scheduler( - setup_frbc_asset, - start=pd.Timestamp("2025-01-20T13:00+01"), - end=pd.Timestamp("2025-01-20T19:00+01"), - resolution=pd.Timedelta("PT1H"), - flex_model={}, # S2Scheduler fetches this from asset attributes - flex_context={ - "target-profile": get_JouleProfileTarget(), # todo: port target profile from Java test - }, - ) - results = scheduler.compute() - assert results == "todo: check for expected results"