Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
25497ca
MacOs gitignore
VladIftime Aug 18, 2025
5117d46
Check for correctnes of the algo
VladIftime Aug 18, 2025
47540c7
Added the plotting for cost
VladIftime Aug 18, 2025
070e188
Full pipeline attempt. Still need test.
VladIftime Aug 25, 2025
1f3031c
Full pipeline: fixing the scheduler class
VladIftime Aug 26, 2025
723c1ed
Full pipeline: removed the stand alone test
VladIftime Aug 26, 2025
a5474c1
dev: log traceback in S2Scheduler.compute() for debugging
Flix6x Sep 12, 2025
7e8aae8
fix: do not type-ignore a potential None value for congestion_point_t…
Flix6x Sep 12, 2025
c9e4446
style: mypy complains with `error: X | Y syntax for unions requires P…
Flix6x Sep 12, 2025
3447cf1
fix: upgrading s2-python to use pydantic 2
Flix6x Sep 12, 2025
4b0ccf5
dev: debug statement for DevicePlan initialization
Flix6x Sep 15, 2025
77665d7
fix: use app logger
Flix6x Sep 17, 2025
df874c5
dev: add minimum requirement for inflect package
Flix6x Sep 17, 2025
ee9a5ba
Merge branch 'dev/cost-target-porting' of github.com:FlexMeasures/fle…
VladIftime Sep 17, 2025
8c23baa
feat: support switching to costs minimizing mode, and fetch prices
Flix6x Sep 17, 2025
c16f136
feat: forward fill missing prices, while logging a warning
Flix6x Sep 17, 2025
fca1f92
fix: remove breakpoint accidentally committed
Flix6x Sep 17, 2025
a1fb028
fix: condition
Flix6x Sep 17, 2025
159a01d
feat: use a positive price in case of no prices, while logging a warning
Flix6x Sep 17, 2025
b168e8b
refactor: only log one of the two warnings
Flix6x Sep 17, 2025
a58468a
Merge branch 'dev/cost-target-porting' of github.com:FlexMeasures/fle…
VladIftime Sep 22, 2025
ae59602
Missing db-option and rate-limiting the scheduler
VladIftime Sep 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ exclude = .git,__pycache__,documentation
max-line-length = 160
max-complexity = 13
select = B,C,E,F,W,B9
ignore = E501, W503, E203
ignore = E501, W503, E203, E231

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,6 @@ cython_debug/
*.pickle
flexmeasures.log
*.png

#MacOS
.DS_Store
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
using the FLEXMEASURES_PLUGINS setting (a list).
Alternatively, if you installed this plugin as a package (e.g. via `python setup.py install`, `pip install -e` or `pip install flexmeasures_s2` should this project be on Pypi), then "flexmeasures_s2" suffices.

2.
2. Config settings (during development)

In your `flexmeasures.cfg`, you can set the following config settings for this plugin:

- `FLEXMEASURES_S2_TARGET_MODE`: "energy" or "costs"
- `FLEXMEASURES_S2_PRICE_SENSOR`: sensor ID of the price sensor

To do: move these settings to the db asset (preferred scheduler and flex-context).


## Development
Expand Down
14 changes: 14 additions & 0 deletions flexmeasures_s2/profile_steering/cluster_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,17 @@ def set_congestion_point_target(

if elements is not None:
self._congestion_point_targets[congestion_point_id].elements = elements

def contains_energy_target(self) -> bool:
"""
Check if this cluster target contains any energy targets (JouleElement instances).

Returns:
True if there are any JouleElement instances in the global target profile, False otherwise
"""
from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile

for element in self._global_target_profile.elements:
if isinstance(element, TargetProfile.JouleElement):
return True
return False
37 changes: 20 additions & 17 deletions flexmeasures_s2/profile_steering/common/proposal.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,25 @@ def get_global_improvement_value(self) -> float:
)
return self.global_improvement_value

# def get_cost_improvement_value(self) -> float:
# if self.cost_improvement_value is None:
# self.cost_improvement_value = self.get_cost(
# self.old_plan, self.global_diff_target
# ) - self.get_cost(self.proposed_plan, self.global_diff_target)
# return self.cost_improvement_value
def get_cost_improvement_value(self) -> float:
if self.cost_improvement_value is None:
self.cost_improvement_value = self.get_cost(
self.old_plan, self.global_diff_target
) - self.get_cost(self.proposed_plan, self.global_diff_target)
return self.cost_improvement_value

# @staticmethod
# def get_cost(plan: JouleProfile, target_profile: TargetProfile) -> float:
# cost = 0.0
# for i in range(target_profile.metadata.get_nr_of_timesteps()):
# joule_usage = plan.get_elements()[i]
# target_element = target_profile.get_elements()[i]
# if isinstance(target_element, TargetProfile.TariffElement):
# cost += (joule_usage / 3_600_000) * target_element.get_tariff()
# return cost
@staticmethod
def get_cost(plan: JouleProfile, target_profile: TargetProfile) -> float:
cost = 0.0
for i in range(target_profile.metadata.nr_of_timesteps):
joule_usage = plan.elements[i]
target_element = target_profile.elements[i]
if (
isinstance(target_element, TargetProfile.TariffElement)
and joule_usage is not None
):
cost += (joule_usage / 3_600_000) * target_element.get_tariff()
return cost

def get_congestion_improvement_value(self) -> float:
if self.congestion_improvement_value is None:
Expand Down Expand Up @@ -112,8 +115,8 @@ def is_preferred_to(self, other: "Proposal") -> bool:
elif (
self.get_global_improvement_value()
== other.get_global_improvement_value()
# and self.get_cost_improvement_value()
# > other.get_cost_improvement_value()
and self.get_cost_improvement_value()
> other.get_cost_improvement_value()
):
return True
return False
34 changes: 34 additions & 0 deletions flexmeasures_s2/profile_steering/common/target_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ class JouleElement(Element):
def __init__(self, joules: int):
self.joules = joules

class TariffElement(Element):
def __init__(self, tariff: float):
self.tariff = tariff

def get_tariff(self) -> float:
return self.tariff

class NullElement(Element):
pass

Expand Down Expand Up @@ -169,3 +176,30 @@ def from_joule_profile(joule_profile: JouleProfile) -> "TargetProfile":
joule_profile.metadata.timestep_duration,
[TargetProfile.JouleElement(e) for e in joule_profile.elements],
)

@staticmethod
def from_tariff_values(
metadata: ProfileMetadata, tariff_values: List[float]
) -> "TargetProfile":
"""Create a TargetProfile with TariffElement instances from tariff values.

Args:
metadata: ProfileMetadata for the target profile
tariff_values: List of tariff values (cost per kWh)

Returns:
TargetProfile with TariffElement instances
"""
if len(tariff_values) != metadata.nr_of_timesteps:
raise ValueError(
f"Number of tariff values ({len(tariff_values)}) must match nr_of_timesteps ({metadata.nr_of_timesteps})"
)

elements: List[Union["TargetProfile.Element", None]] = [
TargetProfile.TariffElement(tariff) for tariff in tariff_values
]
return TargetProfile(
metadata.profile_start,
metadata.timestep_duration,
elements,
)
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def from_fill_level_target_profile(fill_level_target_profile):
elements = []
start = fill_level_target_profile.start_time.astimezone(timezone.utc)
for element in fill_level_target_profile.elements:
end = start + timedelta(seconds=element.duration.__root__)
end = start + timedelta(seconds=element.duration.root)
elements.append(
FillLevelTargetElement(
start,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import datetime
from typing import Optional

from flask import current_app as app

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
Expand All @@ -21,9 +23,11 @@
from flexmeasures_s2.profile_steering.device_planner.device_planner_abstract import (
DevicePlanner,
)

from s2python.frbc import FRBCInstruction

# make sure this is a DevicePlanner


class S2FrbcDevicePlanner(DevicePlanner):
def __init__(
self,
Expand Down Expand Up @@ -201,6 +205,18 @@ def current_profile(self) -> JouleProfile:
def get_device_plan(self) -> Optional[DevicePlan]:
if self.accepted_plan is None:
return None
app.logger.debug(
dict(
device_id=self.device_id,
device_name=self.device_name,
connection_id=self.connection_id,
energy_profile=self.accepted_plan.energy,
fill_level_profile=self.accepted_plan.fill_level,
instruction_profile=self.convert_plan_to_instructions(
self.profile_metadata, self.accepted_plan
),
)
)
return DevicePlan(
device_id=self.device_id,
device_name=self.device_name,
Expand All @@ -216,16 +232,44 @@ def get_device_plan(self) -> Optional[DevicePlan]:
def convert_plan_to_instructions(
profile_metadata: ProfileMetadata, device_plan: S2FrbcPlan
) -> S2FrbcInstructionProfile:
import uuid
from datetime import timedelta

elements = []
actuator_configurations_per_timestep = device_plan.get_operation_mode_id()

if actuator_configurations_per_timestep is not None:
for actuator_configurations in actuator_configurations_per_timestep:
new_element = S2FrbcInstructionProfile.Element(
not actuator_configurations, actuator_configurations
for timestep_index, actuator_configurations_dict in enumerate(
actuator_configurations_per_timestep
):
# Calculate execution time for this timestep
execution_time = profile_metadata.profile_start + timedelta(
seconds=timestep_index
* profile_metadata.timestep_duration.total_seconds()
)
elements.append(new_element)
else:
elements = [None] * profile_metadata.nr_of_timesteps
# Ensure timezone-aware datetime
if execution_time.tzinfo is None:
from datetime import timezone

execution_time = execution_time.replace(tzinfo=timezone.utc)

# Create instructions for each actuator at this timestep
for (
actuator_id,
actuator_config,
) in actuator_configurations_dict.items():
if actuator_config is not None:
instruction = FRBCInstruction(
message_id=str(uuid.uuid4()),
id=str(uuid.uuid4()),
actuator_id=str(actuator_id),
operation_mode=str(actuator_config.operation_mode_id),
operation_mode_factor=float(actuator_config.factor),
execution_time=execution_time,
abnormal_condition=False,
)
elements.append(instruction)

return S2FrbcInstructionProfile(
profile_start=profile_metadata.profile_start,
timestep_duration=profile_metadata.timestep_duration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ def get_timer_duration_milliseconds(
timer = next(
(t for t in actuator_description.timers if str(t.id) == timer_id), None
)
return timer.duration.__root__ if timer else 0
return timer.duration.root if timer else 0

@staticmethod
@lru_cache(maxsize=None)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,20 @@
from typing import Dict, List
from typing import List
from datetime import datetime, timedelta
from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_actuator_configuration import (
S2ActuatorConfiguration,
)
from s2python.frbc import FRBCInstruction


class S2FrbcInstructionProfile:
class Element:
def __init__(
self,
idle: bool,
actuator_configuration: Dict[str, S2ActuatorConfiguration],
):
self.idle = idle
self.actuator_configuration = actuator_configuration

def is_idle(self) -> bool:
return self.idle

# This class represents a profile generated by the Reflex as a list of
# S2 FRBCInstruction s
def __init__(
self,
profile_start: datetime,
timestep_duration: timedelta,
elements: List[Element],
elements: List[FRBCInstruction],
):
self.profile_start = profile_start
self.timestep_duration = timestep_duration
self.elements = elements

def default_value(self) -> "S2FrbcInstructionProfile.Element":
return S2FrbcInstructionProfile.Element(True, {})

def __str__(self) -> str:
return f"S2FrbcInstructionProfile(elements={self.elements})"
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def from_storage_usage_profile(usage_forecast):
start = usage_forecast.start_time
start = start.astimezone(timezone.utc)
for element in usage_forecast.elements:
end = start + timedelta(seconds=element.duration.__root__)
end = start + timedelta(seconds=element.duration.root)
elements.append(
UsageForecastElement(start, end, element.usage_rate_expected)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[0.0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16245000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 8550000, 7695000, 5985000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 5130000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5985000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5130000, 5130000, 5130000, 5130000, 5130000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Loading
Loading