diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..a55e7a1
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/flexmeasures-s2.iml b/.idea/flexmeasures-s2.iml
new file mode 100644
index 0000000..e8fd289
--- /dev/null
+++ b/.idea/flexmeasures-s2.iml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..f8f2fd7
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..b88603f
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 1f9652d..4b31527 100644
--- a/README.md
+++ b/README.md
@@ -29,3 +29,7 @@ or:
Try it:
pre-commit run --all-files --show-diff-on-failure
+
+For profiling, use:
+
+ pyinstrument -o profiling_results.html test_frbc_device.py
diff --git a/flexmeasures_s2/api/somedata.py b/flexmeasures_s2/api/somedata.py
index 17608d9..80a88e4 100755
--- a/flexmeasures_s2/api/somedata.py
+++ b/flexmeasures_s2/api/somedata.py
@@ -1,5 +1,5 @@
-from flask_security import auth_token_required
from flask_json import as_json
+from flask_security import auth_token_required
from .. import flexmeasures_s2_api_bp
diff --git a/flexmeasures_s2/api/tests/test_api.py b/flexmeasures_s2/api/tests/test_api.py
index d2048ad..3f4eb6f 100644
--- a/flexmeasures_s2/api/tests/test_api.py
+++ b/flexmeasures_s2/api/tests/test_api.py
@@ -1,16 +1,52 @@
from flask import url_for
+from flask_security import decorators as fs_decorators
+from rq.job import Job
+from flexmeasures.api.tests.test_auth_token import patched_check_token
+from flexmeasures.api.tests.utils import UserContext
+from flexmeasures.data.services.scheduling import (
+ handle_scheduling_exception,
+ get_data_source_for_job,
+)
+from flexmeasures.data.tests.utils import work_on_rq
-def test_get_somedata_needs_authtoken(client):
- response = client.get(
- url_for("flexmeasures-s2 API.somedata"),
- headers={"content-type": "application/json"},
- follow_redirects=True,
- )
- assert response.status_code == 401 # HTTP error code 401 Unauthorized.
- assert "application/json" in response.content_type
- assert "not be properly authenticated" in response.json["message"]
+def test_s2_frbc_api(monkeypatch, app, setup_frbc_asset):
+ sensor = setup_frbc_asset.sensors[0]
-# TODO: The somedata endpoint requires authentication to be testes successfully.
-# We'll need to add a user in conftest, which also requires us to add a db to testing
+ with UserContext("test_admin_user@seita.nl") as admin:
+ auth_token = admin.get_auth_token()
+
+ monkeypatch.setattr(fs_decorators, "_check_token", patched_check_token)
+ with app.test_client() as client:
+ trigger_schedule_response = client.post(
+ url_for("SensorAPI:trigger_schedule", id=sensor.id),
+ json={
+ "flex-context": {
+ "target-profile": {}, # add target profile
+ }
+ },
+ headers={"Authorization": auth_token},
+ )
+ print("Server responded with:\n%s" % trigger_schedule_response.json)
+ assert trigger_schedule_response.status_code == 200
+ job_id = trigger_schedule_response.json["schedule"]
+
+ # Now that our scheduling job was accepted, we process the scheduling queue
+ work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception)
+ job = Job.fetch(job_id, connection=app.queues["scheduling"].connection).is_finished
+ assert job.is_finished is True
+
+ # First, make sure the expected scheduler data source is now there
+ job.refresh() # catch meta info that was added on this very instance
+ scheduler_source = get_data_source_for_job(job)
+ assert scheduler_source.model == "S2Scheduler"
+
+ # try to retrieve the schedule through the /sensors//schedules/ [GET] api endpoint
+ # todo: to be discussed: the response from the S2Scheduler might get a different format than the FM default
+ # get_schedule_response = client.get(
+ # url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id),
+ # query_string={"duration": "PT48H"},
+ # )
+ # print("Server responded with:\n%s" % get_schedule_response.json)
+ # assert get_schedule_response.status_code == 200
diff --git a/flexmeasures_s2/conftest.py b/flexmeasures_s2/conftest.py
index 186ee1a..a97ad46 100644
--- a/flexmeasures_s2/conftest.py
+++ b/flexmeasures_s2/conftest.py
@@ -1,12 +1,18 @@
import pytest
+from flask_sqlalchemy import SQLAlchemy
+
+from flexmeasures import Asset, AssetType, Sensor, Account
from flexmeasures.app import create as create_flexmeasures_app
+from flexmeasures.auth.policy import ADMIN_ROLE
from flexmeasures.conftest import ( # noqa F401
db,
fresh_db,
-) # Use these fixtures to rely on the FlexMeasures database.
+) # Use these fixtures to rely on the FlexMeasures database. There might be others in flexmeasures/conftest you want to also re-use
+from flexmeasures.data.services.users import create_user
-# There might be others in flexmeasures/conftest you want to also re-use
+from flexmeasures_s2 import S2_SCHEDULER_SPECS
+from flexmeasures_s2.models.const import FRBC_TYPE
@pytest.fixture(scope="session")
@@ -25,3 +31,43 @@ def app():
ctx.pop()
print("DONE WITH APP FIXTURE")
+
+
+@pytest.fixture(scope="module")
+def setup_admin(db: SQLAlchemy): # noqa: F811
+ account = Account(name="Some FlexMeasures host")
+ db.session.add(account)
+ create_user(
+ username="Test Admin User",
+ email="test_admin_user@seita.nl",
+ account_name=account.name,
+ password="testtest",
+ user_roles=dict(name=ADMIN_ROLE, description="A user who can do everything."),
+ )
+ yield account
+
+
+@pytest.fixture(scope="module")
+def setup_frbc_asset(db: SQLAlchemy, setup_admin): # noqa: F811
+ asset_type = AssetType(name=FRBC_TYPE)
+ asset = Asset(
+ name="Test FRBC asset",
+ generic_asset_type=asset_type,
+ owner=setup_admin,
+ )
+ asset.attributes = {
+ "custom-scheduler": S2_SCHEDULER_SPECS,
+ "flex-model": {
+ "S2-FRBC-device-state": None, # ?todo: add serialized state
+ },
+ }
+ db.session.add(asset)
+ sensor = Sensor(
+ name="power",
+ unit="kW",
+ event_resolution="PT5M",
+ generic_asset=asset,
+ )
+ db.session.add(sensor)
+ db.session.flush() # assign (asset and sensor) IDs
+ yield asset
diff --git a/flexmeasures_s2/models/__init__.py b/flexmeasures_s2/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/flexmeasures_s2/models/const.py b/flexmeasures_s2/models/const.py
new file mode 100644
index 0000000..6ff6a35
--- /dev/null
+++ b/flexmeasures_s2/models/const.py
@@ -0,0 +1,2 @@
+NAMESPACE = "fm-s2"
+FRBC_TYPE = f"{NAMESPACE}.FRBC"
diff --git a/flexmeasures_s2/profile_steering/__init__.py b/flexmeasures_s2/profile_steering/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/flexmeasures_s2/profile_steering/cluster_plan.py b/flexmeasures_s2/profile_steering/cluster_plan.py
new file mode 100644
index 0000000..61a58cb
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/cluster_plan.py
@@ -0,0 +1,635 @@
+from datetime import datetime
+from typing import List, Dict, Any, Optional
+import uuid
+
+# Common data types
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata
+
+# Import from common data structures to avoid circular imports
+from flexmeasures_s2.profile_steering.common_data_structures import (
+ ClusterState,
+ DevicePlan,
+)
+
+# Import cluster target
+from flexmeasures_s2.profile_steering.cluster_target import ClusterTarget
+
+
+class ClusterPlanData:
+ """Class representing planning data for a cluster."""
+
+ class CpData:
+ """Class representing data for a congestion point."""
+
+ def __init__(
+ self,
+ cp_id: str,
+ cp_plan: List[float],
+ der_plans: Dict[str, List[float]],
+ cp_consumption: List[float],
+ cp_production: List[float],
+ cp_consumption_max: float,
+ cp_production_max: float,
+ ):
+ self._cp_id = cp_id
+ self._cp_plan = cp_plan
+ self._der_plans = der_plans
+ self._cp_consumption = cp_consumption
+ self._cp_production = cp_production
+ self._cp_consumption_max = cp_consumption_max
+ self._cp_production_max = cp_production_max
+
+ def get_cp_id(self) -> str:
+ return self._cp_id
+
+ def get_cp_plan(self) -> List[float]:
+ return self._cp_plan
+
+ def get_der_plans(self) -> Dict[str, List[float]]:
+ return self._der_plans
+
+ def get_cp_consumption(self) -> List[float]:
+ return self._cp_consumption
+
+ def get_cp_production(self) -> List[float]:
+ return self._cp_production
+
+ def get_cp_consumption_max(self) -> float:
+ return self._cp_consumption_max
+
+ def get_cp_production_max(self) -> float:
+ return self._cp_production_max
+
+ def add_der_plan(self, der_name: str, value: JouleProfile) -> "ClusterPlanData.CpData":
+ """Add a device energy resource plan to this congestion point.
+
+ Args:
+ der_name: The name of the device energy resource
+ value: The profile of the device energy resource
+
+ Returns:
+ A new CpData instance with the added device energy resource plan
+ """
+ der_plan = to_float_array(value)
+ new_cp_plan = [0] * len(self._cp_plan)
+ cp_consumption = [0] * len(self._cp_plan)
+ cp_production = [0] * len(self._cp_plan)
+ consumption_max = self._cp_consumption_max
+ production_max = self._cp_production_max
+
+ for i in range(len(self._cp_plan)):
+ new_cp_plan[i] = der_plan[i] + self._cp_plan[i]
+ cp_consumption[i] = self._cp_consumption[i]
+ cp_production[i] = self._cp_production[i]
+
+ if der_plan[i] >= 0:
+ cp_consumption[i] += der_plan[i]
+ consumption_max = max(cp_consumption[i], consumption_max)
+ else:
+ cp_production[i] += der_plan[i]
+ production_max = min(cp_production[i], production_max)
+
+ new_der_plans = self._der_plans.copy()
+ new_der_plans[der_name] = der_plan
+
+ return ClusterPlanData.CpData(
+ self._cp_id,
+ new_cp_plan,
+ new_der_plans,
+ cp_consumption,
+ cp_production,
+ consumption_max,
+ production_max,
+ )
+
+ @classmethod
+ def empty(cls, cp_id: str, profile_metadata: ProfileMetadata) -> "ClusterPlanData.CpData":
+ """Create an empty CpData instance.
+
+ Args:
+ cp_id: The congestion point ID
+ profile_metadata: Metadata about the profile
+
+ Returns:
+ An empty CpData instance
+ """
+ timesteps = profile_metadata.nr_of_timesteps
+
+ return cls(
+ cp_id,
+ [0.0] * timesteps,
+ {},
+ [0.0] * timesteps,
+ [0.0] * timesteps,
+ 0.0,
+ 0.0,
+ )
+
+ def __init__(
+ self,
+ device_plans: List[DevicePlan] = None,
+ profile_metadata: ProfileMetadata = None,
+ _id: str = None,
+ reason: str = None,
+ target: Any = None,
+ active_target: Any = None,
+ current_plan: List[float] = None,
+ start: int = None,
+ step: int = None,
+ global_deviation_score: float = 1.0,
+ constraint_violation_score: float = 1.0,
+ cp_datas: Dict[str, CpData] = None,
+ ):
+ self._device_plans = device_plans or []
+ self._profile_metadata = profile_metadata
+ self._id = _id
+ self._reason = reason
+ self._target = target
+ self._active_target = active_target
+ self._current_plan = current_plan
+ self._start = start
+ self._step = step
+ self._global_deviation_score = global_deviation_score
+ self._constraint_violation_score = constraint_violation_score
+ self._cp_datas = cp_datas or {}
+
+ def get_device_plans(self) -> List[DevicePlan]:
+ return self._device_plans
+
+ def get_profile_metadata(self) -> ProfileMetadata:
+ return self._profile_metadata
+
+ def get_id(self) -> str:
+ return self._id
+
+ def get_reason(self) -> str:
+ return self._reason
+
+ def get_target(self) -> Any:
+ return self._target
+
+ def get_active_target(self) -> Any:
+ return self._active_target
+
+ def get_current_plan(self) -> List[float]:
+ return self._current_plan
+
+ def get_start(self) -> int:
+ return self._start
+
+ def get_step(self) -> int:
+ return self._step
+
+ def get_global_deviation_score(self) -> float:
+ return self._global_deviation_score
+
+ def get_constraint_violation_score(self) -> float:
+ return self._constraint_violation_score
+
+ def get_cp_datas(self) -> Dict[str, CpData]:
+ return self._cp_datas
+
+ def is_compatible(self, other: Any) -> bool:
+ """Check if this cluster plan data is compatible with another profile.
+
+ Args:
+ other: The other profile to check compatibility with
+
+ Returns:
+ True if compatible, False otherwise
+ """
+ if self._profile_metadata is None:
+ return False
+
+ return self._profile_metadata.is_compatible(other.metadata)
+
+ def subprofile(self, new_start_date: datetime) -> "ClusterPlanData":
+ """Create a subprofile starting at the specified date.
+
+ Args:
+ new_start_date: The new start date for the profile
+
+ Returns:
+ A new ClusterPlanData instance with adjusted start date
+ """
+ # Create a copy of this instance with adjusted device plans
+ new_device_plans = []
+ for device_plan in self._device_plans:
+ new_profile = device_plan._profile.subprofile(new_start_date)
+ new_device_plans.append(DevicePlan(device_plan._device_id, new_profile))
+
+ # Create new profile metadata with adjusted start date
+ new_profile_metadata = self._profile_metadata.subprofile(new_start_date)
+
+ return ClusterPlanData(
+ device_plans=new_device_plans,
+ profile_metadata=new_profile_metadata,
+ _id=self._id,
+ reason=self._reason,
+ target=self._target,
+ active_target=self._active_target,
+ current_plan=self._current_plan,
+ start=int(new_start_date.timestamp() * 1000),
+ # Convert to milliseconds
+ step=self._step,
+ global_deviation_score=self._global_deviation_score,
+ constraint_violation_score=self._constraint_violation_score,
+ cp_datas=self._cp_datas,
+ )
+
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> "ClusterPlanData":
+ """Adjust the number of elements in the profile.
+
+ Args:
+ nr_of_elements: The new number of elements
+
+ Returns:
+ A new ClusterPlanData instance with adjusted number of elements
+ """
+ # Create a copy of this instance with adjusted device plans
+ new_device_plans = []
+ for device_plan in self._device_plans:
+ new_profile = device_plan._profile.adjust_nr_of_elements(nr_of_elements)
+ new_device_plans.append(DevicePlan(device_plan._device_id, new_profile))
+
+ # Create new profile metadata with adjusted number of elements
+ new_profile_metadata = self._profile_metadata.adjust_nr_of_elements(nr_of_elements)
+
+ return ClusterPlanData(
+ device_plans=new_device_plans,
+ profile_metadata=new_profile_metadata,
+ _id=self._id,
+ reason=self._reason,
+ target=self._target,
+ active_target=self._active_target,
+ current_plan=(self._current_plan[:nr_of_elements] if self._current_plan else None),
+ start=self._start,
+ step=self._step,
+ global_deviation_score=self._global_deviation_score,
+ constraint_violation_score=self._constraint_violation_score,
+ cp_datas=self._cp_datas,
+ )
+
+ @classmethod
+ def from_cluster_plan(
+ cls,
+ cluster_plan: "ClusterPlan",
+ active_target: ClusterTarget,
+ active_plan: JouleProfile,
+ ) -> "ClusterPlanData":
+ """Create a ClusterPlanData instance from a ClusterPlan.
+
+ Args:
+ cluster_plan: The cluster plan to create the data from
+ active_target: The active target for the cluster
+ active_plan: The active plan for the cluster
+
+ Returns:
+ A ClusterPlanData instance
+ """
+ congestion_points = {}
+
+ # Get the device plans from the cluster plan
+ cluster_plan_data = cluster_plan._plan_data
+ if cluster_plan_data is None:
+ return None # type: ignore
+
+ # Process each device plan
+ for device_plan in cluster_plan_data._device_plans:
+ cp_id = cluster_plan._state.get_congestion_point(device_plan._device_id)
+ if cp_id is None:
+ # The interface needs all devices to function properly. So for devices without congestion point, set a
+ # dummy congestion point so that those device plans go through.
+ cp_id = "No congestion point"
+
+ if cp_id not in congestion_points:
+ congestion_points[cp_id] = ClusterPlanData.CpData.empty(
+ cp_id, cluster_plan._plan_data._profile_metadata
+ )
+
+ congestion_points[cp_id] = congestion_points[cp_id].add_der_plan(
+ device_plan._device_id, device_plan._profile
+ )
+
+ # Create the current plan
+ if active_plan is None:
+ # Extract the plan from the cluster plan's JouleProfile
+ joule_profile = cluster_plan.get_joule_profile()
+ current_plan = [element if element is not None else 0.0 for element in joule_profile.elements]
+ else:
+ # Use the active plan, adjusting it to the profile metadata
+ profile_start = cluster_plan._plan_data._profile_metadata.profile_start
+ nr_of_timesteps = cluster_plan._plan_data._profile_metadata.nr_of_timesteps
+
+ subprofile = active_plan.subprofile(profile_start)
+ adjusted_profile = subprofile.adjust_nr_of_elements(nr_of_timesteps)
+ current_plan = [element if element is not None else 0.0 for element in adjusted_profile.elements]
+
+ # Get the profile metadata
+ profile_metadata = cluster_plan._plan_data._profile_metadata
+
+ # Set up the active target
+ actual_active_target = active_target if active_target is not None else cluster_plan._target
+
+ # Calculate timestamps
+ start_time = profile_metadata.profile_start.timestamp() * 1000 # Convert to milliseconds
+ timestep_duration = profile_metadata.timestep_duration.total_seconds() * 1000 # Convert to milliseconds
+
+ # Get scores, defaulting to 1.0 if they're NaN
+ global_deviation_score = cluster_plan.get_global_deviation_score()
+ global_deviation_score = 1.0 if global_deviation_score is None else global_deviation_score
+
+ constraint_violation_score = cluster_plan.get_constraint_violation_score()
+ constraint_violation_score = 1.0 if constraint_violation_score is None else constraint_violation_score
+
+ return cls(
+ device_plans=cluster_plan_data._device_plans,
+ profile_metadata=profile_metadata,
+ _id=str(cluster_plan._id),
+ reason=cluster_plan._reason,
+ target=cluster_plan._target,
+ active_target=actual_active_target,
+ current_plan=current_plan,
+ start=int(start_time),
+ step=int(timestep_duration),
+ global_deviation_score=global_deviation_score,
+ constraint_violation_score=constraint_violation_score,
+ cp_datas=congestion_points,
+ )
+
+
+def to_float_array(profile: JouleProfile) -> List[float]:
+ """Convert a JouleProfile to a list of floats.
+
+ Args:
+ profile: The profile to convert
+
+ Returns:
+ A list of floats
+ """
+ result = [0.0] * profile.metadata.nr_of_timesteps
+ for i, element in enumerate(profile.elements):
+ result[i] = 0.0 if element is None else float(element)
+ return result
+
+
+class ClusterPlan:
+ """Class representing a plan for a cluster."""
+
+ def __init__(
+ self,
+ state: ClusterState,
+ target: ClusterTarget,
+ plan_data: ClusterPlanData,
+ reason: str,
+ plan_due_by_date: datetime,
+ parent_plan: Optional["ClusterPlan"] = None,
+ _id: Optional[str] = None,
+ global_deviation_score: Optional[float] = None,
+ constraint_violation_score: Optional[float] = None,
+ activated_at: Optional[datetime] = None,
+ planned_energy: Optional[float] = None,
+ ):
+ self._state = state
+ self._target = target
+ self._plan_data = plan_data
+ self._reason = reason
+ self._plan_due_by_date = plan_due_by_date
+ self._parent_plan = parent_plan
+ self._id = _id or str(uuid.uuid4())
+ self._global_deviation_score = global_deviation_score
+ self._constraint_violation_score = constraint_violation_score
+ self._joule_profile = None # Will be initialized when needed
+ self._activated_at = activated_at
+ self._planned_energy = planned_energy # Lazy evaluation field
+
+ def get_state(self) -> ClusterState:
+ return self._state
+
+ def get_target(self) -> ClusterTarget:
+ return self._target
+
+ def get_plan_data(self) -> ClusterPlanData:
+ return self._plan_data
+
+ def get_reason(self) -> str:
+ return self._reason
+
+ def get_plan_due_by_date(self) -> datetime:
+ return self._plan_due_by_date
+
+ def get_parent_plan(self) -> Optional["ClusterPlan"]:
+ return self._parent_plan
+
+ def get_id(self) -> str:
+ return self._id
+
+ def get_activated_at(self) -> Optional[datetime]:
+ return self._activated_at
+
+ def get_target_energy(self) -> float:
+ """Get the target energy for this plan.
+
+ Returns:
+ The target energy
+ """
+ return self._target._global_target_profile.get_total_energy()
+
+ def get_planned_energy(self) -> float:
+ """Get the planned energy for this plan.
+
+ Returns:
+ The planned energy
+ """
+ if self._planned_energy is not None:
+ return self._planned_energy
+
+ sum_energy = 0.0
+ for device_plan in self._plan_data._device_plans:
+ sum_energy += device_plan._profile.get_total_energy()
+
+ self._planned_energy = sum_energy
+ return sum_energy
+
+ def get_global_deviation_score(self) -> Optional[float]:
+ """Get the global deviation score for this plan.
+
+ Returns:
+ The global deviation score
+ """
+ if self._global_deviation_score is not None:
+ return self._global_deviation_score
+
+ if self._target is None:
+ return None
+
+ plan_segment = self.get_joule_profile().subprofile(self._target._global_target_profile.metadata.profile_start)
+
+ if plan_segment.get_total_energy() == 0.0:
+ return 0.0
+
+ sum_squared_distance = 0.0
+ for i in range(len(plan_segment.elements)):
+ sum_squared_distance += (plan_segment.elements[i] - self._target._global_target_profile.elements[i]) ** 2
+
+ return sum_squared_distance / plan_segment.get_total_energy()
+
+ def get_constraint_violation_score(self) -> Optional[float]:
+ """Get the constraint violation score for this plan.
+
+ Returns:
+ The constraint violation score
+ """
+ if self._constraint_violation_score is not None:
+ return self._constraint_violation_score
+
+ if self._target is None:
+ return None
+
+ planned_energy = self.get_planned_energy()
+ if planned_energy == 0.0:
+ return 0.0
+
+ sum_squared_distance = 0.0
+ for cp_id, cp_profile in self.get_profile_per_congestion_point().items():
+ congestion_point_target = self._target.get_congestion_point_target(cp_id)
+ if congestion_point_target is None:
+ continue
+
+ for i in range(len(cp_profile.elements)):
+ if cp_profile.elements[i] > congestion_point_target.elements[i].max_joule:
+ sum_squared_distance += (
+ cp_profile.elements[i] - congestion_point_target.elements[i].max_joule
+ ) ** 2
+ elif cp_profile.elements[i] < congestion_point_target.elements[i].min_joule:
+ sum_squared_distance += (
+ cp_profile.elements[i] - congestion_point_target.elements[i].min_joule
+ ) ** 2
+
+ return sum_squared_distance / planned_energy
+
+ def has_constraint_violation(self) -> bool:
+ """Check if this plan has any constraint violations.
+
+ Returns:
+ True if there are constraint violations, False otherwise
+ """
+ for cp_id, cp_profile in self.get_profile_per_congestion_point().items():
+ plan = cp_profile.elements
+ congestion_point_target = self._target.get_congestion_point_target(cp_id)
+
+ if congestion_point_target is not None:
+ # If there is no target, we don't need to check for violations
+ target_elements = congestion_point_target.elements
+
+ for i in range(len(target_elements)):
+ element = target_elements[i]
+ max_joule = element.max_joule
+ min_joule = element.min_joule
+ plan_value = plan[i]
+
+ if max_joule is not None and plan_value > max_joule:
+ return True
+
+ if min_joule is not None and plan_value < min_joule:
+ return True
+
+ return False
+
+ def is_compatible(self, other: Any) -> bool:
+ """Check if this cluster plan is compatible with another profile.
+
+ Args:
+ other: The other profile to check compatibility with
+
+ Returns:
+ True if compatible, False otherwise
+ """
+ return self._plan_data.is_compatible(other)
+
+ def subprofile(self, new_start_date: datetime) -> "ClusterPlan":
+ """Create a subprofile starting at the specified date.
+
+ Args:
+ new_start_date: The new start date for the profile
+
+ Returns:
+ A new ClusterPlan instance with adjusted start date
+ """
+ return ClusterPlan(
+ state=self._state,
+ target=self._target.subprofile(new_start_date),
+ plan_data=self._plan_data.subprofile(new_start_date),
+ reason=self._reason,
+ plan_due_by_date=self._plan_due_by_date,
+ parent_plan=None,
+ _id=self._id,
+ activated_at=None,
+ )
+
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> "ClusterPlan":
+ """Adjust the number of elements in the profile.
+
+ Args:
+ nr_of_elements: The new number of elements
+
+ Returns:
+ A new ClusterPlan instance with adjusted number of elements
+ """
+ return ClusterPlan(
+ state=self._state,
+ target=self._target.adjust_nr_of_elements(nr_of_elements),
+ plan_data=self._plan_data.adjust_nr_of_elements(nr_of_elements),
+ reason=self._reason,
+ plan_due_by_date=self._plan_due_by_date,
+ parent_plan=None,
+ _id=self._id,
+ activated_at=None,
+ )
+
+ def get_profile_metadata(self) -> ProfileMetadata:
+ """Get the profile metadata for this plan.
+
+ Returns:
+ The profile metadata
+ """
+ return self._target._global_target_profile.metadata
+
+ def get_profile_per_congestion_point(self) -> Dict[str, JouleProfile]:
+ """Get the profile for each congestion point.
+
+ Returns:
+ A dictionary mapping congestion point IDs to profiles
+ """
+ result = {}
+
+ for device_plan in self._plan_data._device_plans:
+ cp_id = self._state.get_congestion_point(device_plan._device_id)
+ profile = device_plan._profile
+
+ if cp_id in result:
+ result[cp_id] = result[cp_id].add(profile)
+ else:
+ result[cp_id] = profile
+
+ return result
+
+ def get_joule_profile(self) -> JouleProfile:
+ """Get the JouleProfile for this plan.
+
+ Returns:
+ The JouleProfile for this plan
+ """
+
+ # Add all device plans to the profile
+ sum_profile = JouleProfile(
+ profile_start=self._target._global_target_profile.metadata.profile_start,
+ timestep_duration=self._target._global_target_profile.metadata.timestep_duration,
+ profile_length=self._target._global_target_profile.metadata.nr_of_timesteps,
+ value=0.0,
+ )
+ for device_plan in self._plan_data._device_plans:
+ sum_profile = sum_profile.add(device_plan._profile)
+
+ return sum_profile
diff --git a/flexmeasures_s2/profile_steering/cluster_target.py b/flexmeasures_s2/profile_steering/cluster_target.py
new file mode 100644
index 0000000..1a250d1
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/cluster_target.py
@@ -0,0 +1,224 @@
+from datetime import datetime
+from typing import Dict, Optional, Any, List
+import uuid
+
+# Common data types
+from flexmeasures_s2.profile_steering.common.joule_range_profile import (
+ JouleRangeProfile,
+)
+from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile
+
+
+class ClusterTarget:
+ """
+ ClusterTarget instances are used by traders to express how a plan should look like.
+
+ There are two levels at which a target can be set:
+ 1. Global level (i.e., a target for the cluster as a whole)
+ 2. Congestion point level
+
+ The global target is a TargetProfile (i.e., a line) and the congestion point targets
+ are JouleRangeProfile instances, allowing congestion targets to be set with
+ minimum and maximum constraints.
+ """
+
+ def __init__(
+ self,
+ generated_at: datetime,
+ parent_id: Any,
+ generated_by: Any,
+ global_target_profile: TargetProfile,
+ congestion_point_targets: Optional[Dict[str, JouleRangeProfile]] = None,
+ ):
+ """
+ Initialize a ClusterTarget instance.
+
+ Args:
+ generated_at: When the target was generated
+ parent_id: The ID of the parent target
+ generated_by: The entity that generated the target
+ global_target_profile: The global target profile for the cluster
+ congestion_point_targets: Targets for specific congestion points
+ """
+ self._id = str(uuid.uuid4())
+ self._generated_at = generated_at
+ self._parent_id = parent_id
+ self._generated_by = generated_by
+ self._global_target_profile = global_target_profile
+ self._congestion_point_targets = congestion_point_targets or {}
+
+ # Validate
+ if global_target_profile is None:
+ raise ValueError("global_target_profile cannot be None")
+
+ if congestion_point_targets is not None:
+ for cp_id, cp_target in congestion_point_targets.items():
+ if not cp_target.is_compatible(global_target_profile):
+ raise ValueError(
+ f"Congestion point target {cp_id} is not compatible with the global target profile. "
+ f"Expected global metadata: {global_target_profile.metadata}, "
+ f"but congestion profile had {cp_target.metadata}"
+ )
+
+ def get_id(self) -> str:
+ """
+ Get the ID of this target.
+
+ Returns:
+ The target ID
+ """
+ return self._id
+
+ def get_generated_at(self) -> datetime:
+ """
+ Get when this target was generated.
+ Returns:
+ The generation timestamp
+ """
+ return self._generated_at
+
+ def get_parent_id(self) -> Any:
+ """
+ Get the parent ID of this target.
+
+ Returns:
+ The parent ID
+ """
+ return self._parent_id
+
+ def get_generated_by(self) -> Any:
+ """
+ Get the entity that generated this target.
+
+ Returns:
+ The generator entity
+ """
+ return self._generated_by
+
+ def get_global_target_profile(self) -> TargetProfile:
+ """
+ Get the global target profile.
+
+ Returns:
+ The global target profile
+ """
+ return self._global_target_profile
+
+ def get_congestion_point_targets(self) -> Dict[str, JouleRangeProfile]:
+ """
+ Get all congestion point targets.
+
+ Returns:
+ A dictionary mapping congestion point IDs to their targets
+ """
+ return self._congestion_point_targets
+
+ def get_congestion_point_target(self, congestion_point_id: str) -> Optional[JouleRangeProfile]:
+ """
+ Get the target for a specific congestion point.
+
+ Args:
+ congestion_point_id: The ID of the congestion point
+
+ Returns:
+ The target for the congestion point, or None if not found
+ """
+ return self._congestion_point_targets.get(congestion_point_id)
+
+ def get_profile_metadata(self) -> Any:
+ """
+ Get the profile metadata.
+
+ Returns:
+ The profile metadata
+ """
+ return self._global_target_profile.metadata
+
+ def get_target_energy(self) -> float:
+ """
+ Get the total target energy.
+
+ Returns:
+ The total energy
+ """
+ return self._global_target_profile.get_total_energy()
+
+ def is_compatible(self, other: Any) -> bool:
+ """
+ Check if this target is compatible with another profile.
+
+ Args:
+ other: The other profile to check compatibility with
+
+ Returns:
+ True if compatible, False otherwise
+ """
+ return self._global_target_profile.is_compatible(other)
+
+ def subprofile(self, new_start_date: datetime) -> "ClusterTarget":
+ """
+ Create a subprofile starting at the specified date.
+
+ Args:
+ new_start_date: The new start date for the profile
+
+ Returns:
+ A new ClusterTarget instance with adjusted start date
+ """
+ plans = {}
+ for cp_id, cp_target in self._congestion_point_targets.items():
+ plans[cp_id] = cp_target.subprofile(new_start_date)
+
+ return ClusterTarget(
+ self._generated_at,
+ self._parent_id,
+ self._generated_by,
+ self._global_target_profile.subprofile(new_start_date),
+ plans,
+ )
+
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> "ClusterTarget":
+ """
+ Adjust the number of elements in the profile.
+
+ Args:
+ nr_of_elements: The new number of elements
+
+ Returns:
+ A new ClusterTarget instance with adjusted number of elements
+ """
+ plans = {}
+ for cp_id, cp_target in self._congestion_point_targets.items():
+ plans[cp_id] = cp_target.adjust_nr_of_elements(nr_of_elements)
+
+ return ClusterTarget(
+ self._generated_at,
+ self._parent_id,
+ self._generated_by,
+ self._global_target_profile.adjust_nr_of_elements(nr_of_elements),
+ plans,
+ )
+
+ def set_congestion_point_target(
+ self,
+ congestion_point_id: str,
+ congestion_point_target: JouleRangeProfile,
+ elements: Optional[List[Any]] = None,
+ ):
+ """
+ Set a target for a specific congestion point.
+
+ Args:
+ congestion_point_id: The ID of the congestion point
+ congestion_point_target: The target for the congestion point
+ elements: Optional elements to set in the target profile
+ """
+ if not congestion_point_target.is_compatible(self._global_target_profile):
+ raise ValueError(
+ f"Congestion point target {congestion_point_id} is not compatible with the global target profile"
+ )
+
+ self._congestion_point_targets[congestion_point_id] = congestion_point_target
+
+ if elements is not None:
+ self._congestion_point_targets[congestion_point_id].set_elements(elements)
diff --git a/flexmeasures_s2/profile_steering/common/__init__.py b/flexmeasures_s2/profile_steering/common/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/flexmeasures_s2/profile_steering/common/abstract_profile.py b/flexmeasures_s2/profile_steering/common/abstract_profile.py
new file mode 100644
index 0000000..271bde9
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/abstract_profile.py
@@ -0,0 +1,146 @@
+from abc import ABC, abstractmethod
+from typing import List, TypeVar, Generic, Optional
+from datetime import datetime, timedelta
+from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata
+
+E = TypeVar("E")
+PT = TypeVar("PT", bound="AbstractProfile")
+
+
+class AbstractProfile(ABC, Generic[E, PT]):
+ def __init__(
+ self,
+ profile_metadata: Optional[ProfileMetadata] = None,
+ elements: Optional[List[E]] = None,
+ profile_start: Optional[datetime] = None,
+ timestep_duration: Optional[timedelta] = None,
+ value: Optional[E] = None,
+ nr_of_elements: Optional[int] = None
+ ):
+ """
+ Initialize an AbstractProfile with various parameter combinations.
+
+ Args:
+ profile_metadata: ProfileMetadata object containing start time and duration
+ elements: List of profile elements
+ profile_start: Start time of the profile
+ timestep_duration: Duration of each timestep
+ value: Single value to fill the entire profile
+ nr_of_elements: Number of elements when using a single value
+ """
+ # Case 1: Initialize with metadata and elements
+ if profile_metadata is not None and elements is not None:
+ self.metadata = profile_metadata
+ self.elements = elements
+ self.validate(profile_metadata, elements)
+ return
+
+ # Case 2: Initialize with start time, duration and elements
+ if profile_start is not None and timestep_duration is not None and elements is not None:
+ self.metadata = ProfileMetadata(profile_start, timestep_duration, len(elements))
+ self.elements = elements
+ self.validate(self.metadata, elements)
+ return
+
+ # Case 3: Initialize with start time, duration, single value and number of elements
+ if profile_start is not None and timestep_duration is not None and value is not None and nr_of_elements is not None:
+ self.elements = [value] * nr_of_elements
+ self.metadata = ProfileMetadata(profile_start, timestep_duration, nr_of_elements)
+ self.validate(self.metadata, self.elements)
+ return
+
+ # Case 4: Empty initialization (for serialization)
+ self.metadata = None
+ self.elements = []
+
+ @abstractmethod
+ def validate(self, profile_metadata: ProfileMetadata, elements: List[E]):
+ if elements is None:
+ raise ValueError("elements cannot be null")
+ if (
+ 24 * 60 * 60 * 1000
+ ) % profile_metadata.timestep_duration.total_seconds() * 1000 != 0:
+ raise ValueError("A day should be dividable by the timeStepDuration")
+ if (
+ not self.start_of_current_aligned_date(
+ profile_metadata.profile_start,
+ profile_metadata.timestep_duration,
+ )
+ == profile_metadata.profile_start
+ ):
+ raise ValueError(
+ "The startTimeDuration should be aligned with the timeStepDuration"
+ )
+
+ def get_profile_metadata(self) -> ProfileMetadata:
+ return self.metadata
+
+ def get_elements(self) -> List[E]:
+ return self.elements
+
+ def get_element_end_date_at(self, index: int) -> datetime:
+ return self.metadata.get_profile_start_at_timestep(index + 1)
+
+ def index_at(self, date: datetime) -> int:
+ return self.metadata.get_starting_step_nr(date)
+
+ def element_at(self, date: datetime) -> E:
+ index = self.index_at(date)
+ if index >= 0:
+ return self.elements[index]
+ raise ValueError(f"No element found at date - index {index} is invalid")
+
+ @staticmethod
+ def next_aligned_date(date: datetime, time_step_duration: timedelta) -> datetime:
+ time_step_duration_ms = time_step_duration.total_seconds() * 1000
+ ms_since_last_aligned_date = (date.timestamp() * 1000) % time_step_duration_ms
+
+ if ms_since_last_aligned_date == 0:
+ return date
+ else:
+ return date + timedelta(
+ milliseconds=(time_step_duration_ms - ms_since_last_aligned_date)
+ )
+
+ @staticmethod
+ def start_of_current_aligned_date(
+ date: datetime, time_step_duration: timedelta
+ ) -> datetime:
+ ms_since_last_aligned_date = (
+ (date.timestamp() * 1000) % time_step_duration.total_seconds() * 1000
+ )
+ if ms_since_last_aligned_date == 0:
+ return date
+ else:
+ return datetime.fromtimestamp(
+ date.timestamp() - ms_since_last_aligned_date / 1000
+ )
+
+ @staticmethod
+ def end_of_current_aligned_date(
+ date: datetime, time_step_duration: timedelta
+ ) -> datetime:
+ return (
+ AbstractProfile.start_of_current_aligned_date(date, time_step_duration)
+ + time_step_duration
+ )
+
+ @staticmethod
+ def get_start_of_day(date: datetime) -> datetime:
+ return datetime.combine(date.date(), datetime.min.time())
+
+ @abstractmethod
+ def subprofile(self, new_start_date: datetime) -> PT:
+ pass
+
+ @abstractmethod
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> PT:
+ pass
+
+ @abstractmethod
+ def is_compatible(self, other: PT) -> bool:
+ return self.metadata == other.metadata
+
+ @abstractmethod
+ def default_value(self) -> E:
+ pass
diff --git a/flexmeasures_s2/profile_steering/common/device_planner/__init__.py b/flexmeasures_s2/profile_steering/common/device_planner/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/flexmeasures_s2/profile_steering/common/device_planner/device_plan.py b/flexmeasures_s2/profile_steering/common/device_planner/device_plan.py
new file mode 100644
index 0000000..61d7d35
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/device_planner/device_plan.py
@@ -0,0 +1,48 @@
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+from flexmeasures_s2.profile_steering.common.soc_profile import SoCProfile
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_instruction_profile import (
+ S2FrbcInstructionProfile,
+)
+
+
+class DevicePlan:
+ def __init__(
+ self,
+ device_id: str,
+ device_name: str,
+ connection_id: str,
+ energy_profile: JouleProfile,
+ fill_level_profile: SoCProfile,
+ instruction_profile: S2FrbcInstructionProfile,
+ ):
+ self.device_id = device_id
+ self.device_name = device_name
+ self.connection_id = connection_id
+ self.energy_profile = energy_profile
+ self.fill_level_profile = fill_level_profile
+ self.instruction_profile = instruction_profile
+
+ 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_energy_profile(self) -> JouleProfile:
+ return self.energy_profile
+
+ def get_fill_level_profile(self) -> SoCProfile:
+ return self.fill_level_profile
+
+ def get_instruction_profile(self) -> S2FrbcInstructionProfile:
+ return self.instruction_profile
+
+ def __str__(self) -> str:
+ return (
+ f"DevicePlan(device_id={self.device_id}, device_name={self.device_name}, "
+ f"connection_id={self.connection_id}, energy_profile={self.energy_profile}, "
+ f"fill_level_profile={self.fill_level_profile}, instruction_profile={self.instruction_profile})"
+ )
diff --git a/flexmeasures_s2/profile_steering/common/device_planner/device_planner.py b/flexmeasures_s2/profile_steering/common/device_planner/device_planner.py
new file mode 100644
index 0000000..2dc36c6
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/device_planner/device_planner.py
@@ -0,0 +1,79 @@
+from datetime import datetime
+from typing import Optional
+from abc import ABC, abstractmethod
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+from flexmeasures_s2.profile_steering.common.proposal import Proposal
+
+
+class DevicePlanner(ABC):
+ @abstractmethod
+ def get_device_id(self) -> str:
+ """Get the device ID."""
+ pass
+
+ @abstractmethod
+ def get_device_name(self) -> str:
+ """Get the device name."""
+ pass
+
+ @abstractmethod
+ def get_connection_id(self) -> str:
+ """Get the connection ID."""
+ pass
+
+ @abstractmethod
+ def create_improved_planning(
+ self,
+ cluster_diff_profile: JouleProfile,
+ diff_to_max_profile: JouleProfile,
+ diff_to_min_profile: JouleProfile,
+ plan_due_by_date: datetime,
+ ) -> Proposal:
+ """Create an improved planning proposal.
+
+ Args:
+ cluster_diff_profile: The difference profile for the cluster
+ diff_to_max_profile: The difference to the maximum profile
+ diff_to_min_profile: The difference to the minimum profile
+ plan_due_by_date: The date by which the plan must be ready
+
+ Returns:
+ A Proposal object representing the improved plan
+ """
+ pass
+
+ @abstractmethod
+ def create_initial_planning(self, plan_due_by_date: datetime) -> JouleProfile:
+ """Create an initial planning profile.
+
+ Args:
+ plan_due_by_date: The date by which the plan must be ready
+
+ Returns:
+ A JouleProfile representing the initial plan
+ """
+ pass
+
+ @abstractmethod
+ def accept_proposal(self, accepted_proposal: Proposal):
+ """Accept a proposal and update the device's planning.
+
+ Args:
+ accepted_proposal: The proposal to accept
+ """
+ pass
+
+ @abstractmethod
+ def get_current_profile(self) -> JouleProfile:
+ """Get the current profile of the device."""
+ pass
+
+ @abstractmethod
+ def get_device_plan(self) -> Optional[JouleProfile]:
+ """Get the device plan."""
+ pass
+
+ @abstractmethod
+ def get_priority_class(self) -> int:
+ """Get the priority class of the device."""
+ pass
diff --git a/flexmeasures_s2/profile_steering/common/joule_profile.py b/flexmeasures_s2/profile_steering/common/joule_profile.py
new file mode 100644
index 0000000..a9261f8
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/joule_profile.py
@@ -0,0 +1,186 @@
+from datetime import datetime, timedelta
+from typing import List, Optional, Union
+from flexmeasures_s2.profile_steering.common.abstract_profile import AbstractProfile
+from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata
+
+
+class JouleProfile(AbstractProfile[int, "JouleProfile"]):
+ def __init__(
+ self,
+ profile_start: Optional[datetime] = None,
+ timestep_duration: Optional[timedelta] = None,
+ elements: Optional[List[int]] = None,
+ metadata: Optional[ProfileMetadata] = None,
+ value: Optional[int] = None,
+ profile_length: Optional[int] = None,
+ other_profile: Optional["JouleProfile"] = None,
+ ):
+ """
+ Initialize a JouleProfile with various parameter combinations.
+
+ Args:
+ profile_start: Start time of the profile
+ timestep_duration: Duration of each timestep
+ elements: List of energy values
+ metadata: ProfileMetadata object containing start time and duration
+ value: Single value to fill the entire profile
+ profile_length: Length of the profile when using a single value
+ other_profile: Another JouleProfile to copy from
+ """
+ # Case 1: Copy from another profile
+ if other_profile is not None:
+ super().__init__(
+ profile_metadata=other_profile.metadata, elements=other_profile.elements.copy()
+ )
+ return
+
+ # Case 2: Initialize from metadata
+ if metadata is not None:
+ if elements is not None:
+ super().__init__(profile_metadata=metadata, elements=elements)
+ elif value is not None:
+ elements = [value] * metadata.nr_of_timesteps
+ super().__init__(profile_metadata=metadata, elements=elements)
+ else:
+ super().__init__(profile_metadata=metadata, elements=[])
+ return
+
+ # Case 3: Initialize with single value and profile length
+ if value is not None and profile_length is not None:
+ elements = [value] * profile_length
+ super().__init__(profile_start=profile_start, timestep_duration=timestep_duration, elements=elements)
+ return
+
+ # Case 4: Basic initialization
+ if profile_start is not None and timestep_duration is not None:
+ super().__init__(
+ profile_start=profile_start,
+ timestep_duration=timestep_duration,
+ elements=elements if elements is not None else [],
+ )
+ return
+
+ # Case 5: Empty initialization (for serialization)
+ super().__init__()
+
+ def validate(self, profile_metadata: ProfileMetadata, elements: List[int]):
+ super().validate(profile_metadata, elements)
+ # Add any JouleProfile-specific validation here if needed
+
+ def default_value(self) -> int:
+ return 0
+
+ def subprofile(self, new_start_date: datetime) -> "JouleProfile":
+ index = self.index_at(new_start_date)
+ if index < 0:
+ raise ValueError("New start date is outside profile range")
+ new_elements = self.elements[index:]
+ return JouleProfile(new_start_date, self.metadata.timestep_duration, new_elements)
+
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> "JouleProfile":
+ if nr_of_elements < len(self.elements):
+ new_elements = self.elements[:nr_of_elements]
+ else:
+ new_elements = self.elements + [0] * (nr_of_elements - len(self.elements))
+ return JouleProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ new_elements,
+ )
+
+ def is_compatible(self, other: AbstractProfile) -> bool:
+ return self.metadata == other.metadata
+
+ def avg_power_at(self, date: datetime) -> Optional[float]:
+ element = self.element_at(date)
+ return element / self.metadata.timestep_duration.total_seconds()
+
+ def add(self, other: "JouleProfile") -> "JouleProfile":
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ summed_elements = [0] * len(self.elements)
+ for i in range(len(self.elements)):
+ if self.elements[i] is None or other.elements[i] is None:
+ summed_elements[i] = self.default_value()
+ else:
+ summed_elements[i] = self.elements[i] + other.elements[i]
+ return JouleProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ summed_elements,
+ )
+
+ def subtract(self, other: "JouleProfile") -> "JouleProfile":
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ diff_elements = [0] * len(self.elements)
+ for i in range(len(self.elements)):
+ if self.elements[i] is None or other.elements[i] is None:
+ diff_elements[i] = self.default_value()
+ else:
+ diff_elements[i] = self.elements[i] - other.elements[i]
+ return JouleProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ diff_elements,
+ )
+
+ def absolute_values(self) -> "JouleProfile":
+ abs_elements = [abs(e) for e in self.elements]
+ return JouleProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ abs_elements,
+ )
+
+ def sum_quadratic_distance(self) -> float:
+ return sum(e**2 for e in self.elements if e is not None)
+
+ def is_below_or_equal(self, other: "JouleProfile") -> bool:
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ return all(a <= b for a, b in zip(self.elements, other.elements) if b is not None)
+
+ def is_above_or_equal(self, other: "JouleProfile") -> bool:
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ return all(a >= b for a, b in zip(self.elements, other.elements) if b is not None)
+
+ def minimum(self, other: "JouleProfile") -> "JouleProfile":
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ # skip None values
+ min_elements = [min(a, b) for a, b in zip(self.elements, other.elements) if a is not None and b is not None]
+ return JouleProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ min_elements,
+ )
+
+ def maximum(self, other: "JouleProfile") -> "JouleProfile":
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ # skip None values
+ max_elements = [max(a, b) for a, b in zip(self.elements, other.elements) if a is not None and b is not None]
+ return JouleProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ max_elements,
+ )
+
+ def get_total_energy(self) -> int:
+ return sum(self.elements)
+
+ def get_total_energy_production(self) -> int:
+ return sum(min(0, e) for e in self.elements)
+
+ def get_total_energy_consumption(self) -> int:
+ return sum(max(0, e) for e in self.elements)
+
+ def get_energy_for_timestep(self, index: int) -> Optional[int]:
+ if 0 <= index < len(self.elements):
+ return self.elements[index]
+ return None
+
+ def __str__(self) -> str:
+ return f"JouleProfile(elements={self.elements}, profile_start={self.metadata.profile_start}, timestep_duration={self.metadata.timestep_duration})"
diff --git a/flexmeasures_s2/profile_steering/common/joule_range_profile.py b/flexmeasures_s2/profile_steering/common/joule_range_profile.py
new file mode 100644
index 0000000..ac86e03
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/joule_range_profile.py
@@ -0,0 +1,204 @@
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import List, Optional, TypeVar, Union, Tuple
+
+from flexmeasures_s2.profile_steering.common.abstract_profile import AbstractProfile
+from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+
+
+@dataclass
+class Element:
+ """
+ Represents an element in a JouleRangeProfile with min and max joule values.
+ Both values can be None, representing unbounded values.
+ """
+
+ min_joule: Optional[int] = None
+ max_joule: Optional[int] = None
+
+ # Class constant for NULL element
+ NULL = None # Will be set after class definition
+
+ def __eq__(self, other):
+ if not isinstance(other, Element):
+ return False
+ return self.min_joule == other.min_joule and self.max_joule == other.max_joule
+
+ def __str__(self):
+ return f"Element(min_joule={self.min_joule}, max_joule={self.max_joule})"
+
+
+# Set the NULL element after class definition
+Element.NULL = Element(None, None)
+
+
+class JouleRangeProfile(AbstractProfile[Element, "JouleRangeProfile"]):
+ """
+ A profile containing elements with minimum and maximum joule values.
+ This is the Python implementation of the Java JouleRangeProfile class.
+ """
+
+ def __init__(
+ self,
+ profile_start: Union[datetime, ProfileMetadata],
+ timestep_duration: Optional[timedelta] = None,
+ elements: Optional[List[Element]] = None,
+ min_value: Optional[int] = None,
+ max_value: Optional[int] = None,
+ nr_of_timesteps: Optional[int] = None,
+ ):
+ """
+ Initialize a JouleRangeProfile with various constructor options.
+
+ Args:
+ profile_start: Either a datetime representing profile start or a ProfileMetadata
+ timestep_duration: Duration of each timestep (if profile_start is datetime)
+ elements: List of Element objects with min/max joule values
+ min_value: Optional default min value for all elements
+ max_value: Optional default max value for all elements
+ nr_of_timesteps: Number of timesteps for the profile
+ """
+ if isinstance(profile_start, ProfileMetadata):
+ metadata = profile_start
+ if nr_of_timesteps is None:
+ nr_of_timesteps = metadata.nr_of_timesteps
+
+ elements = self._create_element_array(nr_of_timesteps, min_value, max_value)
+
+ else:
+ if elements is not None:
+ metadata = ProfileMetadata(profile_start, timestep_duration or timedelta(), len(elements))
+ elif nr_of_timesteps is not None:
+ metadata = ProfileMetadata(profile_start, timestep_duration or timedelta(), nr_of_timesteps)
+ elements = self._create_element_array(nr_of_timesteps, min_value, max_value)
+ else:
+ metadata = ProfileMetadata(profile_start, timestep_duration or timedelta(), 0)
+ elements = []
+
+ super().__init__(metadata, elements)
+
+ def _create_element_array(
+ self, nr_of_timesteps: int, min_value: Optional[int], max_value: Optional[int]
+ ) -> List[Element]:
+ if min_value is None and max_value is None:
+ return [Element(None, None)] * nr_of_timesteps
+ return [Element(min_value, max_value)] * nr_of_timesteps
+
+ def validate(self, profile_metadata: ProfileMetadata, elements: List[Element]):
+ """Validate the elements and metadata for this profile."""
+ super().validate(profile_metadata, elements)
+ # Add any JouleRangeProfile-specific validation here if needed
+
+ def default_value(self) -> Element:
+ return Element(None, None)
+
+ def subprofile(self, new_start_date: datetime) -> "JouleRangeProfile":
+ """
+ Create a subprofile starting from the given date.
+
+ Args:
+ new_start_date: Start date for the new profile
+
+ Returns:
+ New JouleRangeProfile starting from the given date
+ """
+ index = self.index_at(new_start_date)
+ if index < 0:
+ raise ValueError("New start date is outside profile range")
+ new_elements = self.elements[index:]
+ return JouleRangeProfile(new_start_date, self.metadata.timestep_duration, new_elements)
+
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> "JouleRangeProfile":
+ """
+ Adjust the number of elements in this profile.
+
+ Args:
+ nr_of_elements: New number of elements
+
+ Returns:
+ New JouleRangeProfile with adjusted number of elements
+ """
+ if nr_of_elements < len(self.elements):
+ new_elements = self.elements[:nr_of_elements]
+ else:
+ new_elements = self.elements + [Element(None, None)] * (nr_of_elements - len(self.elements))
+ return JouleRangeProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ new_elements,
+ )
+
+ def is_compatible(self, other: AbstractProfile) -> bool:
+ """
+ Check if this profile is compatible with another profile.
+
+ Args:
+ other: Another profile to check compatibility with
+
+ Returns:
+ True if compatible, False otherwise
+
+ """
+ return self.metadata.timestep_duration == other.metadata.timestep_duration and len(self.elements) == len(
+ other.elements
+ )
+
+ def get_energy_for_timestep(self, index: int) -> Optional[int]:
+ if 0 <= index < len(self.elements):
+ return self.elements[index].max_joule
+ return None
+
+ def get_min_energy_for_timestep(self, index: int) -> Optional[int]:
+ if 0 <= index < len(self.elements):
+ return self.elements[index].min_joule
+ return None
+
+ def get_max_energy_for_timestep(self, index: int) -> Optional[int]:
+ if 0 <= index < len(self.elements):
+ return self.elements[index].max_joule
+ return None
+
+ def add(self, other: "JouleRangeProfile") -> "JouleRangeProfile":
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ summed_elements = [Element(None, None)] * len(self.elements)
+ for i in range(len(self.elements)):
+ if self.elements[i].min_joule is None or other.elements[i].min_joule is None:
+ summed_elements[i] = Element(None, None)
+ else:
+ summed_elements[i] = Element(
+ self.elements[i].min_joule + other.elements[i].min_joule,
+ self.elements[i].max_joule + other.elements[i].max_joule,
+ )
+ return JouleRangeProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ summed_elements,
+ )
+
+ def subtract(self, other: "JouleRangeProfile") -> "JouleRangeProfile":
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ diff_elements = [Element(None, None)] * len(self.elements)
+ for i in range(len(self.elements)):
+ if self.elements[i].min_joule is None or other.elements[i].min_joule is None:
+ diff_elements[i] = Element(None, None)
+ else:
+ diff_elements[i] = Element(
+ self.elements[i].min_joule - other.elements[i].max_joule,
+ self.elements[i].max_joule - other.elements[i].min_joule,
+ )
+ return JouleRangeProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ diff_elements,
+ )
+
+ def __str__(self) -> str:
+ """Return a string representation of this profile."""
+ return (
+ f"JouleRangeProfile(elements={self.elements}, "
+ f"profile_start={self.metadata.profile_start}, "
+ f"timestep_duration={self.metadata.timestep_duration})"
+ )
diff --git a/flexmeasures_s2/profile_steering/common/profile_metadata.py b/flexmeasures_s2/profile_steering/common/profile_metadata.py
new file mode 100644
index 0000000..ebce1e1
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/profile_metadata.py
@@ -0,0 +1,105 @@
+from datetime import datetime, timedelta
+
+
+class ProfileMetadata:
+ NR_OF_TIMESTEPS_KEY = "nrOfTimesteps"
+ TIMESTEP_DURATION_KEY = "timestepDurationMs"
+ PROFILE_START_KEY = "profileStart"
+
+ def __init__(
+ self,
+ profile_start: datetime,
+ timestep_duration: timedelta,
+ nr_of_timesteps: int,
+ ):
+ self.profile_start = profile_start
+ self.timestep_duration = timestep_duration
+ self.nr_of_timesteps = nr_of_timesteps
+ self.profile_end = profile_start + timestep_duration * nr_of_timesteps
+ self.profile_duration = self.profile_end - self.profile_start
+
+ def get_profile_start(self) -> datetime:
+ return self.profile_start
+
+ def get_timestep_duration(self) -> timedelta:
+ return self.timestep_duration
+
+ def get_nr_of_timesteps(self) -> int:
+ return self.nr_of_timesteps
+
+ def get_profile_end(self) -> datetime:
+ return self.profile_end
+
+ def get_profile_duration(self) -> timedelta:
+ return self.profile_duration
+
+ def get_profile_start_at_timestep(self, i: int) -> datetime:
+ if i >= self.nr_of_timesteps or i < 0:
+ raise ValueError(f"Expected i to be between 0 <= i < {self.nr_of_timesteps} but was {i}")
+ return self.profile_start + self.timestep_duration * i
+
+ def get_starting_step_nr(self, instant: datetime) -> int:
+ if instant < self.profile_start:
+ return -1
+ elif instant >= self.profile_end:
+ return self.nr_of_timesteps
+ else:
+ duration_between_secs = (instant - self.profile_start).total_seconds()
+ timestep_duration_secs = self.timestep_duration.total_seconds()
+ return int(duration_between_secs // timestep_duration_secs)
+
+ def is_aligned_with(self, other: "ProfileMetadata") -> bool:
+ return (
+ self.timestep_duration == other.timestep_duration
+ and (
+ abs((self.profile_start - other.profile_start).total_seconds()) % self.timestep_duration.total_seconds()
+ )
+ == 0
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ self.PROFILE_START_KEY: int(self.profile_start.timestamp() * 1000),
+ self.TIMESTEP_DURATION_KEY: int(self.timestep_duration.total_seconds() * 1000),
+ self.NR_OF_TIMESTEPS_KEY: self.nr_of_timesteps,
+ }
+
+ @staticmethod
+ def from_dict(data: dict) -> "ProfileMetadata":
+ profile_start = datetime.fromtimestamp(data[ProfileMetadata.PROFILE_START_KEY] / 1000)
+ timestep_duration = timedelta(milliseconds=data[ProfileMetadata.TIMESTEP_DURATION_KEY])
+ nr_of_timesteps = data[ProfileMetadata.NR_OF_TIMESTEPS_KEY]
+ return ProfileMetadata(profile_start, timestep_duration, nr_of_timesteps)
+
+ def subprofile(self, new_start_date: datetime) -> "ProfileMetadata":
+ if new_start_date < self.profile_start:
+ raise ValueError("The new start date should be after the current start date")
+ new_start_date = self.next_aligned_date(new_start_date, self.timestep_duration)
+ skipped_steps = int(
+ (new_start_date - self.profile_start).total_seconds() // self.timestep_duration.total_seconds()
+ )
+ nr_of_elements = max(0, self.nr_of_timesteps - skipped_steps)
+ return ProfileMetadata(new_start_date, self.timestep_duration, nr_of_elements)
+
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> "ProfileMetadata":
+ return ProfileMetadata(self.profile_start, self.timestep_duration, nr_of_elements)
+
+ @staticmethod
+ def next_aligned_date(date: datetime, timestep_duration: timedelta) -> datetime:
+ # Align the date to the next timestep
+ remainder = (date - datetime.min).total_seconds() % timestep_duration.total_seconds()
+ if remainder == 0:
+ return date
+ return date + timedelta(seconds=(timestep_duration.total_seconds() - remainder))
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, ProfileMetadata):
+ return False
+ return (
+ self.nr_of_timesteps == other.nr_of_timesteps
+ and self.profile_start == other.profile_start
+ and self.timestep_duration == other.timestep_duration
+ )
+
+ def __hash__(self) -> int:
+ return hash((self.nr_of_timesteps, self.profile_start, self.timestep_duration))
diff --git a/flexmeasures_s2/profile_steering/common/proposal.py b/flexmeasures_s2/profile_steering/common/proposal.py
new file mode 100644
index 0000000..1a38627
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/proposal.py
@@ -0,0 +1,131 @@
+from typing import Optional
+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 Proposal:
+ def __init__(
+ self,
+ global_diff_target: TargetProfile,
+ diff_to_congestion_max: JouleProfile,
+ diff_to_congestion_min: JouleProfile,
+ proposed_plan: JouleProfile,
+ old_plan: JouleProfile,
+ origin: DevicePlanner,
+ ):
+ self.global_diff_target = global_diff_target
+ self.diff_to_congestion_max = diff_to_congestion_max
+ self.diff_to_congestion_min = diff_to_congestion_min
+ self.proposed_plan = proposed_plan
+ self.old_plan = old_plan
+ self.global_improvement_value: Optional[float] = None
+ self.congestion_improvement_value: Optional[float] = None
+ self.cost_improvement_value: Optional[float] = None
+ self.origin = origin
+
+ def get_global_improvement_value(self) -> float:
+ if self.global_improvement_value is None:
+ # print(f"self.old_plan: {self.old_plan}")
+ # print(f"self.proposed_plan: {self.proposed_plan}")
+ # print the quadratic distance of the global diff target
+ # print(f"self.global_diff_target.sum_quadratic_distance(): {self.global_diff_target.sum_quadratic_distance()}")
+ self.global_improvement_value = (
+ self.global_diff_target.sum_quadratic_distance()
+ - self.global_diff_target.add(self.old_plan)
+ .subtract(self.proposed_plan)
+ .sum_quadratic_distance()
+ )
+ 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
+
+ # @staticmethod
+ # def get_cost(plan: JouleProfile, target_profile: TargetProfile) -> float:
+ # cost = 0.0
+ # for i in range(target_profile.get_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
+
+ def get_congestion_improvement_value(self) -> float:
+ if self.congestion_improvement_value is None:
+ zero_profile = JouleProfile(
+ self.old_plan.get_profile_metadata().get_profile_start(),
+ self.old_plan.get_profile_metadata().get_timestep_duration(),
+ [0] * len(self.old_plan.get_elements()),
+ )
+ exceed_max_target_old = self.diff_to_congestion_max.minimum(
+ zero_profile
+ ).sum_quadratic_distance()
+ # print(f"exceed_max_target_old: {exceed_max_target_old}")
+ # print(f"self.diff_to_congestion_max: {self.diff_to_congestion_max}")
+ exceed_max_target_proposal = (
+ self.diff_to_congestion_max.add(self.old_plan)
+ .subtract(self.proposed_plan)
+ .minimum(zero_profile)
+ .sum_quadratic_distance()
+ )
+ # print(f"exceed_max_target_proposal: {exceed_max_target_proposal}")
+ exceed_min_target_old = self.diff_to_congestion_min.maximum(
+ zero_profile
+ ).sum_quadratic_distance()
+ # print(f"exceed_min_target_old: {exceed_min_target_old}")
+ exceed_min_target_proposal = (
+ self.diff_to_congestion_min.add(self.old_plan)
+ .subtract(self.proposed_plan)
+ .maximum(zero_profile)
+ .sum_quadratic_distance()
+ )
+ # print(f"exceed_min_target_proposal: {exceed_min_target_proposal}")
+ if (
+ exceed_max_target_old == exceed_max_target_proposal
+ and exceed_min_target_old == exceed_min_target_proposal
+ ):
+ self.congestion_improvement_value = 0.0
+ else:
+ self.congestion_improvement_value = (
+ exceed_max_target_old
+ + exceed_min_target_old
+ - exceed_max_target_proposal
+ - exceed_min_target_proposal
+ )
+ # print(f"congestion_improvement_value: {self.congestion_improvement_value}")
+ return self.congestion_improvement_value
+
+ def is_preferred_to(self, other: "Proposal") -> bool:
+ if self.get_congestion_improvement_value() >= 0:
+ if (
+ self.get_global_improvement_value()
+ > other.get_global_improvement_value()
+ ):
+ return True
+ elif (
+ self.get_global_improvement_value()
+ == other.get_global_improvement_value()
+ # and self.get_cost_improvement_value()
+ # > other.get_cost_improvement_value()
+ ):
+ return True
+ return False
+
+ def get_origin(self) -> DevicePlanner:
+ return self.origin
+
+ def get_proposed_plan(self) -> JouleProfile:
+ return self.proposed_plan
+
+ def get_old_plan(self) -> JouleProfile:
+ return self.old_plan
+
+ def get_energy(self) -> JouleProfile:
+ return self.proposed_plan
diff --git a/flexmeasures_s2/profile_steering/common/s2_actuator_configuration.py b/flexmeasures_s2/profile_steering/common/s2_actuator_configuration.py
new file mode 100644
index 0000000..b70b0c9
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/s2_actuator_configuration.py
@@ -0,0 +1,20 @@
+class S2ActuatorConfiguration:
+ def __init__(self, operation_mode_id, factor):
+ self.operation_mode_id = operation_mode_id
+ self.factor = factor
+
+ def get_operation_mode_id(self):
+ return self.operation_mode_id
+
+ def get_factor(self):
+ return self.factor
+
+ def to_dict(self):
+ return {"operationModeId": self.operation_mode_id, "factor": self.factor}
+
+ @staticmethod
+ def from_dict(data):
+ return S2ActuatorConfiguration(data["operationModeId"], data["factor"])
+
+ def __str__(self):
+ return f"S2ActuatorConfiguration [operationModeId={self.operation_mode_id}, factor={self.factor}]"
diff --git a/flexmeasures_s2/profile_steering/common/soc_profile.py b/flexmeasures_s2/profile_steering/common/soc_profile.py
new file mode 100644
index 0000000..32b1f8e
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/soc_profile.py
@@ -0,0 +1,45 @@
+from typing import List, Optional
+from flexmeasures_s2.profile_steering.common.abstract_profile import AbstractProfile
+from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata
+from datetime import datetime
+
+
+class SoCProfile(AbstractProfile[float, "SoCProfile"]):
+ def __init__(self, profile_metadata: ProfileMetadata, elements: Optional[List[float]] = None):
+ self.profile_metadata = profile_metadata
+ self.timestep_duration = self.profile_metadata.timestep_duration
+ self.profile_start = self.profile_metadata.profile_start
+ self.profile_end = self.profile_metadata.profile_end
+ super().__init__(self.profile_metadata, elements if elements is not None else [])
+
+ def default_value(self) -> Optional[float]:
+ return None
+
+ def __str__(self) -> str:
+ return f"SoCProfile(elements={self.elements}, profile_start={self.profile_start}, timestep_duration={self.timestep_duration})"
+
+ def is_compatible(self, other: AbstractProfile) -> bool:
+ return self.metadata.timestep_duration == other.metadata.timestep_duration and len(self.elements) == len(
+ other.elements
+ )
+
+ def validate(self, profile_metadata: ProfileMetadata, elements: List[float]):
+ super().validate(profile_metadata, elements)
+
+ def subprofile(self, new_start_date: datetime) -> "SoCProfile":
+ index = self.index_at(new_start_date)
+ if index < 0:
+ raise ValueError("New start date is outside profile range")
+ new_elements = self.elements[index:]
+ return SoCProfile(new_start_date, self.metadata.timestep_duration, new_elements)
+
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> "SoCProfile":
+ if nr_of_elements < len(self.elements):
+ new_elements = self.elements[:nr_of_elements]
+ else:
+ new_elements = self.elements + [0.0] * (nr_of_elements - len(self.elements))
+ return SoCProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ new_elements,
+ )
diff --git a/flexmeasures_s2/profile_steering/common/target_profile.py b/flexmeasures_s2/profile_steering/common/target_profile.py
new file mode 100644
index 0000000..b82eab3
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common/target_profile.py
@@ -0,0 +1,150 @@
+from typing import List, Union
+from datetime import datetime, timedelta
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+from flexmeasures_s2.profile_steering.common.abstract_profile import AbstractProfile
+from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata
+
+
+class TargetProfile(AbstractProfile[Union["TargetProfile.Element", None], "TargetProfile"]):
+ class Element:
+ pass
+
+ class JouleElement(Element):
+ def __init__(self, joules: int):
+ self.joules = joules
+
+ def get_joules(self) -> int:
+ return self.joules
+
+ class NullElement(Element):
+ pass
+
+ NULL_ELEMENT: "TargetProfile.Element" = NullElement()
+
+ def __init__(
+ self,
+ profile_start: datetime,
+ timestep_duration: timedelta,
+ elements: List["TargetProfile.Element | None"],
+ ):
+ metadata = ProfileMetadata(profile_start, timestep_duration, len(elements))
+ super().__init__(metadata, elements)
+ # if elements is a list of ints, convert it to a list of JouleElement
+ if isinstance(elements, list) and all(isinstance(e, int) for e in elements):
+ self.elements = [TargetProfile.JouleElement(e) for e in elements]
+ else:
+ self.elements = elements
+
+ def validate(
+ self,
+ profile_metadata: ProfileMetadata,
+ elements: List["TargetProfile.Element | None"],
+ ):
+ super().validate(profile_metadata, elements)
+ # Add any TargetProfile-specific validation if needed
+
+ def default_value(self) -> "TargetProfile.Element":
+ return TargetProfile.NULL_ELEMENT
+
+ def subprofile(self, new_start_date: datetime) -> "TargetProfile":
+ index = self.index_at(new_start_date)
+ if index < 0:
+ raise ValueError("New start date is outside profile range")
+ new_elements = self.elements[index:]
+ return TargetProfile(new_start_date, self.metadata.timestep_duration, new_elements)
+
+ def adjust_nr_of_elements(self, nr_of_elements: int) -> "TargetProfile":
+ if nr_of_elements < len(self.elements):
+ new_elements = self.elements[:nr_of_elements]
+ else:
+ new_elements = self.elements + [self.default_value()] * (nr_of_elements - len(self.elements))
+ return TargetProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ new_elements,
+ )
+
+ def is_compatible(self, other: AbstractProfile) -> bool:
+ return self.metadata.timestep_duration == other.metadata.timestep_duration and len(self.elements) == len(
+ other.elements
+ )
+
+ def get_total_energy(self) -> int:
+ return sum(e.get_joules() for e in self.elements if isinstance(e, TargetProfile.JouleElement))
+
+ def target_elements_to_joule_profile(self) -> JouleProfile:
+ joules = [e.get_joules() for e in self.elements if isinstance(e, TargetProfile.JouleElement)]
+ return JouleProfile(
+ self.metadata.profile_start,
+ self.metadata.timestep_duration,
+ joules,
+ )
+
+ def nr_of_joule_target_elements(self) -> int:
+ return len([e for e in self.elements if isinstance(e, TargetProfile.JouleElement)])
+
+ def subtract(self, other: JouleProfile) -> "TargetProfile":
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ diff_elements: List["TargetProfile.Element | None"] = []
+ for i, element in enumerate(self.elements):
+ if isinstance(element, TargetProfile.JouleElement):
+ other_energy = other.get_energy_for_timestep(i)
+ if other_energy is not None:
+ diff_elements.append(TargetProfile.JouleElement(element.get_joules() - other_energy))
+ else:
+ diff_elements.append(self.NULL_ELEMENT)
+ else:
+ diff_elements.append(self.NULL_ELEMENT)
+ return TargetProfile(
+ self.metadata.get_profile_start(),
+ self.metadata.get_timestep_duration(),
+ diff_elements,
+ )
+
+ def add(self, other: JouleProfile) -> "TargetProfile":
+ if not self.is_compatible(other):
+ raise ValueError("Profiles are not compatible")
+ sum_elements: List["TargetProfile.Element | None"] = []
+ for i, element in enumerate(self.elements):
+ if isinstance(element, TargetProfile.JouleElement):
+ other_energy = other.get_energy_for_timestep(i)
+ if other_energy is not None:
+ sum_elements.append(TargetProfile.JouleElement(element.get_joules() + other_energy))
+ else:
+ sum_elements.append(self.NULL_ELEMENT)
+ else:
+ sum_elements.append(self.NULL_ELEMENT)
+ return TargetProfile(
+ self.metadata.get_profile_start(),
+ self.metadata.get_timestep_duration(),
+ sum_elements,
+ )
+
+ def sum_quadratic_distance(self) -> float:
+ return sum(e.get_joules() ** 2 for e in self.elements if isinstance(e, TargetProfile.JouleElement))
+
+ def __str__(self) -> str:
+ return f"TargetProfile(elements={self.elements}, profile_start={self.metadata.profile_start}, timestep_duration={self.metadata.timestep_duration})"
+
+ def get_profile_metadata(self) -> ProfileMetadata:
+ return self.metadata
+
+ def get_elements(self) -> List["TargetProfile.Element | None"]:
+ return self.elements
+
+ @staticmethod
+ def null_profile(metadata: ProfileMetadata) -> "TargetProfile":
+ return TargetProfile(
+ metadata.profile_start,
+ metadata.timestep_duration,
+ [TargetProfile.NULL_ELEMENT] * metadata.nr_of_timesteps,
+ )
+
+ @staticmethod
+ def from_joule_profile(joule_profile: JouleProfile) -> "TargetProfile":
+ return TargetProfile(
+ joule_profile.metadata.profile_start,
+ joule_profile.metadata.timestep_duration,
+ [TargetProfile.JouleElement(e) for e in joule_profile.elements],
+ )
diff --git a/flexmeasures_s2/profile_steering/common_data_structures.py b/flexmeasures_s2/profile_steering/common_data_structures.py
new file mode 100644
index 0000000..3a67895
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/common_data_structures.py
@@ -0,0 +1,52 @@
+from datetime import datetime
+from typing import List, Dict, Any, Optional
+
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+
+
+class ClusterState:
+ """Class representing the state of a cluster of devices."""
+
+ def __init__(self, timestamp: datetime, device_states: Dict[str, Any] = None, congestion_points_by_connection_id: Dict[str, str] = None):
+ self._timestamp = timestamp
+ self._device_states = device_states or {}
+ self._congestion_points_by_connection_id = congestion_points_by_connection_id or {}
+
+ def set_device_states(self, device_states: Dict[str, Any] = None):
+ self._device_states = device_states or {}
+
+ def get_device_states(self) -> Dict[str, Any]:
+ return self._device_states
+
+ def get_congestion_point(self, connection_id: str) -> Optional[str]:
+ return self._congestion_points_by_connection_id.get(connection_id)
+
+ def set_congestion_point(self, connection_id: str, congestion_point_id: str):
+ self._congestion_points_by_connection_id[connection_id] = congestion_point_id
+
+ def get_congestion_points(self) -> List[str]:
+ return list(set(self._congestion_points_by_connection_id.values()))
+
+
+class DevicePlan:
+ """Class representing a plan for a device."""
+
+ def __init__(self, device_id: str, profile: JouleProfile):
+ self._device_id = device_id
+ self._profile = profile
+
+ def get_device_id(self) -> str:
+ return self._device_id
+
+ def get_connection_id(self) -> str:
+ """Get the connection ID for this device plan.
+
+ This is often the same as the device ID but might be different in some cases.
+
+ Returns:
+ The connection ID
+ """
+ return self._device_id
+
+ def get_profile(self) -> JouleProfile:
+ return self._profile
diff --git a/flexmeasures_s2/profile_steering/congestion_point_planner.py b/flexmeasures_s2/profile_steering/congestion_point_planner.py
new file mode 100644
index 0000000..c509101
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/congestion_point_planner.py
@@ -0,0 +1,245 @@
+from datetime import datetime
+from typing import List, Optional
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+from flexmeasures_s2.profile_steering.common.joule_range_profile import (
+ JouleRangeProfile,
+)
+from flexmeasures_s2.profile_steering.common.proposal import Proposal
+from flexmeasures_s2.profile_steering.device_planner.device_planner_abstract import (
+ DevicePlanner,
+)
+
+
+class CongestionPointPlanner:
+ def __init__(self, congestion_point_id: str, congestion_target: JouleRangeProfile):
+ """Initialize a congestion point planner.
+
+ Args:
+
+ congestion_point_id: Unique identifier for this congestion point
+ congestion_target: Target profile with range constraints for this congestion point
+ """
+ self.MAX_ITERATIONS = 1000
+ self.congestion_point_id = congestion_point_id
+ self.congestion_target = congestion_target
+ self.profile_metadata = congestion_target.metadata
+
+ # Create an empty profile (using all zeros)
+ self.empty_profile = JouleProfile(
+ self.profile_metadata.profile_start,
+ self.profile_metadata.timestep_duration,
+ elements=[0] * self.profile_metadata.nr_of_timesteps,
+ )
+
+ # List of device controllers that can be used for planning
+ self.devices: List[DevicePlanner] = []
+
+ # Keep track of accepted and latest plans
+ self.accepted_plan = self.empty_profile
+ self.latest_plan = self.empty_profile
+
+ def add_device(self, device):
+ """Add a device controller to this congestion point."""
+ self.devices.append(device)
+
+ @staticmethod
+ def is_storage_available(self) -> bool:
+ """Check if storage is available at this congestion point."""
+ # For now, always assume storage is available
+ return True
+
+ def create_initial_planning(self, plan_due_by_date: datetime) -> JouleProfile:
+ """Create an initial plan for this congestion point.
+
+ Args:
+ plan_due_by_date: The date by which the plan must be ready
+
+ Returns:
+ A JouleProfile representing the initial plan
+ """
+ current_planning = self.empty_profile
+
+ # Aggregate initial plans from all devices
+ for device in self.devices:
+ current_planning = current_planning.add(
+ device.create_initial_planning(plan_due_by_date)
+ )
+ # print(f"Initial planning after adding device {device.get_device_id()}: {current_planning}")
+
+
+ # Check if the current planning is within the congestion target range
+ if self.congestion_target.is_within_range(current_planning):
+ print(f"Current planning is within the congestion target range. Returning it.")
+ return current_planning
+
+ # If the current planning is not within the congestion target range, optimize it
+ print(f"Current planning is not within the congestion target range. Optimizing it.")
+
+ # print(f"Congestion target: {self.congestion_target}")
+ i = 0
+ best_proposal = None
+
+ max_priority_class = self.max_priority_class()
+ min_priority_class = self.min_priority_class()
+
+ # Iterate over priority classes
+ for priority_class in range(min_priority_class, max_priority_class + 1):
+ print(f"Optimizing priority class: {priority_class}")
+ while True:
+ best_proposal = None
+ diff_to_max = self.congestion_target.difference_with_max_value(
+ current_planning
+ )
+ diff_to_min = self.congestion_target.difference_with_min_value(
+ current_planning
+ )
+
+ # Try to get improved plans from each device controller
+ for device in self.devices:
+ if device.get_priority_class() <= priority_class:
+ try:
+ proposal = device.create_improved_planning(
+ self.empty_profile, # Assuming empty global target
+ diff_to_max,
+ diff_to_min,
+ plan_due_by_date,
+ )
+ print(
+ f"congestion point improvement for '{device.get_device_id()}': {proposal.get_congestion_improvement_value()}"
+ )
+ if (
+ best_proposal is None
+ or proposal.get_congestion_improvement_value()
+ > best_proposal.get_congestion_improvement_value()
+ ):
+ best_proposal = proposal
+ except Exception as e:
+ print(
+ f"Error getting proposal from device {device.get_device_id()}: {e}"
+ )
+ continue
+
+ if (
+ best_proposal is None
+ or best_proposal.get_congestion_improvement_value() <= 0
+ ):
+ break
+
+ # Update the current planning based on the best proposal
+ current_planning = current_planning.subtract(
+ best_proposal.get_old_plan()
+ ).add(best_proposal.get_proposed_plan())
+ best_proposal.get_origin().accept_proposal(best_proposal)
+ i += 1
+
+ print(
+ f"Initial planning '{self.congestion_point_id}': best controller '{best_proposal.get_origin().get_device_id()}' with congestion improvement of {best_proposal.get_congestion_improvement_value()}. Iteration {i}."
+ )
+
+ if i >= self.MAX_ITERATIONS:
+ break
+
+ return current_planning
+
+ def create_improved_planning(
+ self,
+ difference_profile: JouleProfile,
+ target_metadata: any,
+ priority_class: int,
+ plan_due_by_date: datetime,
+ ) -> Optional[Proposal]:
+ """Create an improved plan based on the difference profile.
+
+ Args:
+ difference_profile: The difference between target and current planning
+ target_metadata: Metadata about the target profile
+ priority_class: Priority class for this planning iteration
+ plan_due_by_date: The date by which the plan must be ready
+
+ Returns:
+ A Proposal object if an improvement was found, None otherwise
+ """
+ best_proposal = None
+
+ current_planning = self.get_current_planning()
+
+ diff_to_max_value = self.congestion_target.difference_with_max_value(
+ current_planning
+ )
+ diff_to_min_value = self.congestion_target.difference_with_min_value(
+ current_planning
+ )
+ # print(f"diff_to_max_value: {diff_to_max_value}")
+ # print(f"diff_to_min_value: {diff_to_min_value}")
+ # Try to get improved plans from each device controller
+ for device in self.devices:
+ if device.get_priority_class() <= priority_class:
+ try:
+ # Get an improved plan from this device
+ proposal = device.create_improved_planning(
+ difference_profile,
+ diff_to_max_value,
+ diff_to_min_value,
+ plan_due_by_date,
+ )
+ # print("Plans old vs New")
+ # print(f"device: {device.get_device_name()}")
+ # print(f"old: {proposal.get_old_plan()}")
+ # print(f"new: {proposal.get_proposed_plan()}")
+ if proposal.get_congestion_improvement_value() < 0:
+ print(
+ f"{device.get_device_name()}, congestion improvement: {proposal.get_congestion_improvement_value()}"
+ )
+
+ if best_proposal is None or proposal.is_preferred_to(best_proposal):
+ best_proposal = proposal
+ except Exception as e:
+ print(
+ f"Error getting proposal from device {device.get_device_id()}: {e}"
+ )
+
+ continue
+
+ if best_proposal is None:
+ print(
+ f"CP '{self.congestion_point_id}': No proposal available at current priority level ({priority_class})"
+ )
+ else:
+ if best_proposal.get_congestion_improvement_value() == float("-inf"):
+ raise ValueError(
+ "Invalid proposal with negative infinity improvement value"
+ )
+
+ print(
+ f"CP '{self.congestion_point_id}': Selected best controller '{best_proposal.get_origin().get_device_name()}' with improvement of {best_proposal.get_global_improvement_value()}."
+ )
+
+ return best_proposal
+
+ def get_current_planning(self) -> JouleProfile:
+ """Get the current planning profile."""
+ # Return the latest accepted plan as the current planning
+ current_planning = self.empty_profile
+ for device in self.devices:
+ current_planning = current_planning.add(device.get_current_profile())
+ return current_planning
+
+ def add_device_controller(self, device):
+ """Add a device controller to this congestion point."""
+ self.devices.append(device)
+
+ def get_device_controllers(self) -> List[DevicePlanner]:
+ """Get the list of device controllers."""
+ return self.devices
+
+ def max_priority_class(self) -> int:
+ """Get the maximum priority class among all devices."""
+ if not self.devices:
+ return 1
+ return max(device.get_priority_class() for device in self.devices)
+
+ def min_priority_class(self) -> int:
+ """Get the minimum priority class among all devices."""
+ if not self.devices:
+ return 1
+ return min(device.get_priority_class() for device in self.devices)
diff --git a/flexmeasures_s2/profile_steering/device_planner/__init__.py b/flexmeasures_s2/profile_steering/device_planner/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/flexmeasures_s2/profile_steering/device_planner/device_planner_abstract.py b/flexmeasures_s2/profile_steering/device_planner/device_planner_abstract.py
new file mode 100644
index 0000000..a5efb49
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/device_planner_abstract.py
@@ -0,0 +1,82 @@
+from datetime import datetime
+from typing import Optional
+from abc import ABC, abstractmethod
+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.common.device_planner.device_plan import (
+ DevicePlan,
+)
+
+
+class DevicePlanner(ABC):
+ @abstractmethod
+ def get_device_id(self) -> str:
+ """Get the device ID."""
+ pass
+
+ @abstractmethod
+ def get_device_name(self) -> str:
+ """Get the device name."""
+ pass
+
+ @abstractmethod
+ def get_connection_id(self) -> str:
+ """Get the connection ID."""
+ pass
+
+ @abstractmethod
+ def create_improved_planning(
+ self,
+ cluster_diff_profile: TargetProfile,
+ diff_to_max_profile: JouleProfile,
+ diff_to_min_profile: JouleProfile,
+ plan_due_by_date: datetime,
+ ) -> "Proposal":
+ """Create an improved planning proposal.
+
+ Args:
+ cluster_diff_profile: The difference profile for the cluster
+ diff_to_max_profile: The difference to the maximum profile
+ diff_to_min_profile: The difference to the minimum profile
+ plan_due_by_date: The date by which the plan must be ready
+
+ Returns:
+ A Proposal object representing the improved plan
+ """
+ pass
+
+ @abstractmethod
+ def create_initial_planning(self, plan_due_by_date: datetime) -> JouleProfile:
+ """Create an initial planning profile.
+
+ Args:
+ plan_due_by_date: The date by which the plan must be ready
+
+ Returns:
+ A JouleProfile representing the initial plan
+ """
+ pass
+
+ @abstractmethod
+ def accept_proposal(self, accepted_proposal: "Proposal"):
+ """Accept a proposal and update the device's planning.
+
+ Args:
+ accepted_proposal: The proposal to accept
+ """
+ pass
+
+ @abstractmethod
+ def get_current_profile(self) -> JouleProfile:
+ """Get the current profile of the device."""
+ pass
+
+ @abstractmethod
+ def get_device_plan(self) -> Optional[DevicePlan]:
+ """Get the device plan."""
+ pass
+
+ @abstractmethod
+ def get_priority_class(self) -> int:
+ """Get the priority class of the device."""
+ pass
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/fill_level_target_util.py b/flexmeasures_s2/profile_steering/device_planner/frbc/fill_level_target_util.py
new file mode 100644
index 0000000..bc101e0
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/fill_level_target_util.py
@@ -0,0 +1,55 @@
+from datetime import timedelta, timezone
+
+
+class FillLevelTargetElement:
+ def __init__(self, start, end, lower_limit, upper_limit):
+ self.start = start
+ self.end = end
+ self.lower_limit = lower_limit
+ self.upper_limit = upper_limit
+
+ def split(self, date):
+ if self.date_in_element(date):
+ return [
+ FillLevelTargetElement(
+ self.start, date, self.lower_limit, self.upper_limit
+ ),
+ FillLevelTargetElement(
+ date, self.end, self.lower_limit, self.upper_limit
+ ),
+ ]
+ return []
+
+ def date_in_element(self, date):
+ return self.start <= date <= self.end
+
+
+class FillLevelTargetUtil:
+ @staticmethod
+ 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)
+ elements.append(
+ FillLevelTargetElement(
+ start,
+ end,
+ element.fill_level_range.start_of_range,
+ element.fill_level_range.end_of_range,
+ )
+ )
+ start = end + timedelta(milliseconds=1)
+ return elements
+
+ @staticmethod
+ def get_elements_in_range(target_profile, start, end):
+ elements_in_range = []
+ start = start.replace(tzinfo=None)
+ end = end.replace(tzinfo=None)
+ for element in target_profile:
+ element_start = element.start.replace(tzinfo=None)
+ element_end = element.end.replace(tzinfo=None)
+ if element_start <= end and element_end >= start:
+ elements_in_range.append(element)
+ return elements_in_range
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_operation_mode_wrapper.py b/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_operation_mode_wrapper.py
new file mode 100644
index 0000000..7b353f9
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_operation_mode_wrapper.py
@@ -0,0 +1,74 @@
+from typing import List
+from flexmeasures_s2.profile_steering.s2_utils.number_range_wrapper import (
+ NumberRangeWrapper,
+)
+from s2python.frbc import FRBCOperationModeElement
+
+
+class FrbcOperationModeElementWrapper:
+ def __init__(self, frbc_operation_mode_element: FRBCOperationModeElement):
+ self.fill_rate = NumberRangeWrapper(
+ frbc_operation_mode_element.fill_rate.start_of_range,
+ frbc_operation_mode_element.fill_rate.end_of_range,
+ )
+ self.power_ranges = [
+ NumberRangeWrapper(pr.start_of_range, pr.end_of_range)
+ for pr in frbc_operation_mode_element.power_ranges
+ ]
+ self.fill_level_range = NumberRangeWrapper(
+ frbc_operation_mode_element.fill_level_range.start_of_range,
+ frbc_operation_mode_element.fill_level_range.end_of_range,
+ )
+ self.frbc_operation_mode_element = frbc_operation_mode_element
+
+ def get_fill_rate(self) -> NumberRangeWrapper:
+ return self.fill_rate
+
+ def get_power_ranges(self) -> List[NumberRangeWrapper]:
+ return self.power_ranges
+
+ def get_fill_level_range(self) -> NumberRangeWrapper:
+ return self.fill_level_range
+
+ def get_power_ranges(self):
+ return self.frbc_operation_mode_element.power_ranges
+
+
+class FrbcOperationModeWrapper:
+ def __init__(self, frbc_operation_mode):
+ self.id = frbc_operation_mode.id
+ self.diagnostic_label = frbc_operation_mode.diagnostic_label
+ self.abnormal_condition_only = frbc_operation_mode.abnormal_condition_only
+ self.elements = [
+ FrbcOperationModeElementWrapper(element)
+ for element in frbc_operation_mode.elements
+ ]
+ self.uses_factor = self.calculate_uses_factor()
+
+ def calculate_uses_factor(self) -> bool:
+ from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state_wrapper import (
+ S2FrbcDeviceStateWrapper,
+ )
+
+ for element in self.elements:
+ if (
+ abs(
+ element.fill_rate.start_of_range
+ - element.fill_rate.end_of_range
+ )
+ > S2FrbcDeviceStateWrapper.epsilon
+ ):
+ return True
+ for power_range in element.frbc_operation_mode_element.power_ranges:
+ if (
+ abs(power_range.start_of_range - power_range.end_of_range)
+ > S2FrbcDeviceStateWrapper.epsilon
+ ):
+ return True
+ return False
+
+ def get_elements(self) -> List[FrbcOperationModeElementWrapper]:
+ return self.elements
+
+ def is_uses_factor(self) -> bool:
+ return self.uses_factor
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_state.py b/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_state.py
new file mode 100644
index 0000000..39e6209
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_state.py
@@ -0,0 +1,501 @@
+from datetime import datetime
+from s2python.frbc import (
+ FRBCSystemDescription,
+)
+from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile
+from s2python.frbc.frbc_actuator_status import FRBCActuatorStatus
+import flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state_wrapper as s2_frbc_device_state_wrapper
+
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_actuator_configuration import (
+ S2ActuatorConfiguration,
+)
+import flexmeasures_s2.profile_steering.device_planner.frbc.frbc_timestep as frbc_timestep
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state import (
+ S2FrbcDeviceState,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.selection_reason_result import (
+ SelectionResult,
+ SelectionReason,
+)
+
+from typing import Dict, Optional, Any
+
+
+class FrbcState:
+ constraint_epsilon = 1e-4
+ tariff_epsilon = 0.5
+
+ def __init__(
+ self,
+ timestep,
+ present_fill_level: float,
+ device_state: Optional[S2FrbcDeviceState] = None,
+ previous_state: Optional["FrbcState"] = None,
+ actuator_configurations: Optional[Dict[str, S2ActuatorConfiguration]] = None,
+ ):
+ if previous_state:
+ if device_state:
+ self.device_state = (
+ s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper(device_state)
+ )
+ else:
+ self.device_state = previous_state.device_state
+ self.timestep = timestep
+ self.previous_state = previous_state
+ self.system_description = timestep.get_system_description()
+ self.fill_level = present_fill_level
+ self.bucket = 0
+ self.timestep_energy = 0.0
+ self.fill_level = previous_state.fill_level
+ seconds = self.timestep.get_duration_seconds()
+ for actuator_id, actuator_configuration in actuator_configurations.items():
+ om = self.device_state.get_operation_mode(
+ self.timestep,
+ actuator_id,
+ actuator_configuration.get_operation_mode_id(),
+ )
+ self.timestep_energy += (
+ self.device_state.get_operation_mode_power(
+ om,
+ previous_state.fill_level,
+ actuator_configuration.get_factor(),
+ )
+ * seconds
+ )
+
+ self.fill_level += (
+ self.device_state.get_operation_mode_fill_rate(
+ om,
+ previous_state.fill_level,
+ actuator_configuration.get_factor(),
+ )
+ * seconds
+ )
+ self.fill_level -= (
+ s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.get_leakage_rate(
+ self.timestep, self.fill_level
+ )
+ * seconds
+ )
+ self.fill_level += self.timestep.get_forecasted_usage()
+ self.bucket = (
+ s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.calculate_bucket(
+ self.timestep, self.fill_level
+ )
+ )
+ if (
+ previous_state.system_description.valid_from
+ == self.system_description.valid_from
+ ):
+ self.timer_elapse_map = previous_state.get_timer_elapse_map().copy()
+ for (
+ actuator_id,
+ actuator_configuration,
+ ) in actuator_configurations.items():
+ previous_operation_mode_id = (
+ previous_state.get_actuator_configurations()[
+ actuator_id
+ ].get_operation_mode_id()
+ )
+ new_operation_mode_id = (
+ actuator_configuration.get_operation_mode_id()
+ )
+ if previous_operation_mode_id != new_operation_mode_id:
+ transition = s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.get_transition(
+ self.timestep,
+ actuator_id,
+ previous_operation_mode_id,
+ new_operation_mode_id,
+ )
+ last_timer_id = None
+ new_finished_at = None
+ for timer_id in transition.start_timers:
+ duration = s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.get_timer_duration(
+ self.timestep, actuator_id, str(timer_id)
+ )
+ new_finished_at = self.timestep.get_start_date() + duration
+ last_timer_id = timer_id
+ if last_timer_id is not None:
+ key = FrbcState.timer_key(actuator_id, str(last_timer_id))
+ self.timer_elapse_map[key] = new_finished_at
+ else:
+ self.timer_elapse_map = (
+ self.get_initial_timer_elapse_map_for_system_description(
+ self.system_description
+ )
+ )
+ # calculate scores
+ target = self.timestep.get_target()
+ if isinstance(target, TargetProfile.JouleElement):
+ self.sum_squared_distance = (
+ previous_state.get_sum_squared_distance()
+ + (target.get_joules() - self.timestep_energy) ** 2
+ )
+ self.sum_energy_cost = previous_state.get_sum_energy_cost()
+ else:
+ self.sum_squared_distance = previous_state.get_sum_squared_distance()
+ self.sum_energy_cost = previous_state.get_sum_energy_cost()
+ squared_constraint_violation = (
+ previous_state.get_sum_squared_constraint_violation()
+ )
+ if self.timestep.get_max_constraint() is not None:
+ if self.timestep_energy > self.timestep.get_max_constraint():
+ squared_constraint_violation += (
+ self.timestep_energy - self.timestep.get_max_constraint()
+ ) ** 2
+ if self.timestep.get_min_constraint() is not None:
+ if self.timestep_energy < self.timestep.get_min_constraint():
+ squared_constraint_violation += (
+ self.timestep.get_min_constraint() - self.timestep_energy
+ ) ** 2
+ self.sum_squared_constraint_violation = (
+ previous_state.get_sum_squared_constraint_violation()
+ + squared_constraint_violation
+ )
+ self.sum_squared_energy = (
+ previous_state.get_sum_squared_energy() + self.timestep_energy**2
+ )
+
+ self.selection_reason: Optional[SelectionReason] = None
+ self.actuator_configurations = actuator_configurations or {}
+ for k in list(self.get_actuator_configurations().keys()):
+ if isinstance(k, str):
+ self.actuator_configurations[k] = self.actuator_configurations.pop(
+ k
+ )
+
+ self.timestep.add_state(self)
+
+ else:
+ self.device_state = s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper(
+ device_state
+ )
+ self.timestep = timestep
+ self.previous_state = None
+ self.system_description = timestep.get_system_description()
+ self.fill_level = present_fill_level
+ self.bucket = 0
+ self.timestep_energy = 0.0
+ self.sum_squared_distance = 0.0
+ self.sum_squared_constraint_violation = 0.0
+ self.sum_energy_cost = 0.0
+ self.sum_squared_energy = 0.0
+ self.selection_reason: Optional[SelectionReason] = None
+ self.actuator_configurations = {}
+ self.timer_elapse_map = (
+ self.get_initial_timer_elapse_map_for_system_description(
+ self.system_description
+ )
+ )
+ for a in self.device_state.get_actuator_statuses():
+ actuator_status = a
+ actuators = self.system_description.actuators
+ for actuator in actuators:
+ if actuator.id == a.actuator_id:
+ self.actuator_configurations[
+ str(a.actuator_id)
+ ] = S2ActuatorConfiguration(
+ str(actuator_status.active_operation_mode_id),
+ actuator_status.operation_mode_factor,
+ )
+
+ @staticmethod
+ def get_initial_timer_elapse_map_for_system_description(
+ system_description: FRBCSystemDescription,
+ ) -> Dict[tuple, datetime]:
+ timer_elapse_map = {}
+ for actuator in system_description.actuators:
+ for timer in actuator.timers:
+ key = FrbcState.timer_key(str(actuator.id), str(timer.id))
+ timer_elapse_map[key] = datetime.min # arbitrary day in the past
+ return timer_elapse_map
+
+ def calculate_state_values(
+ self,
+ previous_state: "FrbcState",
+ actuator_configurations: Dict[str, S2ActuatorConfiguration],
+ ):
+ self.timestep_energy = 0.0
+ self.fill_level = previous_state.get_fill_level()
+ seconds = self.timestep.get_duration_seconds()
+ for actuator_id, actuator_configuration in actuator_configurations.items():
+ om = self.device_state.get_operation_mode(
+ self.timestep,
+ actuator_id,
+ actuator_configuration.get_operation_mode_id(),
+ )
+ self.timestep_energy += (
+ self.device_state.get_operation_mode_power(
+ om,
+ previous_state.get_fill_level(),
+ actuator_configuration.get_factor(),
+ )
+ * seconds
+ )
+ self.fill_level += (
+ self.device_state.get_operation_mode_fill_rate(
+ om,
+ previous_state.get_fill_level(),
+ actuator_configuration.get_factor(),
+ )
+ * seconds
+ )
+ self.fill_level -= (
+ s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.get_leakage_rate(
+ self.timestep, self.fill_level
+ )
+ * seconds
+ )
+ self.fill_level += self.timestep.get_forecasted_usage()
+ self.bucket = (
+ s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.calculate_bucket(
+ self.timestep, self.fill_level
+ )
+ )
+ self.update_timers(previous_state, actuator_configurations)
+ self.calculate_scores(previous_state)
+ self.timestep.add_state(self)
+
+ def update_timers(
+ self,
+ previous_state: "FrbcState",
+ actuator_configurations: Dict[str, S2ActuatorConfiguration],
+ ):
+ if (
+ previous_state.system_description.valid_from
+ == self.system_description.valid_from
+ ):
+ self.timer_elapse_map = previous_state.get_timer_elapse_map().copy()
+ for actuator_id, actuator_configuration in actuator_configurations.items():
+ previous_operation_mode_id = (
+ previous_state.get_actuator_configurations()[
+ actuator_id
+ ].get_operation_mode_id()
+ )
+ new_operation_mode_id = actuator_configuration.get_operation_mode_id()
+ if previous_operation_mode_id != new_operation_mode_id:
+ transition = s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.get_transition(
+ self.timestep,
+ actuator_id,
+ previous_operation_mode_id,
+ new_operation_mode_id,
+ )
+ if transition is None:
+ continue
+ for timer_id in transition.start_timers:
+ duration = s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.get_timer_duration(
+ self.timestep, actuator_id, str(timer_id)
+ )
+ new_finished_at = self.timestep.get_start_date() + duration
+ key = FrbcState.timer_key(actuator_id, str(timer_id))
+ self.timer_elapse_map[key] = new_finished_at
+ else:
+ self.timer_elapse_map = (
+ self.get_initial_timer_elapse_map_for_system_description(
+ self.system_description
+ )
+ )
+
+ def calculate_scores(self, previous_state: "FrbcState"):
+ target = self.timestep.get_target()
+ if isinstance(target, TargetProfile.JouleElement):
+ self.sum_squared_distance = (
+ previous_state.get_sum_squared_distance()
+ + (target.get_joules() - self.timestep_energy) ** 2
+ )
+ self.sum_energy_cost = previous_state.get_sum_energy_cost()
+
+ else:
+ self.sum_squared_distance = previous_state.get_sum_squared_distance()
+ self.sum_energy_cost = previous_state.get_sum_energy_cost()
+ squared_constraint_violation = (
+ previous_state.get_sum_squared_constraint_violation()
+ )
+ if (
+ self.timestep.get_max_constraint() is not None
+ and self.timestep_energy > self.timestep.get_max_constraint()
+ ):
+ squared_constraint_violation += (
+ self.timestep_energy - self.timestep.get_max_constraint()
+ ) ** 2
+ if (
+ self.timestep.get_min_constraint() is not None
+ and self.timestep_energy < self.timestep.get_min_constraint()
+ ):
+ squared_constraint_violation += (
+ self.timestep.get_min_constraint() - self.timestep_energy
+ ) ** 2
+ self.sum_squared_constraint_violation = (
+ previous_state.get_sum_squared_constraint_violation()
+ + squared_constraint_violation
+ )
+ self.sum_squared_energy = (
+ previous_state.get_sum_squared_energy() + self.timestep_energy**2
+ )
+
+ @staticmethod
+ def timer_key(actuator_id: str, timer_id: str) -> tuple:
+ return (actuator_id, timer_id)
+
+ def generate_next_timestep_states(self, target_timestep):
+ all_actions = self.device_state.get_all_possible_actuator_configurations(
+ target_timestep
+ )
+ for action in all_actions:
+ self.try_create_next_state(self, target_timestep, action)
+
+ @staticmethod
+ def try_create_next_state(
+ previous_state: "FrbcState",
+ target_timestep,
+ actuator_configs_for_target_timestep: Dict[str, S2ActuatorConfiguration],
+ ):
+ if (
+ previous_state.system_description.valid_from
+ == target_timestep.system_description.valid_from
+ ):
+ for (
+ actuator_id,
+ actuator_configuration,
+ ) in actuator_configs_for_target_timestep.items():
+
+ # print all the keys in the previous_state.get_actuator_configurations()
+ # print(previous_state.get_actuator_configurations().keys())
+ try:
+ previous_operation_mode_id = (
+ previous_state.get_actuator_configurations()[
+ actuator_id
+ ].get_operation_mode_id()
+ )
+ except KeyError:
+ raise KeyError(
+ f"UUID {actuator_id} not found in actuator configurations"
+ )
+
+ new_operation_mode_id = actuator_configuration.get_operation_mode_id()
+ if previous_operation_mode_id != new_operation_mode_id:
+ transition = s2_frbc_device_state_wrapper.S2FrbcDeviceStateWrapper.get_transition(
+ target_timestep,
+ actuator_id,
+ previous_operation_mode_id,
+ new_operation_mode_id,
+ )
+ if transition is None:
+ return False
+ for timer_id in transition.blocking_timers:
+ timer_is_finished_at = (
+ previous_state.get_timer_elapse_map().get(
+ FrbcState.timer_key(actuator_id, str(timer_id))
+ )
+ )
+ if (
+ timer_is_finished_at
+ and timer_is_finished_at.year != 1
+ and target_timestep.get_start_date()
+ < timer_is_finished_at.astimezone(
+ target_timestep.get_start_date().tzinfo
+ )
+ ):
+ return False
+ FrbcState(
+ timestep=target_timestep,
+ previous_state=previous_state,
+ actuator_configurations=actuator_configs_for_target_timestep,
+ present_fill_level=0,
+ )
+ return True
+
+ def is_preferable_than(self, other_state: "FrbcState") -> SelectionResult:
+ if (
+ abs(
+ self.sum_squared_constraint_violation
+ - other_state.get_sum_squared_constraint_violation()
+ )
+ >= self.constraint_epsilon
+ ):
+ return SelectionResult(
+ result=self.sum_squared_constraint_violation
+ < other_state.get_sum_squared_constraint_violation(),
+ reason=SelectionReason.CONGESTION_CONSTRAINT,
+ )
+ elif (
+ abs(self.sum_squared_distance - other_state.get_sum_squared_distance())
+ >= self.constraint_epsilon
+ ):
+ return SelectionResult(
+ result=self.sum_squared_distance
+ < other_state.get_sum_squared_distance(),
+ reason=SelectionReason.ENERGY_TARGET,
+ )
+ elif (
+ abs(self.sum_energy_cost - other_state.get_sum_energy_cost())
+ >= self.tariff_epsilon
+ ):
+ return SelectionResult(
+ result=self.sum_energy_cost < other_state.get_sum_energy_cost(),
+ reason=SelectionReason.TARIFF_TARGET,
+ )
+ else:
+ return SelectionResult(
+ result=self.sum_squared_energy < other_state.get_sum_squared_energy(),
+ reason=SelectionReason.MIN_ENERGY,
+ )
+
+ def is_within_fill_level_range(self):
+ fill_level_range = self.system_description.storage.fill_level_range
+ return (
+ self.fill_level >= fill_level_range.start_of_range
+ and self.fill_level <= fill_level_range.end_of_range
+ )
+
+ def get_fill_level_distance(self):
+ fill_level_range = self.system_description.storage.fill_level_range
+ if self.fill_level < fill_level_range.start_of_range:
+ return fill_level_range.start_of_range - self.fill_level
+ else:
+ return self.fill_level - fill_level_range.end_of_range
+
+ def get_device_state(self):
+ return self.device_state
+
+ def get_previous_state(self):
+ return self.previous_state
+
+ def get_actuator_configurations(self):
+ return self.actuator_configurations
+
+ def get_fill_level(self) -> float:
+ return self.fill_level
+
+ def get_bucket(self) -> int:
+ return self.bucket
+
+ def get_timestep_energy(self) -> float:
+ return self.timestep_energy
+
+ def get_sum_squared_distance(self) -> float:
+ return self.sum_squared_distance
+
+ def get_sum_squared_constraint_violation(self) -> float:
+ return self.sum_squared_constraint_violation
+
+ def get_sum_energy_cost(self) -> float:
+ return self.sum_energy_cost
+
+ def get_sum_squared_energy(self) -> float:
+ return self.sum_squared_energy
+
+ def get_timer_elapse_map(self) -> Dict[tuple, datetime]:
+ return self.timer_elapse_map
+
+ def set_selection_reason(self, selection_reason):
+ self.selection_reason = selection_reason
+
+ def get_selection_reason(self) -> Optional[SelectionReason]:
+ return self.selection_reason
+
+ def get_system_description(self) -> FRBCSystemDescription:
+ return self.system_description
+
+ def get_timestep(self):
+ return self.timestep
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_timestep.py b/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_timestep.py
new file mode 100644
index 0000000..f8b663b
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/frbc_timestep.py
@@ -0,0 +1,173 @@
+from datetime import datetime, timedelta
+from typing import List, Optional
+
+from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile
+from flexmeasures_s2.profile_steering.s2_utils.number_range_wrapper import (
+ NumberRangeWrapper,
+)
+from s2python.frbc import FRBCSystemDescription, FRBCLeakageBehaviour
+from flexmeasures_s2.profile_steering.device_planner.frbc.frbc_state import FrbcState
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state import (
+ S2FrbcDeviceState,
+)
+from enum import Enum
+
+
+class SelectionReason(Enum):
+ NO_ALTERNATIVE = "NA"
+ EMERGENCY_STATE = "ES"
+ CONGESTION_CONSTRAINT = "CC"
+ ENERGY_TARGET = "ET"
+ TARIFF_TARGET = "TT"
+ MIN_ENERGY = "ME"
+
+ @property
+ def abbr(self) -> str:
+ return self.value
+
+
+class FrbcTimestep:
+ def __init__(
+ self,
+ start_date: datetime,
+ end_date: datetime,
+ system_description: FRBCSystemDescription,
+ leakage_behaviour: FRBCLeakageBehaviour,
+ fill_level_target: Optional[NumberRangeWrapper],
+ forecasted_fill_level_usage: float,
+ computational_parameters: S2FrbcDeviceState.ComputationalParameters,
+ ):
+ self.nr_of_buckets: int = computational_parameters.get_nr_of_buckets()
+ self.start_date: datetime = start_date
+ self.end_date: datetime = end_date
+ self.duration: timedelta = end_date - start_date
+ self.system_description: FRBCSystemDescription = system_description
+ self.leakage_behaviour: FRBCLeakageBehaviour = leakage_behaviour
+ self.fill_level_target: Optional[NumberRangeWrapper] = fill_level_target
+ self.forecasted_fill_level_usage: float = forecasted_fill_level_usage
+ self.state_list: List[Optional[FrbcState]] = [None] * (self.nr_of_buckets + 1)
+ self.emergency_state: Optional[FrbcState] = None
+ # print timestep start date and end date
+ # print(f"Timestep start date: {self.start_date}, end date: {self.end_date}")
+ # print(f"Forecasted fill level usage: {self.forecasted_fill_level_usage}")
+ # if self.fill_level_target is not None:
+ # print(f"Fill level start of range: {self.fill_level_target.start_of_range}")
+ # print(f"Fill level end of range: {self.fill_level_target.end_of_range}")
+
+ def get_nr_of_buckets(self) -> int:
+ return self.nr_of_buckets
+
+ def set_targets(
+ self, target: float, min_constraint: float, max_constraint: float
+ ) -> None:
+ self.target = target
+ self.min_constraint = min_constraint
+ self.max_constraint = max_constraint
+
+ def add_state(self, state: FrbcState) -> None:
+ if state and state.is_within_fill_level_range():
+ stored_state = self.state_list[state.bucket]
+ if stored_state is None:
+ self.state_list[state.bucket] = state
+ state.set_selection_reason(SelectionReason.NO_ALTERNATIVE)
+ else:
+ selection_result = state.is_preferable_than(stored_state)
+ if selection_result.result:
+ self.state_list[state.bucket] = state
+ self.state_list[state.bucket].set_selection_reason(selection_result.reason) # type: ignore
+ else:
+ if (
+ self.emergency_state is None
+ or state.get_fill_level_distance()
+ < self.emergency_state.get_fill_level_distance()
+ ):
+ self.emergency_state = state
+ state.set_selection_reason(SelectionReason.EMERGENCY_STATE)
+
+ def add_all_states(self, states: List[FrbcState]) -> None:
+ for state in states:
+ self.add_state(state)
+
+ def get_start_date(self) -> datetime:
+ return self.start_date
+
+ def get_end_date(self) -> datetime:
+ return self.end_date
+
+ def get_system_description(self) -> FRBCSystemDescription:
+ return self.system_description
+
+ def get_leakage_behaviour(self) -> FRBCLeakageBehaviour:
+ return self.leakage_behaviour
+
+ def get_duration(self) -> timedelta:
+ return self.duration
+
+ def get_duration_seconds(self) -> int:
+ return int(self.duration.total_seconds())
+
+ def get_target(self) -> TargetProfile.JouleElement:
+ return self.target
+
+ def get_min_constraint(self) -> float:
+ return self.min_constraint
+
+ def get_max_constraint(self) -> float:
+ return self.max_constraint
+
+ def get_fill_level_target(self) -> Optional[NumberRangeWrapper]:
+ return self.fill_level_target
+
+ def get_state_list(self) -> List[Optional[FrbcState]]:
+ return self.state_list
+
+ def get_final_states(self) -> List[FrbcState]:
+ final_states = [state for state in self.state_list if state is not None]
+ # print out the position of the states in final_states
+
+ if not final_states and self.emergency_state is not None:
+ return [self.emergency_state]
+ return final_states
+
+ def get_final_states_within_fill_level_target(self) -> List[FrbcState]:
+ final_states = self.get_final_states()
+ if self.fill_level_target is None:
+ return final_states
+ final_states = [
+ s for s in final_states if self.state_is_within_fill_level_target_range(s)
+ ]
+ if final_states:
+ return final_states
+ best_state = min(
+ self.get_final_states(), key=self.get_fill_level_target_distance
+ )
+ return [best_state]
+
+ def state_is_within_fill_level_target_range(self, state: FrbcState) -> bool:
+ if self.fill_level_target is None:
+ return True
+ return (
+ self.fill_level_target.start_of_range is None
+ or state.get_fill_level() >= self.fill_level_target.start_of_range
+ ) and (
+ self.fill_level_target.end_of_range is None
+ or state.get_fill_level() <= self.fill_level_target.end_of_range
+ )
+
+ def get_fill_level_target_distance(self, state: FrbcState) -> float:
+ if self.fill_level_target is None:
+ return 0
+ if (
+ self.fill_level_target.end_of_range is None
+ or state.get_fill_level() < self.fill_level_target.start_of_range
+ ):
+ return self.fill_level_target.start_of_range - state.get_fill_level()
+ else:
+ return state.get_fill_level() - self.fill_level_target.end_of_range
+
+ def get_forecasted_usage(self) -> float:
+ return self.forecasted_fill_level_usage
+
+ def clear(self) -> None:
+ self.state_list = [None] * len(self.state_list)
+ self.emergency_state = None
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/operation_mode_profile_tree.py b/flexmeasures_s2/profile_steering/device_planner/frbc/operation_mode_profile_tree.py
new file mode 100644
index 0000000..91ae363
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/operation_mode_profile_tree.py
@@ -0,0 +1,357 @@
+from datetime import datetime, timedelta
+from typing import List, Optional, Any, Tuple
+from zoneinfo import ZoneInfo
+
+from flexmeasures_s2.profile_steering.device_planner.frbc.frbc_timestep import (
+ FrbcTimestep,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state_wrapper import (
+ S2FrbcDeviceStateWrapper,
+)
+
+from flexmeasures_s2.profile_steering.device_planner.frbc.frbc_state import FrbcState
+from flexmeasures_s2.profile_steering.device_planner.frbc.fill_level_target_util import (
+ FillLevelTargetUtil,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.usage_forecast_util import (
+ UsageForecastUtil,
+)
+from flexmeasures_s2.profile_steering.s2_utils.number_range_wrapper import (
+ NumberRangeWrapper,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_plan import S2FrbcPlan
+from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+from flexmeasures_s2.profile_steering.common.soc_profile import SoCProfile
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state import (
+ S2FrbcDeviceState,
+)
+from pint import UnitRegistry
+
+SI = UnitRegistry()
+
+
+# TODO: Add S2FrbcInsightsProfile?->Update 08-02-2025: Not needed for now
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+import matplotlib.patches as mpatches
+
+
+def plot_planning_results(
+ timestep_start_times, energy_elements, fill_level_elements, operation_mode_ids, ids
+):
+ """
+ Plots the energy, fill level, actuator usage, and operation mode ID lists using matplotlib.
+
+ :param timestep_start_times: List of datetime objects representing the start time of each timestep.
+ :param energy_elements: List of energy values.
+ :param fill_level_elements: List of fill level values.
+ :param operation_mode_ids: List of dictionaries with actuator configurations.
+ :param ids: List of tuples (id, name) for debugging.
+ """
+ # Create a figure and a set of subplots
+ fig, axs = plt.subplots(4, 1, figsize=(12, 16), sharex=True)
+
+ # Plot energy elements
+ axs[0].plot(timestep_start_times, energy_elements, label="Energy", color="blue")
+ axs[0].set_ylabel("Energy (Joules)")
+ axs[0].legend(loc="upper right")
+ axs[0].grid(True)
+
+ # Plot fill level elements
+ axs[1].plot(
+ timestep_start_times, fill_level_elements, label="Fill Level", color="green"
+ )
+ axs[1].set_ylabel("Fill Level (%)")
+ axs[1].legend(loc="upper right")
+ axs[1].grid(True)
+
+ # Create a dictionary from the list of tuples for easier lookup
+ id_to_name = {id_val: name for id_val, name in ids}
+
+ # Create a color mapping for names
+ unique_names = set(name for _, name in ids)
+ actuator_colors = {name: f"C{i}" for i, name in enumerate(unique_names)}
+
+ # Plot actuator usage
+ for i, (time, config) in enumerate(zip(timestep_start_times, operation_mode_ids)):
+ for actuator_id, config in config.items():
+ actuator_name = id_to_name.get(str(actuator_id), str(actuator_id))
+ axs[2].scatter(
+ time,
+ actuator_name,
+ color=actuator_colors.get(actuator_name, "black"),
+ label=actuator_name if i == 0 else "",
+ )
+ axs[2].set_ylabel("Actuator Name")
+ handles, labels = axs[2].get_legend_handles_labels()
+ by_label = dict(zip(labels, handles))
+ axs[2].legend(by_label.values(), by_label.keys(), loc="upper right")
+ axs[2].grid(True)
+
+ # Plot operation modes
+ for i, (time, config) in enumerate(zip(timestep_start_times, operation_mode_ids)):
+ for actuator_id, config in config.items():
+ operation_mode_id = getattr(config, "operation_mode_id", None)
+ if operation_mode_id:
+ operation_mode_name = id_to_name.get(
+ str(operation_mode_id), str(operation_mode_id)
+ )
+ axs[3].scatter(
+ time,
+ operation_mode_name,
+ color=actuator_colors.get(operation_mode_name, "black"),
+ label=operation_mode_name if i == 0 else "",
+ )
+ axs[3].set_ylabel("Operation Mode Name")
+ handles, labels = axs[3].get_legend_handles_labels()
+ by_label = dict(zip(labels, handles))
+ axs[3].legend(by_label.values(), by_label.keys(), loc="upper right")
+ axs[3].grid(True)
+
+ # Format the x-axis to show time and set ticks every 30 minutes
+ axs[3].xaxis.set_major_locator(mdates.MinuteLocator(interval=30))
+ axs[3].xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
+ fig.autofmt_xdate()
+
+ # Adjust layout
+ plt.tight_layout()
+ plt.show()
+
+
+class OperationModeProfileTree:
+ def __init__(
+ self,
+ device_state: S2FrbcDeviceState,
+ profile_metadata: ProfileMetadata,
+ plan_due_by_date: datetime,
+ ):
+ self.device_state = S2FrbcDeviceStateWrapper(device_state)
+ self.profile_metadata = profile_metadata
+ self.plan_due_by_date = plan_due_by_date
+ self.timestep_duration_seconds = int(
+ profile_metadata.get_timestep_duration().total_seconds()
+ )
+ self.timesteps: List[FrbcTimestep] = []
+ self.generate_timesteps()
+
+ def generate_timesteps(self) -> None:
+ time_step_start = self.profile_metadata.get_profile_start()
+ for i in range(self.profile_metadata.get_nr_of_timesteps()):
+ if i == 187:
+ print("here")
+ time_step_end = (
+ time_step_start + self.profile_metadata.get_timestep_duration()
+ )
+
+ if i == 0:
+ time_step_start = self.plan_due_by_date
+ system_default_timezone = time_step_start.astimezone().tzinfo
+ time_step_start_dt = time_step_start.astimezone(system_default_timezone)
+ current_system_description = self.get_latest_before(
+ time_step_start_dt,
+ self.device_state.get_system_descriptions(),
+ lambda sd: sd.valid_from,
+ )
+ current_leakage_behaviour = self.get_latest_before(
+ time_step_start_dt,
+ self.device_state.get_leakage_behaviours(),
+ lambda lb: lb.valid_from,
+ )
+ current_fill_level = self.get_latest_before(
+ time_step_start_dt,
+ self.device_state.get_fill_level_target_profiles(),
+ lambda fl: fl.start_time,
+ )
+ current_fill_level_target = None
+ if current_fill_level:
+ current_fill_level_target = (
+ FillLevelTargetUtil.from_fill_level_target_profile(
+ current_fill_level
+ )
+ )
+ fill_level_target = self.get_fill_level_target_for_timestep(
+ current_fill_level_target, time_step_start, time_step_end
+ )
+ current_usage_forecast = self.get_latest_before(
+ time_step_start_dt,
+ self.device_state.get_usage_forecasts(),
+ lambda uf: uf.start_time,
+ )
+ current_usage_forecast_profile = None
+ if current_usage_forecast:
+ current_usage_forecast_profile = (
+ UsageForecastUtil.from_storage_usage_profile(current_usage_forecast)
+ )
+
+ usage_forecast = self.get_usage_forecast_for_timestep(
+ current_usage_forecast_profile, time_step_start, time_step_end
+ )
+ self.timesteps.append(
+ FrbcTimestep(
+ time_step_start.astimezone(system_default_timezone),
+ time_step_end.astimezone(system_default_timezone),
+ current_system_description,
+ current_leakage_behaviour,
+ fill_level_target,
+ usage_forecast,
+ self.device_state.get_computational_parameters(),
+ )
+ )
+ time_step_start = time_step_end
+
+ @staticmethod
+ def get_latest_before(before, select_from, get_date_time):
+ latest_before = None
+ latest_before_date_time = None
+ if select_from:
+ for current in select_from:
+ if current:
+ current_date_time = get_date_time(current).replace(tzinfo=None)
+ before = before.replace(tzinfo=None)
+ if current_date_time <= before and (
+ latest_before is None
+ or current_date_time > latest_before_date_time
+ ):
+ latest_before = current
+ latest_before_date_time = current_date_time
+ return latest_before
+
+ @staticmethod
+ def get_fill_level_target_for_timestep(
+ fill_level_target_profile: Optional[Any],
+ time_step_start: datetime,
+ time_step_end: datetime,
+ ) -> Optional[NumberRangeWrapper]:
+ if not fill_level_target_profile:
+ return None
+ time_step_end -= timedelta(milliseconds=1)
+ lower, upper = None, None
+ for e in FillLevelTargetUtil.get_elements_in_range(
+ fill_level_target_profile, time_step_start, time_step_end
+ ):
+ if e.lower_limit is not None and (lower is None or e.lower_limit > lower):
+ lower = e.lower_limit
+ if e.upper_limit is not None and (upper is None or e.upper_limit < upper):
+ upper = e.upper_limit
+ if lower is None and upper is None:
+ return None
+ return NumberRangeWrapper(lower, upper)
+
+ @staticmethod
+ def get_usage_forecast_for_timestep(
+ usage_forecast: Optional[Any],
+ time_step_start: datetime,
+ time_step_end: datetime,
+ ) -> float:
+ if not usage_forecast:
+ return 0
+ time_step_end -= timedelta(milliseconds=1)
+ usage = 0
+ usage = UsageForecastUtil.sub_profile(
+ usage_forecast=usage_forecast,
+ time_step_start=time_step_start,
+ time_step_end=time_step_end,
+ )
+
+ return usage
+
+ def find_best_plan(
+ self,
+ target_profile: Any,
+ diff_to_min_profile: Any,
+ diff_to_max_profile: Any,
+ ids: Optional[dict] = None,
+ ) -> Tuple[S2FrbcPlan, List[datetime]]:
+ for i, ts in enumerate(self.timesteps):
+ ts.clear()
+ ts.set_targets(
+ target_profile.get_elements()[i],
+ diff_to_min_profile.get_elements()[i],
+ diff_to_max_profile.get_elements()[i],
+ )
+ first_timestep_index = next(
+ (i for i, ts in enumerate(self.timesteps) if ts.get_system_description()),
+ -1,
+ )
+ first_timestep = self.timesteps[first_timestep_index]
+ last_timestep = self.timesteps[-1]
+ state_zero = FrbcState(
+ device_state=self.device_state,
+ timestep=first_timestep,
+ present_fill_level=0,
+ )
+ state_zero.generate_next_timestep_states(first_timestep)
+
+ for i in range(first_timestep_index, len(self.timesteps) - 1):
+ # print(f"Generating next timestep states for timestep: {i}")
+ # if i == 187:
+ # print("here")
+ current_timestep = self.timesteps[i]
+ next_timestep = self.timesteps[i + 1]
+ final_states = current_timestep.get_final_states_within_fill_level_target()
+ # print(f"There are {len(final_states)} final states")
+ for frbc_state in final_states:
+ frbc_state.generate_next_timestep_states(next_timestep)
+ end_state = self.find_best_end_state(
+ last_timestep.get_final_states_within_fill_level_target()
+ )
+ plan = self.convert_to_plan(first_timestep_index, end_state)
+ # Extract only the time component from each datetime object
+ timestep_start_times = [ts.get_start_date() for ts in self.timesteps]
+
+ # Now use this list in the plot function
+ # plot_planning_results(timestep_start_times, plan.get_energy().elements, plan.get_fill_level().elements, plan.get_operation_mode_id(), ids)
+
+ return plan
+
+ @staticmethod
+ def find_best_end_state(states: List[FrbcState]) -> FrbcState:
+ best_state = states[0]
+ for state in states[1:]:
+ if state.is_preferable_than(best_state).result:
+ best_state = state
+ return best_state
+
+ def convert_to_plan(
+ self, first_timestep_index_with_state: int, end_state: FrbcState
+ ) -> S2FrbcPlan:
+ energy: List[int] = [0] * self.profile_metadata.get_nr_of_timesteps()
+ fill_level: List[float] = [0.0] * self.profile_metadata.get_nr_of_timesteps()
+ actuators: List[dict] = [{}] * self.profile_metadata.get_nr_of_timesteps()
+ insight_elements = [None] * self.profile_metadata.get_nr_of_timesteps()
+ state_selection_reasons: List[str] = [
+ ""
+ ] * self.profile_metadata.get_nr_of_timesteps()
+ state = end_state
+ for i in range(self.profile_metadata.get_nr_of_timesteps() - 1, -1, -1):
+ if i >= first_timestep_index_with_state:
+ energy[i] = int(state.get_timestep_energy())
+ fill_level[i] = state.get_fill_level()
+ actuators[i] = state.get_actuator_configurations()
+ state_selection_reasons[
+ i
+ ] = state.get_selection_reason() # type: ignore
+ state = state.get_previous_state()
+ else:
+ energy[i] = 0
+ fill_level[i] = 0.0
+ actuators[i] = {}
+ insight_elements[i] = None
+ energy[0] += self.device_state.get_energy_in_current_timestep().value
+ return S2FrbcPlan(
+ False,
+ JouleProfile(
+ self.profile_metadata.get_profile_start(),
+ self.profile_metadata.get_timestep_duration(),
+ energy,
+ ),
+ SoCProfile(
+ self.profile_metadata,
+ fill_level,
+ ),
+ actuators,
+ )
+
+ def get_timestep_duration_seconds(self) -> int:
+ return self.timestep_duration_seconds
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_actuator_configuration.py b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_actuator_configuration.py
new file mode 100644
index 0000000..ed64373
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_actuator_configuration.py
@@ -0,0 +1,25 @@
+from typing import Dict, Any
+
+# TODO: Should this just be an actuator description
+
+
+class S2ActuatorConfiguration:
+ def __init__(self, operation_mode_id: str, factor):
+ self.operation_mode_id = operation_mode_id
+ self.factor = factor
+
+ def get_operation_mode_id(self) -> str:
+ return self.operation_mode_id
+
+ def get_factor(self) -> float:
+ return self.factor
+
+ def to_dict(self):
+ return {"operationModeId": self.operation_mode_id, "factor": self.factor}
+
+ @staticmethod
+ def from_dict(data: Dict[str, Any]) -> "S2ActuatorConfiguration":
+ return S2ActuatorConfiguration(str(data["operationModeId"]), data["factor"])
+
+ def __str__(self):
+ return f"S2ActuatorConfiguration [operationModeId={self.operation_mode_id}, factor={self.factor}]"
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_planner.py b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_planner.py
new file mode 100644
index 0000000..ed9b070
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_planner.py
@@ -0,0 +1,223 @@
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy.sql.base import elements
+
+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.device_planner.frbc.s2_frbc_plan import S2FrbcPlan
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_instruction_profile import (
+ S2FrbcInstructionProfile,
+)
+from flexmeasures_s2.profile_steering.common.profile_metadata import ProfileMetadata
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state_wrapper import (
+ S2FrbcDeviceStateWrapper,
+)
+from flexmeasures_s2.profile_steering.common.device_planner.device_plan import (
+ DevicePlan,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.operation_mode_profile_tree import (
+ OperationModeProfileTree,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state import (
+ S2FrbcDeviceState,
+)
+from flexmeasures_s2.profile_steering.device_planner.device_planner_abstract import (
+ DevicePlanner,
+)
+
+
+# make sure this is a DevicePlanner
+class S2FrbcDevicePlanner(DevicePlanner):
+ def __init__(
+ self,
+ s2_frbc_state: S2FrbcDeviceState,
+ profile_metadata: ProfileMetadata,
+ plan_due_by_date: datetime,
+ ):
+ self.s2_frbc_state = s2_frbc_state
+ self.profile_metadata = profile_metadata
+ self.zero_profile = JouleProfile(
+ profile_metadata.get_profile_start(),
+ profile_metadata.get_timestep_duration(),
+ [0] * profile_metadata.get_nr_of_timesteps(),
+ )
+ self.null_profile = JouleProfile(
+ profile_metadata.get_profile_start(),
+ profile_metadata.get_timestep_duration(),
+ elements=[None] * profile_metadata.get_nr_of_timesteps(),
+ )
+ if self.is_storage_available(self.s2_frbc_state):
+ self.state_tree = OperationModeProfileTree(
+ self.s2_frbc_state,
+ profile_metadata,
+ plan_due_by_date,
+ )
+ self.priority_class = 1
+ self.latest_plan: Optional[S2FrbcPlan] = None
+ self.accepted_plan: Optional[S2FrbcPlan] = None
+
+ def is_storage_available(self, storage_state: S2FrbcDeviceState) -> bool:
+ latest_before_first_ptu = OperationModeProfileTree.get_latest_before(
+ self.profile_metadata.get_profile_start().replace(tzinfo=None),
+ storage_state.get_system_descriptions(),
+ lambda sd: sd.valid_from.replace(tzinfo=None),
+ )
+ if not storage_state.get_system_descriptions():
+ return False
+ if latest_before_first_ptu is None:
+ active_and_upcoming_system_descriptions_has_active_storage = any(
+ # TODO: ask if TypeError: can't compare offset-naive and offset-aware datetimes could be solved differently
+ self.profile_metadata.get_profile_end().replace(tzinfo=None)
+ >= sd.valid_from.replace(tzinfo=None)
+ >= self.profile_metadata.get_profile_start().replace(tzinfo=None)
+ for sd in storage_state.get_system_descriptions()
+ )
+ else:
+ active_and_upcoming_system_descriptions_has_active_storage = any(
+ self.profile_metadata.get_profile_end().replace(tzinfo=None)
+ >= sd.valid_from.replace(tzinfo=None)
+ >= latest_before_first_ptu.valid_from.replace(tzinfo=None)
+ for sd in storage_state.get_system_descriptions()
+ )
+ return (
+ storage_state._is_online()
+ and active_and_upcoming_system_descriptions_has_active_storage
+ )
+
+ def get_device_id(self) -> str:
+ return self.s2_frbc_state.get_device_id()
+
+ def get_connection_id(self) -> str:
+ return self.s2_frbc_state.get_connection_id()
+
+ def get_device_name(self) -> str:
+ return self.s2_frbc_state.get_device_name()
+
+ def create_improved_planning(
+ self,
+ diff_to_global_target: TargetProfile,
+ diff_to_max: JouleProfile,
+ diff_to_min: JouleProfile,
+ plan_due_by_date: datetime,
+ ) -> Proposal:
+ if self.accepted_plan is None:
+ raise ValueError("No accepted plan found")
+
+ target = diff_to_global_target.add(self.accepted_plan.get_energy())
+
+ max_profile = diff_to_max.add(self.accepted_plan.get_energy())
+ min_profile = diff_to_min.add(self.accepted_plan.get_energy())
+
+ if self.is_storage_available(self.s2_frbc_state):
+ self.latest_plan = self.state_tree.find_best_plan(
+ target, min_profile, max_profile
+ )
+ else:
+ self.latest_plan = S2FrbcPlan(
+ idle=True,
+ energy=self.zero_profile,
+ fill_level=None,
+ operation_mode_id=[],
+ )
+ if self.latest_plan is None:
+ raise ValueError("No latest plan found")
+ proposal = Proposal(
+ global_diff_target=diff_to_global_target,
+ diff_to_congestion_max=diff_to_max,
+ diff_to_congestion_min=diff_to_min,
+ proposed_plan=self.latest_plan.get_energy(),
+ old_plan=self.accepted_plan.get_energy(),
+ origin=self,
+ )
+ return proposal
+
+ def create_initial_planning(
+ self, plan_due_by_date: datetime, ids: Optional[dict] = None
+ ) -> S2FrbcPlan:
+ if self.is_storage_available(self.s2_frbc_state):
+ if ids is None:
+ self.latest_plan = self.state_tree.find_best_plan(
+ TargetProfile.null_profile(self.profile_metadata),
+ self.null_profile,
+ self.null_profile,
+ )
+ else:
+ self.latest_plan = self.state_tree.find_best_plan(
+ TargetProfile.null_profile(self.profile_metadata),
+ self.null_profile,
+ self.null_profile,
+ ids,
+ )
+ else:
+ self.latest_plan = S2FrbcPlan(
+ idle=True,
+ energy=self.zero_profile,
+ fill_level=None,
+ operation_mode_id=[],
+ )
+ self.accepted_plan = self.latest_plan
+ return self.latest_plan.get_energy() # type: ignore
+
+ def accept_proposal(self, accepted_proposal: Proposal) -> None:
+ if self.latest_plan is None:
+ raise ValueError("No latest plan found")
+ if accepted_proposal.get_origin() != self:
+ raise ValueError(
+ f"Storage controller '{self.get_device_id()}' received a proposal that he did not send."
+ )
+ if not accepted_proposal.get_proposed_plan() == self.latest_plan.get_energy():
+ raise ValueError(
+ f"Storage controller '{self.get_device_id()}' received a proposal that he did not send."
+ )
+ if accepted_proposal.get_congestion_improvement_value() < 0:
+ raise ValueError(
+ f"Storage controller '{self.get_device_id()}' received a proposal with negative improvement"
+ )
+ self.accepted_plan = self.latest_plan
+
+ def get_current_profile(self) -> JouleProfile:
+ if self.accepted_plan is None:
+ raise ValueError("No accepted plan found")
+ return self.accepted_plan.get_energy()
+
+ def get_latest_plan(self) -> Optional[S2FrbcPlan]:
+ return self.latest_plan
+
+ def get_device_plan(self) -> Optional[DevicePlan]:
+ if self.accepted_plan is None:
+ return None
+ return DevicePlan(
+ device_id=self.get_device_id(),
+ device_name=self.get_device_name(),
+ connection_id=self.get_connection_id(),
+ energy_profile=self.accepted_plan.get_energy(),
+ fill_level_profile=self.accepted_plan.get_fill_level(),
+ instruction_profile=self.convert_plan_to_instructions(
+ self.profile_metadata, self.accepted_plan
+ ),
+ )
+
+ @staticmethod
+ def convert_plan_to_instructions(
+ profile_metadata: ProfileMetadata, device_plan: S2FrbcPlan
+ ) -> S2FrbcInstructionProfile:
+ 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
+ )
+ elements.append(new_element)
+ else:
+ elements = [None] * profile_metadata.get_nr_of_timesteps()
+ return S2FrbcInstructionProfile(
+ profile_start=profile_metadata.get_profile_start(),
+ timestep_duration=profile_metadata.get_timestep_duration(),
+ elements=elements,
+ )
+
+ def get_priority_class(self) -> int:
+ return self.priority_class
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_state.py b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_state.py
new file mode 100644
index 0000000..d10eb79
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_state.py
@@ -0,0 +1,97 @@
+from datetime import datetime
+from typing import List, Optional, Dict
+from s2python.common import CommodityQuantity, PowerForecast
+from s2python.frbc import (
+ FRBCSystemDescription,
+ FRBCLeakageBehaviour,
+ FRBCUsageForecast,
+ FRBCFillLevelTargetProfile,
+)
+from s2python.frbc.frbc_actuator_status import FRBCActuatorStatus
+from s2python.generated.gen_s2 import FRBCStorageStatus, PowerValue
+
+
+class S2FrbcDeviceState:
+ class ComputationalParameters:
+ def __init__(self, nr_of_buckets: int, stratification_layers: int):
+ self.nr_of_buckets = nr_of_buckets
+ self.stratification_layers = stratification_layers
+
+ def get_nr_of_buckets(self) -> int:
+ return self.nr_of_buckets
+
+ def get_stratification_layers(self) -> int:
+ return self.stratification_layers
+
+ 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],
+ system_descriptions: List[FRBCSystemDescription],
+ leakage_behaviours: List[FRBCLeakageBehaviour],
+ usage_forecasts: List[FRBCUsageForecast],
+ fill_level_target_profiles: List[FRBCFillLevelTargetProfile],
+ computational_parameters: ComputationalParameters,
+ actuator_statuses: Optional[List[FRBCActuatorStatus]] = None,
+ storage_status: Optional[List[FRBCStorageStatus]] = None,
+ ):
+ 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
+ self.system_descriptions = system_descriptions
+ self.leakage_behaviours = leakage_behaviours
+ self.usage_forecasts = usage_forecasts
+ self.fill_level_target_profiles = fill_level_target_profiles
+ self.computational_parameters = computational_parameters
+ self.actuator_statuses = actuator_statuses
+ self.storage_status = storage_status
+
+ def get_system_descriptions(self) -> List[FRBCSystemDescription]:
+ return self.system_descriptions
+
+ def get_leakage_behaviours(self) -> List[FRBCLeakageBehaviour]:
+ return self.leakage_behaviours
+
+ def get_usage_forecasts(self) -> List[FRBCUsageForecast]:
+ return self.usage_forecasts
+
+ def get_fill_level_target_profiles(self) -> List[FRBCFillLevelTargetProfile]:
+ return self.fill_level_target_profiles
+
+ 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 _is_online(self) -> bool:
+ return self.is_online
+
+ def get_computational_parameters(self) -> ComputationalParameters:
+ return self.computational_parameters
+
+ def get_power_forecast(self) -> Optional[PowerForecast]:
+ return self.power_forecast
+
+ def get_energy_in_current_timestep(self) -> CommodityQuantity:
+ return self.energy_in_current_timestep
+
+ def get_actuator_statuses(self) -> List[FRBCActuatorStatus]:
+ return self.actuator_statuses
+
+ def get_storage_status(self) -> List[FRBCStorageStatus]:
+ return self.storage_status
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_state_wrapper.py b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_state_wrapper.py
new file mode 100644
index 0000000..7bc86b6
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_device_state_wrapper.py
@@ -0,0 +1,373 @@
+from datetime import datetime, timedelta
+from typing import Dict, List, Optional, Any
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_actuator_configuration import (
+ S2ActuatorConfiguration,
+)
+from s2python.common import CommodityQuantity
+from flexmeasures_s2.profile_steering.device_planner.frbc.frbc_operation_mode_wrapper import (
+ FrbcOperationModeWrapper,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.frbc_timestep import (
+ FrbcTimestep,
+)
+
+from s2python.common.transition import Transition
+from s2python.frbc import (
+ FRBCLeakageBehaviourElement,
+ FRBCActuatorDescription,
+ FRBCActuatorStatus,
+ FRBCStorageStatus,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state import (
+ S2FrbcDeviceState,
+)
+
+
+class S2FrbcDeviceStateWrapper:
+ epsilon = 1e-4
+
+ def __init__(self, device_state):
+ self.device_state: S2FrbcDeviceState = device_state
+ self.nr_of_buckets: int = (
+ self.device_state.get_computational_parameters().get_nr_of_buckets()
+ )
+ self.nr_of_stratification_layers: int = (
+ self.device_state.get_computational_parameters().get_stratification_layers()
+ )
+ self.actuator_operation_mode_map_per_timestep: Dict[
+ datetime, Dict[str, List[str]]
+ ] = {}
+ self.all_actions: Dict[datetime, List[Dict[str, S2ActuatorConfiguration]]] = {}
+ self.operation_mode_uses_factor_map: Dict[str, bool] = {}
+ self.operation_modes: Dict[str, FrbcOperationModeWrapper] = {}
+
+ def is_online(self) -> bool:
+ return self.device_state._is_online()
+
+ def get_power_forecast(self) -> Any:
+ return self.device_state.get_power_forecast()
+
+ def get_system_descriptions(self) -> Any:
+ return self.device_state.get_system_descriptions()
+
+ def get_leakage_behaviours(self) -> Any:
+ return self.device_state.get_leakage_behaviours()
+
+ def get_usage_forecasts(self) -> Any:
+ return self.device_state.get_usage_forecasts()
+
+ def get_fill_level_target_profiles(self) -> Any:
+ return self.device_state.get_fill_level_target_profiles()
+
+ def get_computational_parameters(self) -> Any:
+ return self.device_state.get_computational_parameters()
+
+ def get_actuators(self, target_timestep: FrbcTimestep) -> List[str]:
+ actuator_operation_mode_map = self.actuator_operation_mode_map_per_timestep.get(
+ target_timestep.get_start_date()
+ )
+ if actuator_operation_mode_map is None:
+ actuator_operation_mode_map = self.create_actuator_operation_mode_map(
+ target_timestep
+ )
+ return list(actuator_operation_mode_map.keys())
+
+ def get_normal_operation_modes_for_actuator(
+ self, target_timestep: FrbcTimestep, actuator_id: str
+ ) -> List[str]:
+ actuator_operation_mode_map = self.actuator_operation_mode_map_per_timestep.get(
+ target_timestep.get_start_date()
+ )
+ if actuator_operation_mode_map is None:
+ actuator_operation_mode_map = self.create_actuator_operation_mode_map(
+ target_timestep
+ )
+ return actuator_operation_mode_map.get(actuator_id, [])
+
+ def create_actuator_operation_mode_map(
+ self, target_timestep: FrbcTimestep
+ ) -> Dict[str, List[str]]:
+ actuator_operation_mode_map = {}
+ for a in target_timestep.get_system_description().actuators:
+ actuator_operation_mode_map[str(a.id)] = [
+ str(om.id) for om in a.operation_modes if not om.abnormal_condition_only
+ ]
+ self.actuator_operation_mode_map_per_timestep[
+ target_timestep.get_start_date()
+ ] = actuator_operation_mode_map
+ return actuator_operation_mode_map
+
+ def get_operation_mode(
+ self, target_timestep, actuator_id: str, operation_mode_id: str
+ ):
+ from flexmeasures_s2.profile_steering.device_planner.frbc.frbc_operation_mode_wrapper import (
+ FrbcOperationModeWrapper,
+ )
+
+ om_key = f"{actuator_id}-{operation_mode_id}"
+ if om_key in self.operation_modes:
+ return self.operation_modes[om_key]
+ actuators = target_timestep.get_system_description().actuators
+ found_actuator_description = next(
+ (ad for ad in actuators if str(ad.id) == actuator_id), None
+ )
+ if found_actuator_description:
+ for operation_mode in found_actuator_description.operation_modes:
+ if str(operation_mode.id) == operation_mode_id:
+ found_operation_mode = FrbcOperationModeWrapper(operation_mode)
+ self.operation_modes[om_key] = found_operation_mode
+ return found_operation_mode
+ return None
+
+ def operation_mode_uses_factor(
+ self,
+ target_timestep: FrbcTimestep,
+ actuator_id: str,
+ operation_mode_id: str,
+ ) -> bool:
+ key = f"{actuator_id}-{operation_mode_id}"
+ if key not in self.operation_mode_uses_factor_map:
+ result = self.get_operation_mode(
+ target_timestep, actuator_id, operation_mode_id
+ ).is_uses_factor()
+ self.operation_mode_uses_factor_map[key] = result
+ return self.operation_mode_uses_factor_map[key]
+
+ def get_all_possible_actuator_configurations(
+ self, target_timestep: FrbcTimestep
+ ) -> List[Dict[Any, S2ActuatorConfiguration]]:
+ timestep_date = target_timestep.get_start_date()
+ if timestep_date not in self.all_actions:
+ possible_actuator_configs = {}
+ for actuator_id in self.get_actuators(target_timestep):
+ actuator_list = []
+ for operation_mode_id in self.get_normal_operation_modes_for_actuator(
+ target_timestep, actuator_id
+ ):
+ if self.operation_mode_uses_factor(
+ target_timestep, actuator_id, operation_mode_id
+ ):
+ for i in range(self.nr_of_stratification_layers + 1):
+ factor_for_actuator = i * (
+ 1.0 / self.nr_of_stratification_layers
+ )
+ actuator_list.append(
+ S2ActuatorConfiguration(
+ operation_mode_id, factor_for_actuator
+ )
+ )
+ else:
+ actuator_list.append(
+ S2ActuatorConfiguration(operation_mode_id, 0.0)
+ )
+ possible_actuator_configs[actuator_id] = actuator_list
+ keys = list(possible_actuator_configs.keys())
+ actions_for_timestep = []
+ combination = [0] * len(keys)
+ actions_for_timestep.append(
+ self.combination_to_map(combination, keys, possible_actuator_configs)
+ )
+ while self.increase(combination, keys, possible_actuator_configs):
+ actions_for_timestep.append(
+ self.combination_to_map(
+ combination, keys, possible_actuator_configs
+ )
+ )
+ self.all_actions[timestep_date] = actions_for_timestep
+ return self.all_actions[timestep_date]
+
+ def combination_to_map(
+ self,
+ cur: List[int],
+ keys: List[str],
+ possible_actuator_configs: Dict[str, List[S2ActuatorConfiguration]],
+ ) -> Dict[str, S2ActuatorConfiguration]:
+ combination = {}
+ for i, key in enumerate(keys):
+ combination[key] = possible_actuator_configs[key][cur[i]]
+ return combination
+
+ def increase(
+ self,
+ cur: List[int],
+ keys: List[str],
+ possible_actuator_configs: Dict[str, List[S2ActuatorConfiguration]],
+ ) -> bool:
+ cur[0] += 1
+ for i, key in enumerate(keys):
+ if cur[i] >= len(possible_actuator_configs[key]):
+ if i + 1 >= len(keys):
+ return False
+ cur[i] = 0
+ cur[i + 1] += 1
+ return True
+
+ @staticmethod
+ def get_transition(
+ target_timestep,
+ actuator_id: str,
+ from_operation_mode_id: str,
+ to_operation_mode_id: str,
+ ) -> Optional[Transition]:
+
+ actuator_description = S2FrbcDeviceStateWrapper.get_actuator_description(
+ target_timestep, actuator_id
+ )
+ if actuator_description is None:
+ return None
+
+ for transition in actuator_description.transitions:
+ if (
+ str(transition.from_) == from_operation_mode_id
+ and str(transition.to) == to_operation_mode_id
+ ):
+ return transition
+ else:
+ # print(f"Transition {transition.from_} -> {transition.to} does not match {from_operation_mode_id} -> {to_operation_mode_id}")
+ pass
+ return None
+
+ def get_operation_mode_power(
+ self, om: FrbcOperationModeWrapper, fill_level: float, factor: float
+ ) -> float:
+
+ element = self.find_operation_mode_element(om, fill_level)
+ power_watt = 0
+ for power_range in element.get_power_ranges():
+ if power_range.commodity_quantity in [
+ CommodityQuantity.ELECTRIC_POWER_L1,
+ CommodityQuantity.ELECTRIC_POWER_L2,
+ CommodityQuantity.ELECTRIC_POWER_L3,
+ CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC,
+ ]:
+ start = power_range.start_of_range
+ end = power_range.end_of_range
+ power_watt += (end - start) * factor + start
+ return power_watt
+
+ @staticmethod
+ def find_operation_mode_element(om, fill_level):
+ element = next(
+ (
+ e
+ for e in om.get_elements()
+ if e.fill_level_range.start_of_range
+ <= fill_level
+ <= e.fill_level_range.end_of_range
+ ),
+ None,
+ )
+ if element is None:
+ first = om.get_elements()[0]
+ last = om.get_elements()[-1]
+ element = (
+ first
+ if fill_level < first.fill_level_range.start_of_range
+ else last
+ )
+ return element
+
+ def get_operation_mode_fill_rate(
+ self, om: FrbcOperationModeWrapper, fill_level: float, factor: float
+ ) -> float:
+ element = self.find_operation_mode_element(om, fill_level)
+ fill_rate = element.fill_rate
+ start = fill_rate.end_of_range
+ end = fill_rate.start_of_range
+ return (start - end) * factor + end
+
+ @staticmethod
+ def get_leakage_rate(target_timestep: FrbcTimestep, fill_level: float) -> float:
+ if target_timestep.get_leakage_behaviour() is None:
+ return 0
+ else:
+ return S2FrbcDeviceStateWrapper.find_leakage_element(
+ target_timestep, fill_level
+ ).leakage_rate
+
+ @staticmethod
+ def find_leakage_element(
+ target_timestep: FrbcTimestep, fill_level: float
+ ) -> FRBCLeakageBehaviourElement:
+ leakage = target_timestep.get_leakage_behaviour()
+ element = next(
+ (
+ e
+ for e in leakage.elements
+ if e.fill_level_range.start_of_range
+ <= fill_level
+ <= e.fill_level_range.end_of_range
+ ),
+ None,
+ )
+ if element is None:
+ first = leakage.elements[0]
+ last = leakage.elements[-1]
+ element = (
+ first if fill_level < first.fill_level_range.start_of_range else last
+ )
+ return element
+
+ @staticmethod
+ def calculate_bucket(target_timestep: FrbcTimestep, fill_level: float) -> int:
+ fill_level_lower_limit = (
+ target_timestep.get_system_description().storage.fill_level_range.start_of_range
+ )
+ fill_level_upper_limit = (
+ target_timestep.get_system_description().storage.fill_level_range.end_of_range
+ )
+ return int(
+ (fill_level - fill_level_lower_limit)
+ / (fill_level_upper_limit - fill_level_lower_limit)
+ * target_timestep.get_nr_of_buckets()
+ )
+
+ @staticmethod
+ def get_timer_duration_milliseconds(
+ target_timestep: FrbcTimestep, actuator_id: str, timer_id: str
+ ) -> int:
+ actuator_description = S2FrbcDeviceStateWrapper.get_actuator_description(
+ target_timestep, actuator_id
+ )
+ if actuator_description is None:
+ raise ValueError(
+ f"Actuator description not found for actuator {actuator_id}"
+ )
+ timer = next(
+ (t for t in actuator_description.timers if str(t.id) == timer_id),
+ None,
+ )
+ # Return the duration in milliseconds directly
+ return timer.duration.root if timer else 0
+
+ @staticmethod
+ def get_timer_duration(
+ target_timestep: FrbcTimestep, actuator_id: str, timer_id: str
+ ) -> timedelta:
+ return timedelta(
+ milliseconds=S2FrbcDeviceStateWrapper.get_timer_duration_milliseconds(
+ target_timestep, actuator_id, timer_id
+ )
+ )
+
+ @staticmethod
+ def get_actuator_description(
+ target_timestep: FrbcTimestep, actuator_id: str
+ ) -> Optional[FRBCActuatorDescription]:
+ return next(
+ (
+ ad
+ for ad in target_timestep.get_system_description().actuators
+ if str(ad.id) == actuator_id
+ ),
+ None,
+ )
+
+ def get_energy_in_current_timestep(self) -> CommodityQuantity:
+ return self.device_state.get_energy_in_current_timestep()
+
+ def get_actuator_statuses(self) -> List[FRBCActuatorStatus]:
+ return self.device_state.get_actuator_statuses()
+
+ def get_storage_status(self) -> List[FRBCStorageStatus]:
+ return self.device_state.get_storage_status()
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_instruction_profile.py b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_instruction_profile.py
new file mode 100644
index 0000000..7e99542
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_instruction_profile.py
@@ -0,0 +1,40 @@
+from typing import Dict, List
+from datetime import datetime, timedelta
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_actuator_configuration import (
+ S2ActuatorConfiguration,
+)
+
+
+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
+
+ def get_actuator_configuration(
+ self,
+ ) -> Dict[str, S2ActuatorConfiguration]:
+ return self.actuator_configuration
+
+ def __init__(
+ self,
+ profile_start: datetime,
+ timestep_duration: timedelta,
+ elements: List[Element],
+ ):
+ 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})"
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_plan.py b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_plan.py
new file mode 100644
index 0000000..8ce7a04
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/s2_frbc_plan.py
@@ -0,0 +1,30 @@
+from typing import List, Dict
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_actuator_configuration import (
+ S2ActuatorConfiguration,
+)
+
+
+class S2FrbcPlan:
+ def __init__(
+ self,
+ idle: bool,
+ energy,
+ fill_level,
+ operation_mode_id: List[Dict[str, S2ActuatorConfiguration]],
+ ):
+ self.idle = idle
+ self.energy = energy
+ self.fill_level = fill_level
+ self.operation_mode_id = operation_mode_id
+
+ def is_idle(self) -> bool:
+ return self.idle
+
+ def get_energy(self):
+ return self.energy
+
+ def get_fill_level(self):
+ return self.fill_level
+
+ def get_operation_mode_id(self) -> List[Dict[str, S2ActuatorConfiguration]]:
+ return self.operation_mode_id
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/selection_reason_result.py b/flexmeasures_s2/profile_steering/device_planner/frbc/selection_reason_result.py
new file mode 100644
index 0000000..757b859
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/selection_reason_result.py
@@ -0,0 +1,20 @@
+from enum import Enum
+
+
+class SelectionReason(Enum):
+ CONGESTION_CONSTRAINT = "C"
+ ENERGY_TARGET = "E"
+ TARIFF_TARGET = "T"
+ MIN_ENERGY = "M"
+ NO_ALTERNATIVE = "_"
+ EMERGENCY_STATE = "!"
+
+
+class SelectionResult:
+ def __init__(
+ self,
+ result: bool,
+ reason: SelectionReason,
+ ):
+ self.result = result
+ self.reason = reason
diff --git a/flexmeasures_s2/profile_steering/device_planner/frbc/usage_forecast_util.py b/flexmeasures_s2/profile_steering/device_planner/frbc/usage_forecast_util.py
new file mode 100644
index 0000000..9f9cbd6
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/device_planner/frbc/usage_forecast_util.py
@@ -0,0 +1,116 @@
+# TODO: Is this the same as FRBCUsageForecast?
+
+
+from datetime import timedelta, timezone
+from typing import List
+
+
+class UsageForecastElement:
+ def __init__(self, start, end, usage_rate):
+ self.start = start.replace(tzinfo=None)
+ self.end = end.replace(tzinfo=None)
+ self.usage_rate = usage_rate
+
+ def get_usage(self):
+ seconds = (
+ self.end - self.start
+ ).total_seconds() + 0.001 # one milisecond added for the offeset from total_Seconds()
+ return seconds * self.usage_rate
+
+ def split(self, date):
+ if self.date_in_element(date):
+ return [
+ UsageForecastElement(self.start, date, self.usage_rate),
+ UsageForecastElement(date, self.end, self.usage_rate),
+ ]
+ # If the date is not within the range, return the element itself twice to avoid index errors
+ return [self, self]
+
+ def date_in_element(self, date):
+ # Ensure both self.start and self.end are offset-naive or offset-aware
+ if self.start.tzinfo is None and self.end.tzinfo is None:
+ # Make date offset-naive
+ date = date.replace(tzinfo=None)
+ elif self.start.tzinfo is not None and self.end.tzinfo is not None:
+ # Make date offset-aware with the same timezone as self.start
+ date = date.astimezone(self.start.tzinfo)
+ else:
+ # If there's a mismatch, raise an error or handle it appropriately
+ raise ValueError("Mismatch between offset-naive and offset-aware datetimes")
+
+ return self.start <= date <= self.end
+
+
+class UsageForecastUtil:
+ @staticmethod
+ def from_storage_usage_profile(usage_forecast):
+ elements = []
+ start = usage_forecast.start_time
+ start = start.astimezone(timezone.utc)
+ for element in usage_forecast.elements:
+ end = start + timedelta(seconds=element.duration.root)
+ elements.append(
+ UsageForecastElement(start, end, element.usage_rate_expected)
+ )
+ start = end + timedelta(milliseconds=1)
+ return elements
+
+ @staticmethod
+ def sub_profile(
+ usage_forecast: List[UsageForecastElement], time_step_start, time_step_end
+ ):
+ if usage_forecast is None:
+ return 0
+
+ usage = 0
+ time_step_start = time_step_start.replace(tzinfo=None)
+ time_step_end = time_step_end.replace(tzinfo=None)
+
+ for element in usage_forecast:
+ element_start = element.start.replace(tzinfo=None)
+ element_end = element.end.replace(tzinfo=None)
+
+ if element_start < time_step_start < element_end < time_step_end:
+ # case 1: ....s.....e
+ # case 1: |-------|..
+ split = element.split(time_step_start)
+ usage += split[1].get_usage()
+ elif element_start > time_step_start and element_end < time_step_end:
+ # case 2: s...........e
+ # case 2: ..|-------|..
+ usage += element.get_usage()
+ elif time_step_start < element_start < time_step_end < element_end:
+ # case 3: s.....e....
+ # case 3: ..|-------|
+ split = element.split(time_step_end)
+ usage += split[0].get_usage()
+ elif element_start < time_step_start and element_end > time_step_end:
+ # case 4: ....s...e....
+ # case 4: |-----------|
+ split1 = element.split(time_step_start)
+ split2 = split1[1].split(time_step_end)
+ usage += split2[0].get_usage()
+ elif element_start == time_step_start and element_end == time_step_end:
+ # case 5: s...........e
+ # case 5: |-----------|
+ usage += element.get_usage()
+ elif element_start == time_step_start and element_end > time_step_end:
+ # case 6: s.....e......
+ # case 6: |-----------|
+ split = element.split(time_step_end)
+ usage += split[0].get_usage()
+ elif element_start == time_step_start and element_end < time_step_end:
+ # case 7: s...............e
+ # case 7: |-----------|....
+ usage += element.get_usage()
+ elif element_start < time_step_start and element_end == time_step_end:
+ # case 8: ......s.....e
+ # case 8: |-----------|
+ split = element.split(time_step_start)
+ usage += split[1].get_usage()
+ elif element_start > time_step_start and element_end == time_step_end:
+ # case 9: s...........e
+ # case 9: ...|--------|
+ usage += element.get_usage()
+
+ return usage
diff --git a/flexmeasures_s2/profile_steering/planning_service_impl.py b/flexmeasures_s2/profile_steering/planning_service_impl.py
new file mode 100644
index 0000000..2ce9560
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/planning_service_impl.py
@@ -0,0 +1,252 @@
+from datetime import datetime
+from typing import List, Dict, Any, Optional
+
+# Core profile steering imports
+from flexmeasures_s2.profile_steering.root_planner import RootPlanner
+from flexmeasures_s2.profile_steering.congestion_point_planner import (
+ CongestionPointPlanner,
+)
+
+# Common data types
+from flexmeasures_s2.profile_steering.common.joule_range_profile import (
+ JouleRangeProfile,
+)
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile
+
+# Import from common data structures to avoid circular imports
+from flexmeasures_s2.profile_steering.common_data_structures import (
+ ClusterState,
+ DevicePlan,
+)
+
+# Import cluster related classes
+from flexmeasures_s2.profile_steering.cluster_plan import ClusterPlan, ClusterPlanData
+from flexmeasures_s2.profile_steering.cluster_target import ClusterTarget
+
+# Device planner imports
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_planner import (
+ S2FrbcDevicePlanner,
+)
+from flexmeasures_s2.profile_steering.device_planner.frbc.s2_frbc_device_state import (
+ S2FrbcDeviceState,
+)
+
+# Logger setup
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PlanningServiceConfig:
+ """Configuration for the planning service."""
+
+ def __init__(
+ self,
+ energy_improvement_criterion: float = 0.01,
+ cost_improvement_criterion: float = 0.01,
+ congestion_retry_iterations: int = 5,
+ multithreaded: bool = False,
+ ):
+ self._energy_improvement_criterion = energy_improvement_criterion
+ self._cost_improvement_criterion = cost_improvement_criterion
+ self._congestion_retry_iterations = congestion_retry_iterations
+ self._multithreaded = multithreaded
+
+ def energy_improvement_criterion(self) -> float:
+ return self._energy_improvement_criterion
+
+ def cost_improvement_criterion(self) -> float:
+ return self._cost_improvement_criterion
+
+ def congestion_retry_iterations(self) -> int:
+ return self._congestion_retry_iterations
+
+ def multithreaded(self) -> bool:
+ return self._multithreaded
+
+
+class PlanningService:
+ """Interface for planning services."""
+
+ def plan(
+ self,
+ state: ClusterState,
+ target: ClusterTarget,
+ planning_window: int,
+ reason: str,
+ plan_due_by_date: datetime,
+ optimize_for_target: bool,
+ max_priority_class: int,
+ ) -> ClusterPlan:
+ """Create a plan for the cluster."""
+ raise NotImplementedError("Subclasses must implement this method")
+
+
+class PlanningServiceImpl(PlanningService):
+ """Implementation of the planning service."""
+
+ DEFAULT_CONGESTION_POINT = ""
+
+ def __init__(self, config: PlanningServiceConfig, context: Any = None):
+ """Initialize the planning service.
+
+ Args:
+ config: Configuration for the planning service
+ context: Execution context (used for multithreading)
+ """
+ self.config = config
+ self.context = context
+ logger.info("Planning service initialized")
+
+ def get_congestion_point(
+ self, cluster_state: ClusterState, connection_id: str
+ ) -> str:
+ """Get the congestion point for a connection ID.
+
+ Args:
+ cluster_state: The state of the cluster
+ connection_id: The connection ID to get the congestion point for
+
+ Returns:
+ The congestion point ID, or DEFAULT_CONGESTION_POINT if none is assigned
+ """
+ congestion_point = cluster_state.get_congestion_point(connection_id)
+ if congestion_point is None:
+ # This can happen if a device has no congestion point assigned yet.
+ # We handle this by giving them all the empty congestion point.
+ return self.DEFAULT_CONGESTION_POINT
+ return congestion_point
+
+ def create_controller_tree(
+ self,
+ cluster_state: ClusterState,
+ target: ClusterTarget,
+ plan_due_by_date: datetime,
+ ) -> RootPlanner:
+ """Create a tree of controllers for planning.
+
+ Args:
+ cluster_state: The state of the cluster
+ target: The target for the cluster
+ plan_due_by_date: The date by which planning must be completed
+
+ Returns:
+ A RootPlanner with appropriate device planners added
+ """
+ root_planner = RootPlanner(
+ target.get_global_target_profile(),
+ self.config.energy_improvement_criterion(),
+ self.config.cost_improvement_criterion(),
+ self.context,
+ )
+
+ for device_id, device_state in cluster_state.get_device_states().items():
+ congestion_point = self.get_congestion_point(
+ cluster_state, device_state.get_connection_id()
+ )
+ cpc = root_planner.get_congestion_point_controller(congestion_point)
+
+ if cpc is None:
+ # Create a new congestion point controller if one doesn't exist yet
+ congestion_point_target = target.get_congestion_point_target(
+ congestion_point
+ )
+ if congestion_point == self.DEFAULT_CONGESTION_POINT:
+ # This is a dummy congestion point. We will give it an empty profile.
+ congestion_point_target = JouleRangeProfile(
+ target.get_global_target_profile().get_profile_metadata(),
+ elements=congestion_point_target.get_elements(),
+ )
+
+ cpc = CongestionPointPlanner(congestion_point, congestion_point_target)
+ root_planner.add_congestion_point_controller(cpc)
+
+ # Add the appropriate device planner based on the device state type
+ if isinstance(device_state, S2FrbcDeviceState):
+ logger.debug("S2 FRBC planner created!")
+ cpc.add_device_controller(
+ S2FrbcDevicePlanner(
+ device_state, target.get_profile_metadata(), plan_due_by_date
+ )
+ )
+ # Add other device types here as needed
+ else:
+ logger.warning(
+ f"Unknown device! No device planner added for {device_state}"
+ )
+
+ return root_planner
+
+ def plan(
+ self,
+ state: ClusterState,
+ target: ClusterTarget,
+ planning_window: int,
+ reason: str,
+ plan_due_by_date: datetime,
+ optimize_for_target: bool,
+ max_priority_class: int,
+ ) -> ClusterPlan:
+ """Create a plan for the cluster.
+
+ Args:
+ state: The state of the cluster
+ target: The target for the cluster
+ planning_window: The planning window in seconds
+ reason: The reason for planning
+ plan_due_by_date: The date by which planning must be completed
+ optimize_for_target: Whether to optimize for the target
+ max_priority_class: The maximum priority class to optimize
+
+ Returns:
+ A ClusterPlan for the cluster
+ """
+ start_time = datetime.now()
+
+ # Make sure that all congestion points in the ClusterState have a target:
+ for cp in state.get_congestion_points():
+ congestion_point_target = target.get_congestion_point_target(cp)
+ if congestion_point_target is None and cp is not None:
+ # We don't have a target for the congestion point
+ logger.warning(
+ f"CongestionPoint without target! CongestionPoint: {cp}. Generating empty target."
+ )
+ target.set_congestion_point_target(
+ congestion_point_id=cp,
+ congestion_point_target=JouleRangeProfile(
+ profile_start=target.get_global_target_profile().get_profile_metadata(),
+ ),
+ )
+
+ # Create a tree of controllers and run the planning algorithm
+ root_controller = self.create_controller_tree(state, target, plan_due_by_date)
+
+ try:
+ root_controller.plan(
+ plan_due_by_date,
+ optimize_for_target,
+ max_priority_class,
+ self.config.multithreaded(),
+ )
+
+ # Collect device plans
+ device_plans = []
+ for cpc in root_controller.cp_controllers:
+ for device in cpc.get_device_controllers():
+ device_plans.append(device.get_device_plan())
+
+ # Create and return the cluster plan
+ plan_data = ClusterPlanData(device_plans, target.get_profile_metadata())
+ plan = ClusterPlan(state, target, plan_data, reason, plan_due_by_date, None)
+
+ end_time = datetime.now()
+ execution_time = (
+ end_time - start_time
+ ).total_seconds() * 1000 # Convert to milliseconds
+ logger.info(f"Generated new plan in {execution_time} ms")
+
+ return plan
+ except Exception as e:
+ logger.error(f"Error during planning: {e}")
+ raise
diff --git a/flexmeasures_s2/profile_steering/root_planner.py b/flexmeasures_s2/profile_steering/root_planner.py
new file mode 100644
index 0000000..51fa840
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/root_planner.py
@@ -0,0 +1,130 @@
+from typing import List, Any
+from datetime import datetime
+from flexmeasures_s2.profile_steering.common.joule_profile import JouleProfile
+from .congestion_point_planner import CongestionPointPlanner
+from flexmeasures_s2.profile_steering.common.proposal import Proposal
+from flexmeasures_s2.profile_steering.common.target_profile import TargetProfile
+
+
+class RootPlanner:
+ MAX_ITERATIONS = 1000
+
+ def __init__(
+ self,
+ target: TargetProfile,
+ energy_iteration_criterion: float,
+ cost_iteration_criterion: float,
+ context: Any,
+ ):
+ """
+ target: An object that provides a profile.
+ It must support a method get_profile_start(),
+ have attributes timestep_duration and nr_of_timesteps,
+ and (for optimization purposes) support a subtract() method.
+ context: In a full implementation, this might be an executor or similar. Here it is passed along.
+ """
+ self.context = context
+ self.target = self.remove_null_values(target)
+ self.energy_iteration_criterion = energy_iteration_criterion
+ self.cost_iteration_criterion = cost_iteration_criterion
+ # Create an empty JouleProfile.
+ # We assume that target exposes get_profile_start(), timestep_duration and nr_of_timesteps.
+ self.empty_profile = JouleProfile(
+ self.target.metadata.profile_start,
+ self.target.metadata.timestep_duration,
+ elements=[0] * self.target.metadata.nr_of_timesteps,
+ )
+ self.cp_controllers: List[CongestionPointPlanner] = []
+ self.root_ctrl_planning = self.empty_profile
+
+ def remove_null_values(self, target: Any) -> Any:
+ # TODO: Stub: simply return the target.
+ # In a full implementation, you would remove or replace null elements.
+ return target
+
+ def add_congestion_point_controller(self, cpc: CongestionPointPlanner):
+ self.cp_controllers.append(cpc)
+
+ def get_congestion_point_controller(self, cp_id: str) -> CongestionPointPlanner:
+ for cp in self.cp_controllers:
+ if cp.congestion_point_id == cp_id:
+ return cp
+ return None
+
+ def plan(
+ self,
+ plan_due_by_date: datetime,
+ optimize_for_target: bool,
+ max_priority_class_external: int,
+ multithreaded: bool = False,
+ ):
+ # Compute an initial plan by summing each congestion point's initial planning.
+ self.root_ctrl_planning = self.empty_profile
+ for cpc in self.cp_controllers:
+ initial_plan = cpc.create_initial_planning(plan_due_by_date)
+ self.root_ctrl_planning = self.root_ctrl_planning.add(initial_plan)
+
+ if not optimize_for_target:
+ return
+
+ if not self.cp_controllers:
+ return
+
+ # Determine maximum and minimum priority classes across congestion points.
+ max_priority_class = max(cpc.max_priority_class() for cpc in self.cp_controllers)
+ min_priority_class = min(cpc.min_priority_class() for cpc in self.cp_controllers)
+
+ # Iterate over the priority classes.
+ for priority_class in range(min_priority_class, min(max_priority_class, max_priority_class_external) + 1):
+ i = 0
+ best_proposal = None
+
+ # Simulate a do-while loop: we run at least once.
+ while True:
+ # Compute the difference profile
+ difference_profile = self.target.subtract(self.root_ctrl_planning)
+ best_proposal = None
+
+ # Get proposals from each congestion point controller
+ for cpc in self.cp_controllers:
+ print("Improving------------------------->")
+ try:
+ proposal = cpc.create_improved_planning(
+ difference_profile,
+ self.target.metadata,
+ priority_class,
+ plan_due_by_date,
+ )
+ if proposal is not None:
+ if best_proposal is None or proposal.is_preferred_to(best_proposal):
+ best_proposal = proposal
+ except Exception as e:
+ print(f"Error getting proposal from controller: {e}")
+ continue
+
+ if best_proposal is None:
+ # No proposal could be generated; exit inner loop.
+ break
+
+ # Update the root controller's planning based on the best proposal.
+ self.root_ctrl_planning = self.root_ctrl_planning.subtract(best_proposal.get_old_plan())
+ self.root_ctrl_planning = self.root_ctrl_planning.add(best_proposal.get_proposed_plan())
+
+ # Let the origin device/controller accept the proposal.
+ best_proposal.get_origin().accept_proposal(best_proposal)
+ i += 1
+ print(
+ f"Root controller: selected best controller '{best_proposal.get_origin().get_device_name()}' with global energy impr {best_proposal.get_global_improvement_value()}, congestion impr {best_proposal.get_congestion_improvement_value()}, iteration {i}."
+ )
+
+ # Check stopping criteria: if improvement values are below thresholds or max iterations reached.
+ if (
+ best_proposal.get_global_improvement_value() <= self.energy_iteration_criterion
+ ) or i >= self.MAX_ITERATIONS:
+ break
+
+ print(f"Optimizing priority class {priority_class} was done after {i} iterations.")
+ if i >= self.MAX_ITERATIONS:
+ print(
+ f"Warning: Optimization stopped due to iteration limit. Priority class: {priority_class}, Iterations: {i}"
+ )
diff --git a/flexmeasures_s2/profile_steering/s2_utils/__init__.py b/flexmeasures_s2/profile_steering/s2_utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/flexmeasures_s2/profile_steering/s2_utils/number_range_wrapper.py b/flexmeasures_s2/profile_steering/s2_utils/number_range_wrapper.py
new file mode 100644
index 0000000..1e47d5c
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/s2_utils/number_range_wrapper.py
@@ -0,0 +1,24 @@
+class NumberRangeWrapper:
+ def __init__(self, start_of_range, end_of_range):
+ self.start_of_range = start_of_range
+ self.end_of_range = end_of_range
+
+ def get_start_of_range(self):
+ return self.start_of_range
+
+ def get_end_of_range(self):
+ return self.end_of_range
+
+ def __eq__(self, other):
+ if not isinstance(other, NumberRangeWrapper):
+ return False
+ return (
+ self.start_of_range == other.start_of_range
+ and self.end_of_range == other.end_of_range
+ )
+
+ def __hash__(self):
+ return hash((self.start_of_range, self.end_of_range))
+
+ def __str__(self):
+ return f"NumberRangeWrapper(startOfRange={self.start_of_range}, endOfRange={self.end_of_range})"
diff --git a/flexmeasures_s2/profile_steering/tests/my plot - D = 10 - B = 20 - S = 10 - T = 160.png b/flexmeasures_s2/profile_steering/tests/my plot - D = 10 - B = 20 - S = 10 - T = 160.png
new file mode 100644
index 0000000..7683ed0
Binary files /dev/null and b/flexmeasures_s2/profile_steering/tests/my plot - D = 10 - B = 20 - S = 10 - T = 160.png differ
diff --git a/flexmeasures_s2/profile_steering/tests/plot_utils.py b/flexmeasures_s2/profile_steering/tests/plot_utils.py
new file mode 100644
index 0000000..dcb55d0
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/tests/plot_utils.py
@@ -0,0 +1,22 @@
+# Write a function to plot two given values against each other in a bar chart
+# the values are the execution time of the create_initial_planning function
+# the two values are the execution time of the Java and the execution time of the Python version
+# the values are in seconds
+from matplotlib import pyplot as plt
+
+
+def plot_values(java_time, python_time):
+ # Create a bar chart with the execution times
+ # make the bars different colors
+ # show the values on the bars
+ plt.bar(["Java", "Python"], [java_time, python_time], color=["red", "blue"])
+ # show the values on the bars
+ for i, v in enumerate([java_time, python_time]):
+ plt.text(i, v, str(v), ha="center", va="bottom")
+ plt.title("Execution time of create_initial_planning")
+ plt.xlabel("Language")
+ plt.ylabel("Time (seconds)")
+ plt.show()
+
+
+plot_values(0.231, 0.199840)
diff --git a/flexmeasures_s2/profile_steering/tests/profiling_10evs.txt b/flexmeasures_s2/profile_steering/tests/profiling_10evs.txt
new file mode 100644
index 0000000..81c12da
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/tests/profiling_10evs.txt
@@ -0,0 +1,95 @@
+Test the PlanningServiceImpl with an EV device.
+Generating plan!
+here
+here
+here
+here
+here
+here
+here
+here
+here
+here
+Current planning is within the congestion target range. Returning it.
+Improving------------------------->
+CP '': Selected best controller 'battery10' with improvement of 5.287368307500032e+16.
+Root controller: selected best controller 'battery10' with global energy impr 5.287368307500032e+16, congestion impr 0.0, iteration 1.
+Improving------------------------->
+CP '': Selected best controller 'battery9' with improvement of 4.405210942499994e+16.
+Root controller: selected best controller 'battery9' with global energy impr 4.405210942499994e+16, congestion impr 0.0, iteration 2.
+Improving------------------------->
+CP '': Selected best controller 'battery8' with improvement of 3.673598557500006e+16.
+Root controller: selected best controller 'battery8' with global energy impr 3.673598557500006e+16, congestion impr 0.0, iteration 3.
+Improving------------------------->
+CP '': Selected best controller 'battery7' with improvement of 2.9415809024999936e+16.
+Root controller: selected best controller 'battery7' with global energy impr 2.9415809024999936e+16, congestion impr 0.0, iteration 4.
+Improving------------------------->
+CP '': Selected best controller 'battery6' with improvement of 2.344441207499981e+16.
+Root controller: selected best controller 'battery6' with global energy impr 2.344441207499981e+16, congestion impr 0.0, iteration 5.
+Improving------------------------->
+CP '': Selected best controller 'battery5' with improvement of 1.9925078175000064e+16.
+Root controller: selected best controller 'battery5' with global energy impr 1.9925078175000064e+16, congestion impr 0.0, iteration 6.
+Improving------------------------->
+CP '': Selected best controller 'battery4' with improvement of 1.6354931625000448e+16.
+Root controller: selected best controller 'battery4' with global energy impr 1.6354931625000448e+16, congestion impr 0.0, iteration 7.
+Improving------------------------->
+CP '': Selected best controller 'battery3' with improvement of 1.2916190024999936e+16.
+Root controller: selected best controller 'battery3' with global energy impr 1.2916190024999936e+16, congestion impr 0.0, iteration 8.
+Improving------------------------->
+CP '': Selected best controller 'battery2' with improvement of 9499199624999936.0.
+Root controller: selected best controller 'battery2' with global energy impr 9499199624999936.0, congestion impr 0.0, iteration 9.
+Improving------------------------->
+CP '': Selected best controller 'battery1' with improvement of 5950983824999936.0.
+Root controller: selected best controller 'battery1' with global energy impr 5950983824999936.0, congestion impr 0.0, iteration 10.
+Improving------------------------->
+CP '': Selected best controller 'battery10' with improvement of 176728499999744.0.
+Root controller: selected best controller 'battery10' with global energy impr 176728499999744.0, congestion impr 0.0, iteration 11.
+Improving------------------------->
+CP '': Selected best controller 'battery10' with improvement of 0.0.
+Root controller: selected best controller 'battery10' with global energy impr 0.0, congestion impr 0.0, iteration 12.
+Optimizing priority class 1 was done after 12 iterations.
+Plan generated in 816.82 seconds
+Got cluster plan
+Got device plan
+
+ _ ._ __/__ _ _ _ _ _/_ Recorded: 09:22:09 Samples: 804852
+ /_//_/// /_\ / //_// / //_'/ // Duration: 3318.116 CPU time: 819.221
+/ _/ v5.0.1
+
+Program: test_frbc_device.py
+
+3318.113 test_frbc_device.py:1
+└─ 3316.660 test_planning_service_impl_with_ev_device test_frbc_device.py:582
+ ├─ 2499.828 plot_planning_results test_frbc_device.py:458
+ │ └─ 2499.460 show matplotlib/pyplot.py:569
+ │ [2 frames hidden] matplotlib
+ │ 2499.459 Tk.mainloop tkinter/__init__.py:1456
+ └─ 816.822 PlanningServiceImpl.plan ../planning_service_impl.py:181
+ └─ 816.669 RootPlanner.plan ../root_planner.py:54
+ ├─ 755.735 CongestionPointPlanner.create_improved_planning ../congestion_point_planner.py:144
+ │ └─ 755.530 S2FrbcDevicePlanner.create_improved_planning ../device_planner/frbc/s2_frbc_device_planner.py:98
+ │ └─ 755.442 OperationModeProfileTree.find_best_plan ../device_planner/frbc/operation_mode_profile_tree.py:259
+ │ └─ 752.563 FrbcState.generate_next_timestep_states ../device_planner/frbc/frbc_state.py:350
+ │ └─ 742.274 try_create_next_state ../device_planner/frbc/frbc_state.py:357
+ │ ├─ 587.781 FrbcState.__init__ ../device_planner/frbc/frbc_state.py:29
+ │ │ ├─ 155.578 [self] ../device_planner/frbc/frbc_state.py
+ │ │ ├─ 75.175 UUID.__init__ uuid.py:138
+ │ │ ├─ 65.173 S2FrbcDeviceStateWrapper.get_operation_mode_power ../device_planner/frbc/s2_frbc_device_state_wrapper.py:237
+ │ │ │ └─ 41.267 find_operation_mode_element ../device_planner/frbc/s2_frbc_device_state_wrapper.py:255
+ │ │ ├─ 62.648 FrbcTimestep.add_state ../device_planner/frbc/frbc_timestep.py:67
+ │ │ ├─ 50.049 S2FrbcDeviceStateWrapper.get_operation_mode_fill_rate ../device_planner/frbc/s2_frbc_device_state_wrapper.py:277
+ │ │ │ └─ 33.823 find_operation_mode_element ../device_planner/frbc/s2_frbc_device_state_wrapper.py:255
+ │ │ ├─ 41.714 S2FrbcDeviceStateWrapper.get_operation_mode ../device_planner/frbc/s2_frbc_device_state_wrapper.py:97
+ │ │ └─ 36.547 get_leakage_rate ../device_planner/frbc/s2_frbc_device_state_wrapper.py:286
+ │ ├─ 73.140 UUID.__init__ uuid.py:138
+ │ └─ 54.340 [self] ../device_planner/frbc/frbc_state.py
+ └─ 60.923 CongestionPointPlanner.create_initial_planning ../congestion_point_planner.py:51
+ └─ 60.922 S2FrbcDevicePlanner.create_initial_planning ../device_planner/frbc/s2_frbc_device_planner.py:136
+ └─ 60.922 OperationModeProfileTree.find_best_plan ../device_planner/frbc/operation_mode_profile_tree.py:259
+ └─ 60.786 FrbcState.generate_next_timestep_states ../device_planner/frbc/frbc_state.py:350
+ └─ 60.172 try_create_next_state ../device_planner/frbc/frbc_state.py:357
+ └─ 47.543 FrbcState.__init__ ../device_planner/frbc/frbc_state.py:29
+
+To view this report with different options, run:
+ pyinstrument --load-prev 2025-05-19T09-22-09 [options]
+
diff --git a/flexmeasures_s2/profile_steering/tests/profiling_20_buckets.txt b/flexmeasures_s2/profile_steering/tests/profiling_20_buckets.txt
new file mode 100644
index 0000000..d269bac
--- /dev/null
+++ b/flexmeasures_s2/profile_steering/tests/profiling_20_buckets.txt
@@ -0,0 +1,8244 @@
+Test the PlanningServiceImpl with an EV device.
+Generating plan!
+Current planning is within the congestion target range. Returning it.
+CP '': Selected best controller 'bat1' with improvement of 5914932975000000.0.
+Root controller: selected best controller 'bat1' with global energy impr 5914932975000000.0, congestion impr 0.0, iteration 1.
+CP '': Selected best controller 'bat1' with improvement of 0.0.
+Root controller: selected best controller 'bat1' with global energy impr 0.0, congestion impr 0.0, iteration 2.
+Optimizing priority class 1 was done after 2 iterations.
+Plan generated in 1.67 seconds
+Got cluster plan
+Got device plan
+
+ _ ._ __/__ _ _ _ _ _/_ Recorded: 16:11:14 Samples: 3233
+ /_//_/// /_\ / //_// / //_'/ // Duration: 5.397 CPU time: 4.711
+/ _/ v5.0.1
+
+Program: test_frbc_device.py
+
+5.394 test_frbc_device.py:1
+├─ 4.097 test_planning_service_impl_with_ev_device test_frbc_device.py:582
+│ ├─ 2.429 plot_planning_results test_frbc_device.py:526
+│ │ ├─ 2.082 show matplotlib/pyplot.py:569
+│ │ │ └─ 2.082 _BackendTkAgg.show matplotlib/backend_bases.py:3520
+│ │ │ └─ 2.082 FigureManagerTk.start_main_loop matplotlib/backends/_backend_tk.py:534
+│ │ │ └─ 2.082 Tk.mainloop tkinter/__init__.py:1456
+│ │ │ ├─ 1.799 [self] tkinter/__init__.py
+│ │ │ └─ 0.283 CallWrapper.__call__ tkinter/__init__.py:1916
+│ │ │ ├─ 0.272 callit tkinter/__init__.py:837
+│ │ │ │ ├─ 0.261 idle_draw matplotlib/backends/_backend_tk.py:272
+│ │ │ │ │ └─ 0.261 FigureCanvasTkAgg.draw matplotlib/backends/backend_tkagg.py:9
+│ │ │ │ │ ├─ 0.236 FigureCanvasTkAgg.draw matplotlib/backends/backend_agg.py:375
+│ │ │ │ │ │ ├─ 0.214 draw_wrapper matplotlib/artist.py:92
+│ │ │ │ │ │ │ └─ 0.214 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ └─ 0.214 Figure.draw matplotlib/figure.py:3237
+│ │ │ │ │ │ │ ├─ 0.209 _draw_list_compositing_images matplotlib/image.py:116
+│ │ │ │ │ │ │ │ └─ 0.209 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ │ └─ 0.209 Axes.draw matplotlib/axes/_base.py:3116
+│ │ │ │ │ │ │ │ ├─ 0.179 _draw_list_compositing_images matplotlib/image.py:116
+│ │ │ │ │ │ │ │ │ └─ 0.179 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ │ │ ├─ 0.164 XAxis.draw matplotlib/axis.py:1407
+│ │ │ │ │ │ │ │ │ │ ├─ 0.088 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.086 XTick.draw matplotlib/axis.py:268
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.086 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.050 Text.draw matplotlib/text.py:738
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.034 RendererAgg.draw_text matplotlib/backends/backend_agg.py:185
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.031 [self] matplotlib/backends/backend_agg.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 RendererAgg._prepare_font matplotlib/backends/backend_agg.py:248
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 FontManager._find_fonts_by_props matplotlib/font_manager.py:1363
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 FontManager.findfont matplotlib/font_manager.py:1293
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 FontProperties.__eq__ matplotlib/font_manager.py:711
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.__hash__ matplotlib/font_manager.py:700
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/font_manager.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/backends/backend_agg.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.008 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 [self] matplotlib/text.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 Affine2D.rotate_deg matplotlib/transforms.py:1995
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Affine2D.rotate matplotlib/transforms.py:1972
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 cos
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.invalidate matplotlib/transforms.py:155
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 _amin numpy/core/_methods.py:43
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ufunc.reduce
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 matplotlib/text.py:433
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 getattr
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 [self] matplotlib/text.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 _GeneratorContextManager.__enter__ contextlib.py:130
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Text._cm_set matplotlib/artist.py:1243
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Text. matplotlib/artist.py:146
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Text.set matplotlib/artist.py:1237
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 normalize_kwargs matplotlib/cbook.py:1742
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/artist.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 _GeneratorContextManager.__exit__ contextlib.py:139
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._cm_set matplotlib/artist.py:1243
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text. matplotlib/artist.py:146
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text.set matplotlib/artist.py:1237
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._internal_update matplotlib/artist.py:1226
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._update_props matplotlib/artist.py:1188
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _GeneratorContextManager.__enter__ contextlib.py:130
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _setattr_cm matplotlib/cbook.py:2010
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 getattr
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Text._set_gc_clip matplotlib/artist.py:935
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 GraphicsContextBase.set_clip_path matplotlib/backend_bases.py:848
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 check_isinstance matplotlib/_api/__init__.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 isinstance
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 GraphicsContextBase.set_foreground matplotlib/backend_bases.py:883
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 to_rgba matplotlib/colors.py:277
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _is_nth_color matplotlib/colors.py:218
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform matplotlib/transforms.py:1472
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform_affine matplotlib/transforms.py:2408
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.get_affine matplotlib/transforms.py:2431
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.__init__ matplotlib/transforms.py:1886
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ndarray.copy
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.036 Line2D.draw matplotlib/lines.py:744
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.006 RendererAgg.draw_path matplotlib/backends/backend_agg.py:93
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 [self] matplotlib/backends/backend_agg.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 TransformedBbox.__array__ matplotlib/transforms.py:236
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 TransformedBbox.get_points matplotlib/transforms.py:1108
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.006 Line2D.recache matplotlib/lines.py:672
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 broadcast_arrays numpy/lib/stride_tricks.py:480
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 _broadcast_shape numpy/lib/stride_tricks.py:416
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 numpy/lib/stride_tricks.py:546
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _broadcast_to numpy/lib/stride_tricks.py:340
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 numpy/lib/stride_tricks.py:345
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 numpy/lib/stride_tricks.py:538
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 Path.__init__ matplotlib/path.py:99
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Path._update_values matplotlib/path.py:202
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/path.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 column_stack numpy/lib/shape_base.py:612
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 array
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.006 RendererAgg.new_gc matplotlib/backend_bases.py:604
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.006 GraphicsContextBase.__init__ matplotlib/backend_bases.py:680
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 [self] matplotlib/backend_bases.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 to_rgba matplotlib/colors.py:277
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 _is_nth_color matplotlib/colors.py:218
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/colors.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CapStyle.__call__ enum.py:359
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 [self] matplotlib/lines.py
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 Line2D._get_transformed_path matplotlib/lines.py:732
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 Line2D._transform_path matplotlib/lines.py:717
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 TransformedPath.__init__ matplotlib/transforms.py:2751
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 TransformedPath.set_children matplotlib/transforms.py:179
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/transforms.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 check_isinstance matplotlib/_api/__init__.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 dict.items
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 TransformedPath.get_transformed_points_and_affine matplotlib/transforms.py:2779
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 TransformedPath._revalidate matplotlib/transforms.py:2766
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/transforms.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Path._fast_from_codes_and_verts matplotlib/path.py:162
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _to_unmasked_float_array matplotlib/cbook.py:1337
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 asarray
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 BlendedGenericTransform.transform_path_non_affine matplotlib/transforms.py:1612
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 to_rgba matplotlib/colors.py:277
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Line2D._set_gc_clip matplotlib/artist.py:935
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 GraphicsContextBase.set_clip_path matplotlib/backend_bases.py:848
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 check_isinstance matplotlib/_api/__init__.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 isinstance
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Affine2D.frozen matplotlib/transforms.py:1832
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.__init__ matplotlib/transforms.py:1886
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ndarray.copy
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Line2D._get_markerfacecolor matplotlib/lines.py:968
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 GraphicsContextBase.set_url matplotlib/backend_bases.py:918
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 TransformedPath.get_transformed_path_and_affine matplotlib/transforms.py:2790
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 TransformedPath._revalidate matplotlib/transforms.py:2766
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Path._fast_from_codes_and_verts matplotlib/path.py:162
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _to_unmasked_float_array matplotlib/cbook.py:1337
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Text.draw matplotlib/text.py:738
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 RendererAgg.draw_text matplotlib/backends/backend_agg.py:185
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/backends/backend_agg.py
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 round
+│ │ │ │ │ │ │ │ │ │ ├─ 0.030 XAxis._update_label_position matplotlib/axis.py:2449
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.020 XAxis._get_tick_boxes_siblings matplotlib/axis.py:2234
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.010 XAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 DateFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 matplotlib/ticker.py:217
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 DateFormatter.__call__ matplotlib/dates.py:589
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/dates.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 datetime64.tolist
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 datetime.astimezone
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 datetime.strftime
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 _interval_contains_close matplotlib/transforms.py:2917
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 XAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 MinuteLocator._create_rrule matplotlib/dates.py:1161
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 rrulewrapper.set matplotlib/dates.py:963
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 rrulewrapper._update_rrule matplotlib/dates.py:969
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _iterinfo.ddayset dateutil/rrule.py:1278
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 XAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 rrule.__iter__ dateutil/rrule.py:105
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.viewlim_to_dt matplotlib/dates.py:1099
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 datetime.replace
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.010 XAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.010 matplotlib/axis.py:1343
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.010 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.005 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 _amin numpy/core/_methods.py:43
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ufunc.reduce
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 array
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Affine2D.transform matplotlib/transforms.py:1782
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.transform_affine matplotlib/transforms.py:1848
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 PyCapsule.affine_transform
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 _amax numpy/core/_methods.py:39
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ufunc.reduce
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.__eq__ matplotlib/font_manager.py:711
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Bbox.translated matplotlib/transforms.py:616
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/text.py
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 helper contextlib.py:279
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _GeneratorContextManager.__init__ contextlib.py:102
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Text.get_transform matplotlib/artist.py:448
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform matplotlib/transforms.py:1472
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform_affine matplotlib/transforms.py:2408
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.get_affine matplotlib/transforms.py:2431
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.008 Spine.get_window_extent matplotlib/spines.py:142
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.008 XAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 DateFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 matplotlib/ticker.py:217
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 DateFormatter.__call__ matplotlib/dates.py:589
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 round numpy/core/fromnumeric.py:3269
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _wrapfunc numpy/core/fromnumeric.py:53
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 float64.round
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 GettzFunc.__call__ dateutil/tz/tz.py:1552
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 asanyarray
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 XAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 _iterinfo.ddayset dateutil/rrule.py:1278
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] dateutil/rrule.py
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 XAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 rrule.__mod_distance dateutil/rrule.py:1079
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 divmod
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _iterinfo.ddayset dateutil/rrule.py:1278
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 union matplotlib/transforms.py:641
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/transforms.py:646
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Bbox.xmin matplotlib/transforms.py:299
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/transforms.py
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 min numpy/core/fromnumeric.py:2836
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _wrapreduction numpy/core/fromnumeric.py:71
+│ │ │ │ │ │ │ │ │ │ ├─ 0.027 XAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.027 matplotlib/axis.py:1343
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.027 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.027 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.020 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.018 _get_text_metrics_with_cache_impl matplotlib/text.py:73
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.018 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.013 [self] matplotlib/backends/backend_agg.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 RendererAgg._prepare_font matplotlib/backends/backend_agg.py:248
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 get_font matplotlib/font_manager.py:1586
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/font_manager.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 matplotlib/font_manager.py:1610
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 isinstance
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontManager._find_fonts_by_props matplotlib/font_manager.py:1363
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontManager.findfont matplotlib/font_manager.py:1293
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/font_manager.py:1349
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 get_hinting_flag matplotlib/backends/backend_agg.py:41
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 FontProperties.__eq__ matplotlib/font_manager.py:711
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 [self] matplotlib/text.py
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 from_bounds matplotlib/transforms.py:795
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 from_extents matplotlib/transforms.py:804
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Bbox.__init__ matplotlib/transforms.py:749
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ndarray.copy
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Affine2D.__init__ matplotlib/transforms.py:1886
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text.get_figure matplotlib/artist.py:723
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Figure.get_figure matplotlib/figure.py:232
+│ │ │ │ │ │ │ │ │ │ ├─ 0.013 XAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 XAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 date2num matplotlib/dates.py:405
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 ndarray.astype
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/dates.py:447
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 datetime.replace
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.viewlim_to_dt matplotlib/dates.py:1099
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 numpy/lib/function_base.py:2453
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 isclose numpy/core/numeric.py:2249
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 within_tol numpy/core/numeric.py:2330
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 DateFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 matplotlib/ticker.py:217
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 DateFormatter.__call__ matplotlib/dates.py:589
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/dates.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 round numpy/core/fromnumeric.py:3269
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _wrapfunc numpy/core/fromnumeric.py:53
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 float64.round
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] numpy/lib/function_base.py
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 datetime.strftime
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 XAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 date2num matplotlib/dates.py:405
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ndarray.astype
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.viewlim_to_dt matplotlib/dates.py:1099
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 GettzFunc.__call__ dateutil/tz/tz.py:1552
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 _interval_contains_close matplotlib/transforms.py:2917
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 XTick.update_position matplotlib/axis.py:406
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Line2D.set_xdata matplotlib/lines.py:1276
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 iterable numpy/lib/function_base.py:348
+│ │ │ │ │ │ │ │ │ │ ├─ 0.004 YAxis._update_label_position matplotlib/axis.py:2676
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 YAxis._get_tick_boxes_siblings matplotlib/axis.py:2234
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 YAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/axis.py:1343
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/text.py
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 getattr
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Spine.get_window_extent matplotlib/spines.py:142
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 YTick.update_position matplotlib/axis.py:467
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 YTick.stale matplotlib/artist.py:315
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 YTick._stale_axes_callback matplotlib/artist.py:102
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Axes.stale matplotlib/artist.py:315
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Axes._stale_figure_callback matplotlib/figure.py:65
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Figure.stale matplotlib/artist.py:315
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _auto_draw_if_interactive matplotlib/pyplot.py:1074
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 is_interactive matplotlib/__init__.py:1339
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/figure.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 XAxis._update_offset_text_position matplotlib/axis.py:2475
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 union matplotlib/transforms.py:641
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 matplotlib/transforms.py:647
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Bbox.xmax matplotlib/transforms.py:309
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 max numpy/core/fromnumeric.py:2692
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _wrapreduction numpy/core/fromnumeric.py:71
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ufunc.reduce
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/transforms.py:648
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Bbox.ymin matplotlib/transforms.py:304
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 _min_dispatcher numpy/core/fromnumeric.py:2831
+│ │ │ │ │ │ │ │ │ ├─ 0.011 Legend.draw matplotlib/legend.py:734
+│ │ │ │ │ │ │ │ │ │ ├─ 0.008 draw_wrapper matplotlib/artist.py:30
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.008 VPacker.draw matplotlib/offsetbox.py:374
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.005 draw_wrapper matplotlib/artist.py:30
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.005 HPacker.draw matplotlib/offsetbox.py:374
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.005 draw_wrapper matplotlib/artist.py:30
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.005 VPacker.draw matplotlib/offsetbox.py:374
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 draw_wrapper matplotlib/artist.py:30
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 HPacker.draw matplotlib/offsetbox.py:374
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 draw_wrapper matplotlib/artist.py:30
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 DrawingArea.draw matplotlib/offsetbox.py:651
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Line2D.draw matplotlib/lines.py:744
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.draw_path matplotlib/backends/backend_agg.py:93
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 TextArea.draw matplotlib/offsetbox.py:785
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text.draw matplotlib/text.py:738
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _GeneratorContextManager.__enter__ contextlib.py:130
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._cm_set matplotlib/artist.py:1243
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text. matplotlib/artist.py:146
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text.set matplotlib/artist.py:1237
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._internal_update matplotlib/artist.py:1226
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._update_props matplotlib/artist.py:1188
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 helper contextlib.py:279
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _GeneratorContextManager.__init__ contextlib.py:102
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 HPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:473
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:479
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 TextArea.get_bbox matplotlib/offsetbox.py:762
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 VPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:441
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/offsetbox.py:452
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 HPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 HPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:473
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 matplotlib/offsetbox.py:479
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 TextArea.get_bbox matplotlib/offsetbox.py:762
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg._prepare_font matplotlib/backends/backend_agg.py:248
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontManager._find_fonts_by_props matplotlib/font_manager.py:1363
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _reconstruct copy.py:259
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:484
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Bbox.intervaly matplotlib/transforms.py:338
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 VPacker.get_offset matplotlib/offsetbox.py:54
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 VPacker.get_offset matplotlib/offsetbox.py:291
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Legend._findoffset matplotlib/legend.py:717
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Legend._find_best_position matplotlib/legend.py:1147
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/legend.py:1165
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Bbox.count_contains matplotlib/transforms.py:560
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 VPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:441
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:452
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 HPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 HPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:473
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:479
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 VPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 VPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:441
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:452
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 HPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 HPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:473
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:479
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 TextArea.get_bbox matplotlib/offsetbox.py:762
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 get_hinting_flag matplotlib/backends/backend_agg.py:41
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams._get matplotlib/__init__.py:698
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__
+│ │ │ │ │ │ │ │ │ │ └─ 0.003 VPacker.get_window_extent matplotlib/offsetbox.py:363
+│ │ │ │ │ │ │ │ │ │ ├─ 0.002 VPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 VPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:441
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/offsetbox.py:452
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 HPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 HPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:473
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/offsetbox.py:479
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 VPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 VPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:441
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/offsetbox.py:452
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 HPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 HPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:473
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/offsetbox.py:479
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 TextArea.get_bbox matplotlib/offsetbox.py:762
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache_impl matplotlib/text.py:73
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 get_hinting_flag matplotlib/backends/backend_agg.py:41
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams._get matplotlib/__init__.py:698
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 VPacker.get_offset matplotlib/offsetbox.py:54
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 VPacker.get_offset matplotlib/offsetbox.py:291
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Legend._findoffset matplotlib/legend.py:717
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Legend._find_best_position matplotlib/legend.py:1147
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Legend._get_anchored_bbox matplotlib/legend.py:1128
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_anchored_bbox matplotlib/offsetbox.py:1054
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Bbox.anchored matplotlib/transforms.py:479
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Bbox.bounds matplotlib/transforms.py:365
+│ │ │ │ │ │ │ │ │ ├─ 0.001 Rectangle.draw matplotlib/patches.py:633
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Rectangle._draw_paths_with_artist_properties matplotlib/patches.py:583
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.draw_path matplotlib/backends/backend_agg.py:93
+│ │ │ │ │ │ │ │ │ ├─ 0.001 Text.draw matplotlib/text.py:738
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.draw_text matplotlib/backends/backend_agg.py:185
+│ │ │ │ │ │ │ │ │ ├─ 0.001 Line2D.draw matplotlib/lines.py:744
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.draw_path matplotlib/backends/backend_agg.py:93
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 TransformedBbox.__array__ matplotlib/transforms.py:236
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 TransformedBbox.get_points matplotlib/transforms.py:1108
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform matplotlib/transforms.py:1472
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform_affine matplotlib/transforms.py:2408
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.transform matplotlib/transforms.py:1782
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.transform_affine matplotlib/transforms.py:1848
+│ │ │ │ │ │ │ │ │ └─ 0.001 Spine.draw matplotlib/spines.py:293
+│ │ │ │ │ │ │ │ │ └─ 0.001 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ │ │ └─ 0.001 Spine.draw matplotlib/patches.py:633
+│ │ │ │ │ │ │ │ │ └─ 0.001 Spine._draw_paths_with_artist_properties matplotlib/patches.py:583
+│ │ │ │ │ │ │ │ │ └─ 0.001 Spine._set_gc_clip matplotlib/artist.py:935
+│ │ │ │ │ │ │ │ │ └─ 0.001 GraphicsContextBase.set_clip_path matplotlib/backend_bases.py:848
+│ │ │ │ │ │ │ │ │ └─ 0.001 check_isinstance matplotlib/_api/__init__.py:65
+│ │ │ │ │ │ │ │ │ └─ 0.001 dict.items
+│ │ │ │ │ │ │ │ ├─ 0.029 Axes._update_title_position matplotlib/axes/_base.py:3044
+│ │ │ │ │ │ │ │ │ ├─ 0.026 YAxis.get_tightbbox matplotlib/axis.py:1348
+│ │ │ │ │ │ │ │ │ │ ├─ 0.013 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.012 YAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.012 AutoLocator.__call__ matplotlib/ticker.py:2226
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.012 AutoLocator.tick_values matplotlib/ticker.py:2230
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.012 AutoLocator._raw_ticks matplotlib/ticker.py:2157
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.012 YAxis.get_tick_space matplotlib/axis.py:2821
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.012 BboxTransformTo.__sub__ matplotlib/transforms.py:1418
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.012 Affine2D.inverted matplotlib/transforms.py:1869
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.012 inv numpy/linalg/linalg.py:492
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.011 [self] numpy/linalg/linalg.py
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ndarray.astype
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 YTick.update_position matplotlib/axis.py:467
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Line2D.set_ydata matplotlib/lines.py:1295
+│ │ │ │ │ │ │ │ │ │ ├─ 0.010 YAxis._update_label_position matplotlib/axis.py:2676
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.008 YAxis._get_tick_boxes_siblings matplotlib/axis.py:2234
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.005 YAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.005 matplotlib/axis.py:1343
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.005 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 _get_text_metrics_with_cache_impl matplotlib/text.py:73
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 [self] matplotlib/backends/backend_agg.py
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg._prepare_font matplotlib/backends/backend_agg.py:248
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontManager._find_fonts_by_props matplotlib/font_manager.py:1363
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontManager.findfont matplotlib/font_manager.py:1293
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.transform matplotlib/transforms.py:1782
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform matplotlib/transforms.py:1472
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform_affine matplotlib/transforms.py:2408
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.get_affine matplotlib/transforms.py:2431
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ScaledTranslation.get_matrix matplotlib/transforms.py:2676
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.transform matplotlib/transforms.py:1782
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.transform_affine matplotlib/transforms.py:1848
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 YAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 AutoLocator.__call__ matplotlib/ticker.py:2226
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 AutoLocator.tick_values matplotlib/ticker.py:2230
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 nonsingular matplotlib/transforms.py:2837
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 finfo.__new__ numpy/core/getlimits.py:484
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 AutoLocator._raw_ticks matplotlib/ticker.py:2157
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 YAxis.get_tick_space matplotlib/axis.py:2821
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 unit matplotlib/transforms.py:785
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 YAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 AutoLocator.__call__ matplotlib/ticker.py:2226
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 AutoLocator.tick_values matplotlib/ticker.py:2230
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 AutoLocator._raw_ticks matplotlib/ticker.py:2157
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 scale_range matplotlib/ticker.py:1982
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Spine.get_window_extent matplotlib/spines.py:142
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 YTick.update_position matplotlib/axis.py:467
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 YTick.stale matplotlib/artist.py:315
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 YTick._stale_axes_callback matplotlib/artist.py:102
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 union matplotlib/transforms.py:641
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/transforms.py:649
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Bbox.ymax matplotlib/transforms.py:314
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _max_dispatcher numpy/core/fromnumeric.py:2687
+│ │ │ │ │ │ │ │ │ │ ├─ 0.002 matplotlib/axis.py:1373
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache_impl matplotlib/text.py:73
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 Text.get_figure matplotlib/artist.py:723
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 YAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 matplotlib/axis.py:1343
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 _amax numpy/core/_methods.py:39
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 ufunc.reduce
+│ │ │ │ │ │ │ │ │ └─ 0.002 Text.get_tightbbox matplotlib/artist.py:348
+│ │ │ │ │ │ │ │ │ └─ 0.002 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ └─ 0.002 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ └─ 0.002 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ └─ 0.002 _get_text_metrics_with_cache_impl matplotlib/text.py:73
+│ │ │ │ │ │ │ │ │ └─ 0.002 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ └─ 0.001 Axes._unstale_viewLim matplotlib/axes/_base.py:851
+│ │ │ │ │ │ │ │ └─ 0.001 matplotlib/axes/_base.py:854
+│ │ │ │ │ │ │ │ └─ 0.001 Grouper.get_siblings matplotlib/cbook.py:871
+│ │ │ │ │ │ │ │ └─ 0.001 WeakKeyDictionary.get weakref.py:452
+│ │ │ │ │ │ │ └─ 0.004 draw_wrapper matplotlib/artist.py:53
+│ │ │ │ │ │ │ └─ 0.004 Rectangle.draw matplotlib/patches.py:633
+│ │ │ │ │ │ │ └─ 0.004 Rectangle._draw_paths_with_artist_properties matplotlib/patches.py:583
+│ │ │ │ │ │ │ └─ 0.004 RendererAgg.draw_path matplotlib/backends/backend_agg.py:93
+│ │ │ │ │ │ ├─ 0.018 _GeneratorContextManager.__enter__ contextlib.py:130
+│ │ │ │ │ │ │ └─ 0.018 NavigationToolbar2Tk._wait_cursor_for_draw_cm matplotlib/backend_bases.py:2907
+│ │ │ │ │ │ │ └─ 0.018 FigureCanvasTkAgg.set_cursor matplotlib/backends/_backend_tk.py:456
+│ │ │ │ │ │ │ └─ 0.018 Canvas.configure tkinter/__init__.py:1668
+│ │ │ │ │ │ │ └─ 0.018 Canvas._configure tkinter/__init__.py:1655
+│ │ │ │ │ │ │ └─ 0.018 tkapp.call
+│ │ │ │ │ │ └─ 0.005 FigureCanvasTkAgg.get_renderer matplotlib/backends/backend_agg.py:387
+│ │ │ │ │ │ └─ 0.005 RendererAgg.__init__ matplotlib/backends/backend_agg.py:63
+│ │ │ │ │ └─ 0.025 FigureCanvasTkAgg.blit matplotlib/backends/backend_tkagg.py:13
+│ │ │ │ │ └─ 0.025 blit matplotlib/backends/_backend_tk.py:70
+│ │ │ │ │ ├─ 0.024 _blit matplotlib/backends/_backend_tk.py:56
+│ │ │ │ │ │ └─ 0.024 PyCapsule.blit
+│ │ │ │ │ └─ 0.001 [self] matplotlib/backends/_backend_tk.py
+│ │ │ │ └─ 0.011 delayed_destroy matplotlib/backends/_backend_tk.py:597
+│ │ │ │ └─ 0.011 Tk.destroy tkinter/__init__.py:2337
+│ │ │ │ ├─ 0.006 NavigationToolbar2Tk.destroy tkinter/__init__.py:2606
+│ │ │ │ │ ├─ 0.005 Frame.destroy tkinter/__init__.py:2606
+│ │ │ │ │ │ └─ 0.005 tkapp.call
+│ │ │ │ │ └─ 0.001 tkapp.call
+│ │ │ │ └─ 0.005 tkapp.call
+│ │ │ ├─ 0.009 FigureCanvasTkAgg.resize matplotlib/backends/_backend_tk.py:251
+│ │ │ │ ├─ 0.008 PhotoImage.configure tkinter/__init__.py:4067
+│ │ │ │ │ └─ 0.008 tkapp.call
+│ │ │ │ └─ 0.001 ResizeEvent.__init__ matplotlib/backend_bases.py:1235
+│ │ │ │ └─ 0.001 bool.get_width_height matplotlib/backend_bases.py:1944
+│ │ │ │ └─ 0.001 TransformedBbox.max matplotlib/transforms.py:324
+│ │ │ │ └─ 0.001 TransformedBbox.get_points matplotlib/transforms.py:1108
+│ │ │ │ └─ 0.001 min
+│ │ │ └─ 0.003 FigureCanvasTkAgg.motion_notify_event matplotlib/backends/_backend_tk.py:296
+│ │ │ ├─ 0.002 MouseEvent._process matplotlib/backend_bases.py:1187
+│ │ │ │ └─ 0.002 CallbackRegistry.process matplotlib/cbook.py:348
+│ │ │ │ └─ 0.002 NavigationToolbar2Tk.mouse_move matplotlib/backend_bases.py:2951
+│ │ │ │ ├─ 0.001 NavigationToolbar2Tk.set_message matplotlib/backends/_backend_tk.py:721
+│ │ │ │ │ └─ 0.001 StringVar.set tkinter/__init__.py:400
+│ │ │ │ └─ 0.001 _mouse_event_to_message matplotlib/backend_bases.py:2929
+│ │ │ │ └─ 0.001 Axes.format_coord matplotlib/axes/_base.py:4054
+│ │ │ │ └─ 0.001 Axes.format_ydata matplotlib/axes/_base.py:4044
+│ │ │ │ └─ 0.001 matplotlib/transforms.py:195
+│ │ │ └─ 0.001 MouseEvent.__init__ matplotlib/backend_bases.py:1384
+│ │ │ └─ 0.001 MouseEvent.__init__ matplotlib/backend_bases.py:1266
+│ │ │ └─ 0.001 FigureCanvasTkAgg.inaxes matplotlib/backend_bases.py:1803
+│ │ │ └─ 0.001 matplotlib/backend_bases.py:1818
+│ │ │ └─ 0.001 Rectangle.contains_point matplotlib/patches.py:179
+│ │ │ └─ 0.001 Path.contains_point matplotlib/path.py:502
+│ │ │ └─ 0.001 CompositeGenericTransform.frozen matplotlib/transforms.py:2361
+│ │ │ └─ 0.001 CompositeGenericTransform.frozen matplotlib/transforms.py:2361
+│ │ │ └─ 0.001 composite_transform_factory matplotlib/transforms.py:2498
+│ │ │ └─ 0.001 CompositeAffine2D.__init__ matplotlib/transforms.py:2452
+│ │ │ └─ 0.001 CompositeAffine2D.__init__ matplotlib/transforms.py:1769
+│ │ ├─ 0.209 subplots matplotlib/pyplot.py:1620
+│ │ │ ├─ 0.196 figure matplotlib/pyplot.py:872
+│ │ │ │ └─ 0.196 new_figure_manager matplotlib/pyplot.py:549
+│ │ │ │ ├─ 0.186 _BackendTkAgg.new_figure_manager matplotlib/backend_bases.py:3494
+│ │ │ │ │ ├─ 0.185 _BackendTkAgg.new_figure_manager_given_figure matplotlib/backend_bases.py:3503
+│ │ │ │ │ │ └─ 0.185 FigureCanvasTkAgg.new_manager matplotlib/backend_bases.py:1772
+│ │ │ │ │ │ └─ 0.185 FigureManagerTk.create_with_canvas matplotlib/backends/_backend_tk.py:500
+│ │ │ │ │ │ ├─ 0.132 Tk.__init__ tkinter/__init__.py:2279
+│ │ │ │ │ │ │ └─ 0.132 create
+│ │ │ │ │ │ ├─ 0.042 FigureManagerTk.__init__ matplotlib/backends/_backend_tk.py:479
+│ │ │ │ │ │ │ └─ 0.042 FigureManagerTk.__init__ matplotlib/backend_bases.py:2609
+│ │ │ │ │ │ │ └─ 0.042 NavigationToolbar2Tk.__init__ matplotlib/backends/_backend_tk.py:622
+│ │ │ │ │ │ │ ├─ 0.040 NavigationToolbar2Tk._Button matplotlib/backends/_backend_tk.py:827
+│ │ │ │ │ │ │ │ ├─ 0.031 Button.__init__ tkinter/__init__.py:2660
+│ │ │ │ │ │ │ │ │ └─ 0.031 Button.__init__ tkinter/__init__.py:2589
+│ │ │ │ │ │ │ │ │ └─ 0.031 tkapp.call
+│ │ │ │ │ │ │ │ └─ 0.009 NavigationToolbar2Tk._set_image_for_button matplotlib/backends/_backend_tk.py:748
+│ │ │ │ │ │ │ │ ├─ 0.003 PngImageFile.convert PIL/Image.py:929
+│ │ │ │ │ │ │ │ │ ├─ 0.002 PngImageFile.load PIL/ImageFile.py:186
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 ImagingDecoder.decode
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 PngImageFile.load_prepare PIL/PngImagePlugin.py:971
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 PngImageFile.load_prepare PIL/ImageFile.py:323
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 new
+│ │ │ │ │ │ │ │ │ └─ 0.001 PngImageFile.copy PIL/Image.py:1265
+│ │ │ │ │ │ │ │ │ └─ 0.001 PngImageFile._new PIL/Image.py:587
+│ │ │ │ │ │ │ │ ├─ 0.002 Button.configure tkinter/__init__.py:1668
+│ │ │ │ │ │ │ │ │ └─ 0.002 Button._configure tkinter/__init__.py:1655
+│ │ │ │ │ │ │ │ │ └─ 0.002 tkapp.call
+│ │ │ │ │ │ │ │ ├─ 0.001 PhotoImage.__init__ PIL/ImageTk.py:92
+│ │ │ │ │ │ │ │ │ └─ 0.001 PhotoImage.__init__ tkinter/__init__.py:4098
+│ │ │ │ │ │ │ │ │ └─ 0.001 PhotoImage.__init__ tkinter/__init__.py:4033
+│ │ │ │ │ │ │ │ ├─ 0.001 _recolor_icon matplotlib/backends/_backend_tk.py:774
+│ │ │ │ │ │ │ │ ├─ 0.001 PosixPath.exists pathlib.py:1285
+│ │ │ │ │ │ │ │ │ └─ 0.001 PosixPath.stat pathlib.py:1092
+│ │ │ │ │ │ │ │ │ └─ 0.001 PosixPath.__fspath__ pathlib.py:631
+│ │ │ │ │ │ │ │ │ └─ 0.001 PosixPath.__str__ pathlib.py:621
+│ │ │ │ │ │ │ │ └─ 0.001 Image.resize PIL/Image.py:2250
+│ │ │ │ │ │ │ │ └─ 0.001 Image.convert PIL/Image.py:929
+│ │ │ │ │ │ │ │ └─ 0.001 ImagingCore.convert
+│ │ │ │ │ │ │ └─ 0.003 Label.__init__ tkinter/__init__.py:3169
+│ │ │ │ │ │ │ └─ 0.003 Label.__init__ tkinter/__init__.py:2589
+│ │ │ │ │ │ │ ├─ 0.002 tkapp.call
+│ │ │ │ │ │ │ └─ 0.001 Label._options tkinter/__init__.py:1497
+│ │ │ │ │ │ │ └─ 0.001 callable
+│ │ │ │ │ │ ├─ 0.008 FigureCanvasTkAgg.__init__ matplotlib/backends/_backend_tk.py:166
+│ │ │ │ │ │ │ ├─ 0.005 Canvas.create_image tkinter/__init__.py:2817
+│ │ │ │ │ │ │ │ └─ 0.005 Canvas._create tkinter/__init__.py:2797
+│ │ │ │ │ │ │ │ └─ 0.005 tkapp.call
+│ │ │ │ │ │ │ ├─ 0.002 PhotoImage.__init__ tkinter/__init__.py:4098
+│ │ │ │ │ │ │ │ └─ 0.002 PhotoImage.__init__ tkinter/__init__.py:4033
+│ │ │ │ │ │ │ │ └─ 0.002 tkapp.call
+│ │ │ │ │ │ │ └─ 0.001 bool.get_width_height matplotlib/backend_bases.py:1944
+│ │ │ │ │ │ │ └─ 0.001 TransformedBbox.max matplotlib/transforms.py:324
+│ │ │ │ │ │ │ └─ 0.001 TransformedBbox.get_points matplotlib/transforms.py:1108
+│ │ │ │ │ │ │ └─ 0.001 Affine2D.transform matplotlib/transforms.py:1782
+│ │ │ │ │ │ │ └─ 0.001 Affine2D.transform_affine matplotlib/transforms.py:1848
+│ │ │ │ │ │ │ └─ 0.001 NumpyVersion.__init__ numpy/lib/_version.py:55
+│ │ │ │ │ │ │ └─ 0.001 match re.py:187
+│ │ │ │ │ │ │ └─ 0.001 _compile re.py:288
+│ │ │ │ │ │ │ └─ 0.001 compile sre_compile.py:783
+│ │ │ │ │ │ │ └─ 0.001 _code sre_compile.py:622
+│ │ │ │ │ │ │ └─ 0.001 _compile sre_compile.py:87
+│ │ │ │ │ │ │ └─ 0.001 _compile sre_compile.py:87
+│ │ │ │ │ │ └─ 0.003 PhotoImage.__init__ PIL/ImageTk.py:92
+│ │ │ │ │ │ └─ 0.003 _get_image_from_kw PIL/ImageTk.py:42
+│ │ │ │ │ │ └─ 0.003 open PIL/Image.py:3409
+│ │ │ │ │ │ ├─ 0.002 preinit PIL/Image.py:344
+│ │ │ │ │ │ │ └─ 0.002 _handle_fromlist :1053
+│ │ │ │ │ │ │ └─ 0.002 _call_with_frames_removed :233
+│ │ │ │ │ │ │ └─ 0.002 _find_and_load :1022
+│ │ │ │ │ │ │ └─ 0.002 _find_and_load_unlocked :987
+│ │ │ │ │ │ │ └─ 0.002 _load_unlocked :664
+│ │ │ │ │ │ │ ├─ 0.001 SourceFileLoader.exec_module :877
+│ │ │ │ │ │ │ │ └─ 0.001 _call_with_frames_removed :233
+│ │ │ │ │ │ │ │ └─ 0.001 PIL/GifImagePlugin.py:1
+│ │ │ │ │ │ │ │ └─ 0.001 __new__ enum.py:180
+│ │ │ │ │ │ │ │ └─ 0.001 type.__new__
+│ │ │ │ │ │ │ └─ 0.001 module_from_spec :564
+│ │ │ │ │ │ │ └─ 0.001 _init_module_attrs :492
+│ │ │ │ │ │ └─ 0.001 _open_core PIL/Image.py:3482
+│ │ │ │ │ │ └─ 0.001 PngImageFile.__init__ PIL/ImageFile.py:113
+│ │ │ │ │ └─ 0.001 Figure.__init__ matplotlib/figure.py:2464
+│ │ │ │ │ └─ 0.001 Rectangle.__init__ matplotlib/patches.py:748
+│ │ │ │ │ └─ 0.001 Rectangle.__init__ matplotlib/patches.py:48
+│ │ │ │ │ └─ 0.001 Rectangle.set_hatch matplotlib/patches.py:541
+│ │ │ │ └─ 0.010 _warn_if_gui_out_of_main_thread matplotlib/pyplot.py:526
+│ │ │ │ └─ 0.010 _get_backend_mod matplotlib/pyplot.py:359
+│ │ │ │ └─ 0.010 switch_backend matplotlib/pyplot.py:373
+│ │ │ │ ├─ 0.008 switch_backend matplotlib/pyplot.py:373
+│ │ │ │ │ └─ 0.008 BackendRegistry.load_backend_module matplotlib/backends/registry.py:302
+│ │ │ │ │ └─ 0.008 import_module importlib/__init__.py:108
+│ │ │ │ │ └─ 0.008 _gcd_import :1038
+│ │ │ │ │ └─ 0.008 _find_and_load :1022
+│ │ │ │ │ └─ 0.008 _find_and_load_unlocked :987
+│ │ │ │ │ └─ 0.008 _load_unlocked :664
+│ │ │ │ │ └─ 0.008 SourceFileLoader.exec_module :877
+│ │ │ │ │ └─ 0.008 _call_with_frames_removed :233
+│ │ │ │ │ ├─ 0.006 matplotlib/backends/backend_tkagg.py:1
+│ │ │ │ │ │ └─ 0.006 _handle_fromlist :1053
+│ │ │ │ │ │ └─ 0.006 _call_with_frames_removed :233
+│ │ │ │ │ │ └─ 0.006 _find_and_load :1022
+│ │ │ │ │ │ └─ 0.006 _find_and_load_unlocked :987
+│ │ │ │ │ │ └─ 0.006 _load_unlocked :664
+│ │ │ │ │ │ └─ 0.006 SourceFileLoader.exec_module :877
+│ │ │ │ │ │ └─ 0.006 _call_with_frames_removed :233
+│ │ │ │ │ │ └─ 0.006 matplotlib/backends/_backend_tk.py:1
+│ │ │ │ │ │ ├─ 0.005 _find_and_load :1022
+│ │ │ │ │ │ │ └─ 0.005 _find_and_load_unlocked :987
+│ │ │ │ │ │ │ ├─ 0.004 _load_unlocked :664
+│ │ │ │ │ │ │ │ └─ 0.004 SourceFileLoader.exec_module :877
+│ │ │ │ │ │ │ │ └─ 0.004 _call_with_frames_removed :233
+│ │ │ │ │ │ │ │ ├─ 0.003 tkinter/__init__.py:1
+│ │ │ │ │ │ │ │ │ ├─ 0.002 _find_and_load :1022
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 _find_and_load_unlocked :987
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 _load_unlocked :664
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 module_from_spec :564
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 ExtensionFileLoader.create_module :1174
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 _call_with_frames_removed :233
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 create_dynamic
+│ │ │ │ │ │ │ │ │ └─ 0.001 __build_class__
+│ │ │ │ │ │ │ │ └─ 0.001 tkinter/filedialog.py:1
+│ │ │ │ │ │ │ │ └─ 0.001 _find_and_load :1022
+│ │ │ │ │ │ │ │ └─ 0.001 _find_and_load_unlocked :987
+│ │ │ │ │ │ │ │ └─ 0.001 _load_unlocked :664
+│ │ │ │ │ │ │ │ └─ 0.001 SourceFileLoader.exec_module :877
+│ │ │ │ │ │ │ │ └─ 0.001 SourceFileLoader.get_code :950
+│ │ │ │ │ │ │ │ └─ 0.001 _compile_bytecode :670
+│ │ │ │ │ │ │ │ └─ 0.001 loads
+│ │ │ │ │ │ │ └─ 0.001 _find_spec :921
+│ │ │ │ │ │ │ └─ 0.001 PathFinder.find_spec :1431
+│ │ │ │ │ │ │ └─ 0.001 PathFinder._get_spec :1399
+│ │ │ │ │ │ │ └─ 0.001 PathFinder._path_importer_cache :1356
+│ │ │ │ │ │ │ └─ 0.001 getcwd
+│ │ │ │ │ │ └─ 0.001 __build_class__
+│ │ │ │ │ ├─ 0.001 matplotlib/backends/backend_gtk4agg.py:1
+│ │ │ │ │ │ └─ 0.001 _handle_fromlist :1053
+│ │ │ │ │ │ └─ 0.001 _call_with_frames_removed :233
+│ │ │ │ │ │ └─ 0.001 _find_and_load :1022
+│ │ │ │ │ │ └─ 0.001 _find_and_load_unlocked :987
+│ │ │ │ │ │ └─ 0.001 _load_unlocked :664
+│ │ │ │ │ │ └─ 0.001 SourceFileLoader.exec_module :877
+│ │ │ │ │ │ └─ 0.001 SourceFileLoader.get_code :950
+│ │ │ │ │ │ └─ 0.001 _compile_bytecode :670
+│ │ │ │ │ │ └─ 0.001 loads
+│ │ │ │ │ └─ 0.001 matplotlib/backends/backend_qtagg.py:1
+│ │ │ │ │ └─ 0.001 _find_and_load :1022
+│ │ │ │ │ └─ 0.001 _find_and_load_unlocked :987
+│ │ │ │ │ └─ 0.001 _load_unlocked :664
+│ │ │ │ │ └─ 0.001 SourceFileLoader.exec_module :877
+│ │ │ │ │ └─ 0.001 _call_with_frames_removed :233
+│ │ │ │ │ └─ 0.001 matplotlib/backends/qt_compat.py:1
+│ │ │ │ │ └─ 0.001 _setup_pyqt5plus matplotlib/backends/qt_compat.py:66
+│ │ │ │ │ └─ 0.001 _find_and_load :1022
+│ │ │ │ │ └─ 0.001 _find_and_load_unlocked :987
+│ │ │ │ │ └─ 0.001 _find_spec :921
+│ │ │ │ │ └─ 0.001 PathFinder.find_spec :1431
+│ │ │ │ │ └─ 0.001 PathFinder._get_spec :1399
+│ │ │ │ │ └─ 0.001 FileFinder.find_spec :1536
+│ │ │ │ │ └─ 0.001 _path_join :126
+│ │ │ │ │ └─ 0.001 :128
+│ │ │ │ └─ 0.002 _get_running_interactive_framework matplotlib/cbook.py:58
+│ │ │ │ └─ 0.002 PyCapsule.display_is_valid
+│ │ │ └─ 0.013 Figure.subplots matplotlib/figure.py:785
+│ │ │ └─ 0.013 GridSpec.subplots matplotlib/gridspec.py:249
+│ │ │ └─ 0.013 Figure.add_subplot matplotlib/figure.py:644
+│ │ │ └─ 0.013 Axes.__init__ matplotlib/axes/_base.py:579
+│ │ │ ├─ 0.011 Axes.clear matplotlib/axes/_base.py:1409
+│ │ │ │ └─ 0.011 Axes.__clear matplotlib/axes/_base.py:1277
+│ │ │ │ ├─ 0.007 XAxis.clear matplotlib/axis.py:856
+│ │ │ │ │ ├─ 0.005 XAxis._set_scale matplotlib/axis.py:761
+│ │ │ │ │ │ └─ 0.005 LinearScale.set_default_locators_and_formatters matplotlib/scale.py:103
+│ │ │ │ │ │ └─ 0.005 ScalarFormatter.__init__ matplotlib/ticker.py:452
+│ │ │ │ │ │ └─ 0.005 ScalarFormatter.set_useMathText matplotlib/ticker.py:573
+│ │ │ │ │ │ └─ 0.005 FontManager.findfont matplotlib/font_manager.py:1293
+│ │ │ │ │ │ └─ 0.005 FontManager._findfont_cached matplotlib/font_manager.py:1453
+│ │ │ │ │ │ ├─ 0.002 [self] matplotlib/font_manager.py
+│ │ │ │ │ │ ├─ 0.001 FontManager.score_stretch matplotlib/font_manager.py:1233
+│ │ │ │ │ │ ├─ 0.001 FontManager.score_family matplotlib/font_manager.py:1175
+│ │ │ │ │ │ │ └─ 0.001 _expand_aliases matplotlib/font_manager.py:1167
+│ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ │ │ └─ 0.001 RcParams._get matplotlib/__init__.py:698
+│ │ │ │ │ │ └─ 0.001 FontManager.score_weight matplotlib/font_manager.py:1251
+│ │ │ │ │ └─ 0.002 Text._reset_visual_defaults matplotlib/text.py:157
+│ │ │ │ │ ├─ 0.001 Text.set_fontproperties matplotlib/text.py:1316
+│ │ │ │ │ │ └─ 0.001 FontProperties._from_any matplotlib/font_manager.py:677
+│ │ │ │ │ │ └─ 0.001 FontProperties.wrapper matplotlib/font_manager.py:556
+│ │ │ │ │ │ └─ 0.001 FontProperties.__init__ matplotlib/font_manager.py:656
+│ │ │ │ │ │ └─ 0.001 FontProperties.set_size matplotlib/font_manager.py:876
+│ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ └─ 0.001 Text.set_linespacing matplotlib/text.py:1040
+│ │ │ │ │ └─ 0.001 check_isinstance matplotlib/_api/__init__.py:65
+│ │ │ │ │ └─ 0.001 Real.__instancecheck__ abc.py:117
+│ │ │ │ ├─ 0.002 Axes.grid matplotlib/axes/_base.py:3273
+│ │ │ │ │ └─ 0.002 XAxis.grid matplotlib/axis.py:1707
+│ │ │ │ │ ├─ 0.001 XAxis.set_tick_params matplotlib/axis.py:957
+│ │ │ │ │ │ └─ 0.001 _LazyTickList.__get__ matplotlib/axis.py:534
+│ │ │ │ │ │ └─ 0.001 XAxis._get_tick matplotlib/axis.py:1596
+│ │ │ │ │ │ └─ 0.001 XTick.__init__ matplotlib/axis.py:367
+│ │ │ │ │ │ └─ 0.001 Axes.get_xaxis_transform matplotlib/axes/_base.py:928
+│ │ │ │ │ │ └─ 0.001 Spine.get_spine_transform matplotlib/spines.py:340
+│ │ │ │ │ │ └─ 0.001 Spine._ensure_position_is_set matplotlib/spines.py:203
+│ │ │ │ │ │ └─ 0.001 Spine.set_position matplotlib/spines.py:300
+│ │ │ │ │ └─ 0.001 [self] matplotlib/axis.py
+│ │ │ │ └─ 0.002 YAxis.set_clip_path matplotlib/axis.py:1126
+│ │ │ │ ├─ 0.001 YTick.set_clip_path matplotlib/axis.py:234
+│ │ │ │ │ └─ 0.001 YTick.set_clip_path matplotlib/artist.py:784
+│ │ │ │ │ └─ 0.001 TransformedBbox.__init__ matplotlib/transforms.py:1087
+│ │ │ │ └─ 0.001 _LazyTickList.__get__ matplotlib/axis.py:534
+│ │ │ │ └─ 0.001 XAxis._get_tick matplotlib/axis.py:1596
+│ │ │ │ └─ 0.001 XTick.__init__ matplotlib/axis.py:367
+│ │ │ │ └─ 0.001 Text. matplotlib/artist.py:146
+│ │ │ │ └─ 0.001 Text.set matplotlib/artist.py:1237
+│ │ │ │ └─ 0.001 Text._internal_update matplotlib/artist.py:1226
+│ │ │ │ └─ 0.001 Text._update_props matplotlib/artist.py:1188
+│ │ │ │ └─ 0.001 getattr
+│ │ │ ├─ 0.001 Axes._init_axis matplotlib/axes/_base.py:828
+│ │ │ │ └─ 0.001 XAxis.__init__ matplotlib/axis.py:2385
+│ │ │ │ └─ 0.001 XAxis.__init__ matplotlib/axis.py:612
+│ │ │ │ └─ 0.001 Text.__init__ matplotlib/text.py:104
+│ │ │ │ └─ 0.001 Text._reset_visual_defaults matplotlib/text.py:157
+│ │ │ │ └─ 0.001 Text.set_fontproperties matplotlib/text.py:1316
+│ │ │ │ └─ 0.001 FontProperties._from_any matplotlib/font_manager.py:677
+│ │ │ │ └─ 0.001 FontProperties.wrapper matplotlib/font_manager.py:556
+│ │ │ │ └─ 0.001 FontProperties.__init__ matplotlib/font_manager.py:656
+│ │ │ │ └─ 0.001 FontProperties.set_weight matplotlib/font_manager.py:824
+│ │ │ └─ 0.001 Axes.set_subplotspec matplotlib/axes/_base.py:803
+│ │ │ └─ 0.001 Axes._set_position matplotlib/axes/_base.py:1149
+│ │ │ └─ 0.001 Bbox.set matplotlib/transforms.py:1057
+│ │ │ └─ 0.001 any numpy/core/fromnumeric.py:2322
+│ │ ├─ 0.077 tight_layout matplotlib/pyplot.py:2827
+│ │ │ └─ 0.077 Figure.tight_layout matplotlib/figure.py:3608
+│ │ │ └─ 0.077 TightLayoutEngine.execute matplotlib/layout_engine.py:163
+│ │ │ ├─ 0.075 get_tight_layout_figure matplotlib/_tight_layout.py:194
+│ │ │ │ └─ 0.075 _auto_adjust_subplotpars matplotlib/_tight_layout.py:20
+│ │ │ │ ├─ 0.074 _get_tightbbox_for_layout_only matplotlib/artist.py:1395
+│ │ │ │ │ └─ 0.074 Axes.get_tightbbox matplotlib/axes/_base.py:4463
+│ │ │ │ │ ├─ 0.050 _get_tightbbox_for_layout_only matplotlib/artist.py:1395
+│ │ │ │ │ │ └─ 0.050 XAxis.get_tightbbox matplotlib/axis.py:1348
+│ │ │ │ │ │ ├─ 0.023 XAxis._update_label_position matplotlib/axis.py:2449
+│ │ │ │ │ │ │ ├─ 0.017 XAxis._get_tick_boxes_siblings matplotlib/axis.py:2234
+│ │ │ │ │ │ │ │ ├─ 0.012 XAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ │ │ │ └─ 0.012 matplotlib/axis.py:1343
+│ │ │ │ │ │ │ │ │ └─ 0.012 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ ├─ 0.010 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ │ ├─ 0.007 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.006 _get_text_metrics_with_cache_impl matplotlib/text.py:73
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.006 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 [self] matplotlib/backends/backend_agg.py
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.003 RendererAgg._prepare_font matplotlib/backends/backend_agg.py:248
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 FontManager._find_fonts_by_props matplotlib/font_manager.py:1363
+│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 FontManager.findfont matplotlib/font_manager.py:1293
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.__hash__ matplotlib/font_manager.py:700
+│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.get_weight matplotlib/font_manager.py:745
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 get_font matplotlib/font_manager.py:1586
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams._get matplotlib/__init__.py:698
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.__reduce_ex__
+│ │ │ │ │ │ │ │ │ │ ├─ 0.002 _amin numpy/core/_methods.py:43
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 ufunc.reduce
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 array
+│ │ │ │ │ │ │ │ │ └─ 0.002 CompositeGenericTransform.transform matplotlib/transforms.py:1472
+│ │ │ │ │ │ │ │ │ └─ 0.002 CompositeGenericTransform.transform_affine matplotlib/transforms.py:2408
+│ │ │ │ │ │ │ │ │ └─ 0.002 CompositeGenericTransform.get_affine matplotlib/transforms.py:2431
+│ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/transforms.py
+│ │ │ │ │ │ │ │ │ └─ 0.001 BlendedGenericTransform.get_affine matplotlib/transforms.py:2248
+│ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.get_affine matplotlib/transforms.py:2431
+│ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.get_affine matplotlib/transforms.py:2431
+│ │ │ │ │ │ │ │ │ └─ 0.001 BboxTransformFrom.get_matrix matplotlib/transforms.py:2644
+│ │ │ │ │ │ │ │ │ └─ 0.001 TransformedBbox.bounds matplotlib/transforms.py:365
+│ │ │ │ │ │ │ │ │ └─ 0.001 TransformedBbox.get_points matplotlib/transforms.py:1108
+│ │ │ │ │ │ │ │ └─ 0.005 XAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ ├─ 0.002 DateFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/ticker.py:217
+│ │ │ │ │ │ │ │ │ └─ 0.002 DateFormatter.__call__ matplotlib/dates.py:589
+│ │ │ │ │ │ │ │ │ └─ 0.002 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ │ ├─ 0.001 _get_tzinfo matplotlib/dates.py:208
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 isinstance
+│ │ │ │ │ │ │ │ │ └─ 0.001 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ │ └─ 0.001 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ │ └─ 0.001 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ │ └─ 0.001 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ │ │ └─ 0.001 datetime.astimezone
+│ │ │ │ │ │ │ │ ├─ 0.001 XAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ │ │ │ ├─ 0.001 XAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrule.__mod_distance dateutil/rrule.py:1079
+│ │ │ │ │ │ │ │ └─ 0.001 XTick.get_loc matplotlib/axis.py:264
+│ │ │ │ │ │ │ ├─ 0.005 Spine.get_window_extent matplotlib/spines.py:142
+│ │ │ │ │ │ │ │ └─ 0.005 XAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ ├─ 0.002 DateFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/ticker.py:217
+│ │ │ │ │ │ │ │ │ └─ 0.002 DateFormatter.__call__ matplotlib/dates.py:589
+│ │ │ │ │ │ │ │ │ └─ 0.002 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ │ └─ 0.002 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ │ └─ 0.002 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ │ └─ 0.002 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ │ ├─ 0.001 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ │ │ └─ 0.001 numpy/lib/function_base.py:2453
+│ │ │ │ │ │ │ │ │ └─ 0.001 asanyarray
+│ │ │ │ │ │ │ │ ├─ 0.001 XAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ │ │ │ ├─ 0.001 XAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator._create_rrule matplotlib/dates.py:1161
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrulewrapper.set matplotlib/dates.py:963
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrulewrapper._update_rrule matplotlib/dates.py:969
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrule.__init__ dateutil/rrule.py:428
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrule.__construct_byset dateutil/rrule.py:1032
+│ │ │ │ │ │ │ │ └─ 0.001 XTick.update_position matplotlib/axis.py:406
+│ │ │ │ │ │ │ │ └─ 0.001 XTick.stale matplotlib/artist.py:315
+│ │ │ │ │ │ │ │ └─ 0.001 XTick._stale_axes_callback matplotlib/artist.py:102
+│ │ │ │ │ │ │ └─ 0.001 union matplotlib/transforms.py:641
+│ │ │ │ │ │ │ └─ 0.001 matplotlib/transforms.py:649
+│ │ │ │ │ │ │ └─ 0.001 Bbox.ymax matplotlib/transforms.py:314
+│ │ │ │ │ │ │ └─ 0.001 max numpy/core/fromnumeric.py:2692
+│ │ │ │ │ │ │ └─ 0.001 _wrapreduction numpy/core/fromnumeric.py:71
+│ │ │ │ │ │ │ └─ 0.001 ufunc.reduce
+│ │ │ │ │ │ ├─ 0.015 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ ├─ 0.010 YAxis.get_major_ticks matplotlib/axis.py:1655
+│ │ │ │ │ │ │ │ ├─ 0.007 YAxis._get_tick matplotlib/axis.py:1596
+│ │ │ │ │ │ │ │ │ └─ 0.007 YTick.__init__ matplotlib/axis.py:428
+│ │ │ │ │ │ │ │ │ ├─ 0.006 YTick.__init__ matplotlib/axis.py:59
+│ │ │ │ │ │ │ │ │ │ ├─ 0.002 Line2D.get_path matplotlib/lines.py:1035
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Line2D.recache matplotlib/lines.py:672
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Path.__init__ matplotlib/path.py:99
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/path.py
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 check_shape matplotlib/_api/__init__.py:133
+│ │ │ │ │ │ │ │ │ │ ├─ 0.002 Text.__init__ matplotlib/text.py:104
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.002 Text._reset_visual_defaults matplotlib/text.py:157
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 Text.set_fontproperties matplotlib/text.py:1316
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties._from_any matplotlib/font_manager.py:677
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.wrapper matplotlib/font_manager.py:556
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.__init__ matplotlib/font_manager.py:656
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.set_family matplotlib/font_manager.py:784
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/text.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 Line2D.__init__ matplotlib/lines.py:287
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 Line2D.set_linewidth matplotlib/lines.py:1129
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _scale_dashes matplotlib/lines.py:75
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams._get matplotlib/__init__.py:698
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Line2D.set_dash_capstyle matplotlib/lines.py:1409
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 CapStyle.__call__ enum.py:359
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 CapStyle.__new__ enum.py:678
+│ │ │ │ │ │ │ │ │ └─ 0.001 YTick._get_text2_transform matplotlib/axis.py:453
+│ │ │ │ │ │ │ │ │ └─ 0.001 Axes.get_yaxis_text2_transform matplotlib/axes/_base.py:1065
+│ │ │ │ │ │ │ │ │ └─ 0.001 BlendedGenericTransform.__add__ matplotlib/transforms.py:1340
+│ │ │ │ │ │ │ │ │ └─ 0.001 composite_transform_factory matplotlib/transforms.py:2498
+│ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.__init__ matplotlib/transforms.py:2341
+│ │ │ │ │ │ │ │ └─ 0.003 YAxis._copy_tick_props matplotlib/axis.py:1617
+│ │ │ │ │ │ │ │ └─ 0.003 Line2D.update_from matplotlib/lines.py:1338
+│ │ │ │ │ │ │ │ └─ 0.003 MarkerStyle.__init__ matplotlib/markers.py:220
+│ │ │ │ │ │ │ │ ├─ 0.002 MarkerStyle._set_marker matplotlib/markers.py:299
+│ │ │ │ │ │ │ │ │ └─ 0.002 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ │ └─ 0.002 _deepcopy_dict copy.py:227
+│ │ │ │ │ │ │ │ │ └─ 0.002 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/markers.py
+│ │ │ │ │ │ │ ├─ 0.002 YAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ ├─ 0.001 AutoLocator.__call__ matplotlib/ticker.py:2226
+│ │ │ │ │ │ │ │ │ └─ 0.001 AutoLocator.tick_values matplotlib/ticker.py:2230
+│ │ │ │ │ │ │ │ │ └─ 0.001 AutoLocator._raw_ticks matplotlib/ticker.py:2157
+│ │ │ │ │ │ │ │ │ └─ 0.001 YAxis.get_tick_space matplotlib/axis.py:2821
+│ │ │ │ │ │ │ │ │ └─ 0.001 BboxTransformTo.__sub__ matplotlib/transforms.py:1418
+│ │ │ │ │ │ │ │ │ └─ 0.001 Affine2D.inverted matplotlib/transforms.py:1869
+│ │ │ │ │ │ │ │ │ └─ 0.001 inv numpy/linalg/linalg.py:492
+│ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ └─ 0.001 matplotlib/dates.py:1041
+│ │ │ │ │ │ │ │ └─ 0.001 rrulewrapper._attach_tzinfo matplotlib/dates.py:1000
+│ │ │ │ │ │ │ │ └─ 0.001 datetime.replace
+│ │ │ │ │ │ │ ├─ 0.001 DateFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ │ └─ 0.001 matplotlib/ticker.py:217
+│ │ │ │ │ │ │ │ └─ 0.001 DateFormatter.__call__ matplotlib/dates.py:589
+│ │ │ │ │ │ │ │ └─ 0.001 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ └─ 0.001 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ └─ 0.001 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ └─ 0.001 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ └─ 0.001 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ ├─ 0.001 XAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ └─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ └─ 0.001 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ │ │ │ └─ 0.001 _iterinfo.mtimeset dateutil/rrule.py:1294
+│ │ │ │ │ │ │ └─ 0.001 XTick.update_position matplotlib/axis.py:406
+│ │ │ │ │ │ │ └─ 0.001 Line2D.set_xdata matplotlib/lines.py:1276
+│ │ │ │ │ │ │ └─ 0.001 iterable numpy/lib/function_base.py:348
+│ │ │ │ │ │ ├─ 0.006 XAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ │ └─ 0.006 matplotlib/axis.py:1343
+│ │ │ │ │ │ │ └─ 0.006 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ ├─ 0.004 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ ├─ 0.002 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ ├─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 isinstance
+│ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.__eq__ matplotlib/font_manager.py:711
+│ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.__hash__ matplotlib/font_manager.py:700
+│ │ │ │ │ │ │ │ ├─ 0.001 _amin numpy/core/_methods.py:43
+│ │ │ │ │ │ │ │ │ └─ 0.001 ufunc.reduce
+│ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/text.py
+│ │ │ │ │ │ │ ├─ 0.001 CompositeGenericTransform.transform matplotlib/transforms.py:1472
+│ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform_affine matplotlib/transforms.py:2408
+│ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.get_affine matplotlib/transforms.py:2431
+│ │ │ │ │ │ │ │ └─ 0.001 ScaledTranslation.get_matrix matplotlib/transforms.py:2676
+│ │ │ │ │ │ │ └─ 0.001 _GeneratorContextManager.__exit__ contextlib.py:139
+│ │ │ │ │ │ │ └─ 0.001 next
+│ │ │ │ │ │ ├─ 0.004 YAxis._update_label_position matplotlib/axis.py:2676
+│ │ │ │ │ │ │ ├─ 0.003 YAxis._get_tick_boxes_siblings matplotlib/axis.py:2234
+│ │ │ │ │ │ │ │ ├─ 0.002 YAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ │ │ │ └─ 0.002 matplotlib/axis.py:1343
+│ │ │ │ │ │ │ │ │ └─ 0.002 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ │ │ ├─ 0.001 CompositeGenericTransform.transform matplotlib/transforms.py:1472
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.transform_affine matplotlib/transforms.py:2408
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 CompositeGenericTransform.get_affine matplotlib/transforms.py:2431
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 BlendedGenericTransform.get_affine matplotlib/transforms.py:2248
+│ │ │ │ │ │ │ │ │ └─ 0.001 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ │ └─ 0.001 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ │ │ │ │ └─ 0.001 _reconstruct copy.py:259
+│ │ │ │ │ │ │ │ │ └─ 0.001 FontProperties.__newobj__ copyreg.py:100
+│ │ │ │ │ │ │ │ └─ 0.001 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ └─ 0.001 ScalarFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ │ └─ 0.001 ScalarFormatter.set_locs matplotlib/ticker.py:735
+│ │ │ │ │ │ │ └─ 0.001 union matplotlib/transforms.py:641
+│ │ │ │ │ │ │ └─ 0.001 matplotlib/transforms.py:647
+│ │ │ │ │ │ │ └─ 0.001 Bbox.xmax matplotlib/transforms.py:309
+│ │ │ │ │ │ │ └─ 0.001 max numpy/core/fromnumeric.py:2692
+│ │ │ │ │ │ │ └─ 0.001 _wrapreduction numpy/core/fromnumeric.py:71
+│ │ │ │ │ │ ├─ 0.001 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ └─ 0.001 from_bounds matplotlib/transforms.py:795
+│ │ │ │ │ │ │ └─ 0.001 from_extents matplotlib/transforms.py:804
+│ │ │ │ │ │ │ └─ 0.001 reshape numpy/core/fromnumeric.py:200
+│ │ │ │ │ │ │ └─ 0.001 _wrapfunc numpy/core/fromnumeric.py:53
+│ │ │ │ │ │ │ └─ 0.001 _wrapit numpy/core/fromnumeric.py:40
+│ │ │ │ │ │ │ └─ 0.001 asarray
+│ │ │ │ │ │ └─ 0.001 XAxis._update_offset_text_position matplotlib/axis.py:2475
+│ │ │ │ │ │ └─ 0.001 union matplotlib/transforms.py:641
+│ │ │ │ │ │ └─ 0.001 matplotlib/transforms.py:646
+│ │ │ │ │ ├─ 0.011 Spine.get_tightbbox matplotlib/artist.py:348
+│ │ │ │ │ │ └─ 0.011 Spine.get_window_extent matplotlib/spines.py:142
+│ │ │ │ │ │ ├─ 0.010 XAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ ├─ 0.004 DateFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ │ └─ 0.004 matplotlib/ticker.py:217
+│ │ │ │ │ │ │ │ └─ 0.004 DateFormatter.__call__ matplotlib/dates.py:589
+│ │ │ │ │ │ │ │ └─ 0.004 num2date matplotlib/dates.py:457
+│ │ │ │ │ │ │ │ └─ 0.004 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ │ │ │ └─ 0.004 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ │ │ │ └─ 0.004 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ │ │ │ ├─ 0.003 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/dates.py
+│ │ │ │ │ │ │ │ │ ├─ 0.001 datetime.replace
+│ │ │ │ │ │ │ │ │ └─ 0.001 GettzFunc.__call__ dateutil/tz/tz.py:1552
+│ │ │ │ │ │ │ │ └─ 0.001 numpy/lib/function_base.py:2453
+│ │ │ │ │ │ │ │ └─ 0.001 asanyarray
+│ │ │ │ │ │ │ ├─ 0.002 XAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ └─ 0.002 date2num matplotlib/dates.py:405
+│ │ │ │ │ │ │ │ ├─ 0.001 ndarray.astype
+│ │ │ │ │ │ │ │ └─ 0.001 asarray
+│ │ │ │ │ │ │ ├─ 0.002 XAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ │ │ │ └─ 0.002 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ │ │ │ ├─ 0.001 MinuteLocator._create_rrule matplotlib/dates.py:1161
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrulewrapper.set matplotlib/dates.py:963
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrulewrapper._update_rrule matplotlib/dates.py:969
+│ │ │ │ │ │ │ │ │ └─ 0.001 rrule.__init__ dateutil/rrule.py:428
+│ │ │ │ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ │ │ │ └─ 0.001 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ │ │ │ └─ 0.001 _iterinfo.ddayset dateutil/rrule.py:1278
+│ │ │ │ │ │ │ └─ 0.002 XTick.update_position matplotlib/axis.py:406
+│ │ │ │ │ │ │ ├─ 0.001 Text.set_x matplotlib/text.py:1205
+│ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/axis.py
+│ │ │ │ │ │ └─ 0.001 union matplotlib/transforms.py:641
+│ │ │ │ │ │ └─ 0.001 max numpy/core/fromnumeric.py:2692
+│ │ │ │ │ │ └─ 0.001 _wrapreduction numpy/core/fromnumeric.py:71
+│ │ │ │ │ │ └─ 0.001 ufunc.reduce
+│ │ │ │ │ ├─ 0.010 Axes._update_title_position matplotlib/axes/_base.py:3044
+│ │ │ │ │ │ ├─ 0.006 Text.get_tightbbox matplotlib/artist.py:348
+│ │ │ │ │ │ │ └─ 0.006 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ │ ├─ 0.005 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ │ │ └─ 0.005 _get_text_metrics_with_cache matplotlib/text.py:65
+│ │ │ │ │ │ │ │ └─ 0.005 _get_text_metrics_with_cache_impl matplotlib/text.py:73
+│ │ │ │ │ │ │ │ └─ 0.005 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ │ │ └─ 0.005 RendererAgg._prepare_font matplotlib/backends/backend_agg.py:248
+│ │ │ │ │ │ │ │ └─ 0.005 FontManager._find_fonts_by_props matplotlib/font_manager.py:1363
+│ │ │ │ │ │ │ │ └─ 0.005 FontManager.findfont matplotlib/font_manager.py:1293
+│ │ │ │ │ │ │ │ └─ 0.005 FontManager._findfont_cached matplotlib/font_manager.py:1453
+│ │ │ │ │ │ │ │ ├─ 0.003 FontManager.score_weight matplotlib/font_manager.py:1251
+│ │ │ │ │ │ │ │ │ ├─ 0.002 [self] matplotlib/font_manager.py
+│ │ │ │ │ │ │ │ │ └─ 0.001 Number.__instancecheck__ abc.py:117
+│ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/font_manager.py
+│ │ │ │ │ │ │ │ └─ 0.001 Logger.debug logging/__init__.py:1455
+│ │ │ │ │ │ │ │ └─ 0.001 Logger.isEnabledFor logging/__init__.py:1724
+│ │ │ │ │ │ │ └─ 0.001 _GeneratorContextManager.__exit__ contextlib.py:139
+│ │ │ │ │ │ └─ 0.004 YAxis.get_tightbbox matplotlib/axis.py:1348
+│ │ │ │ │ │ ├─ 0.002 YAxis._update_label_position matplotlib/axis.py:2676
+│ │ │ │ │ │ │ ├─ 0.001 Spine.get_window_extent matplotlib/spines.py:142
+│ │ │ │ │ │ │ │ └─ 0.001 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ │ └─ 0.001 YAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ │ │ │ └─ 0.001 AutoLocator.__call__ matplotlib/ticker.py:2226
+│ │ │ │ │ │ │ │ └─ 0.001 AutoLocator.tick_values matplotlib/ticker.py:2230
+│ │ │ │ │ │ │ │ └─ 0.001 AutoLocator._raw_ticks matplotlib/ticker.py:2157
+│ │ │ │ │ │ │ │ └─ 0.001 clip numpy/core/fromnumeric.py:2100
+│ │ │ │ │ │ │ │ └─ 0.001 _wrapfunc numpy/core/fromnumeric.py:53
+│ │ │ │ │ │ │ │ └─ 0.001 _wrapit numpy/core/fromnumeric.py:40
+│ │ │ │ │ │ │ │ └─ 0.001 _clip numpy/core/_methods.py:90
+│ │ │ │ │ │ │ └─ 0.001 YAxis._get_tick_boxes_siblings matplotlib/axis.py:2234
+│ │ │ │ │ │ │ └─ 0.001 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ ├─ 0.001 YAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ │ │ │ └─ 0.001 ScalarFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ │ │ └─ 0.001 ScalarFormatter.set_locs matplotlib/ticker.py:735
+│ │ │ │ │ │ │ └─ 0.001 ScalarFormatter._compute_offset matplotlib/ticker.py:744
+│ │ │ │ │ │ │ └─ 0.001 YAxis.getter matplotlib/axis.py:2356
+│ │ │ │ │ │ │ └─ 0.001 Bbox.intervaly matplotlib/transforms.py:338
+│ │ │ │ │ │ └─ 0.001 YAxis._get_ticklabel_bboxes matplotlib/axis.py:1339
+│ │ │ │ │ │ └─ 0.001 matplotlib/axis.py:1343
+│ │ │ │ │ │ └─ 0.001 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ │ └─ 0.001 Text._get_layout matplotlib/text.py:358
+│ │ │ │ │ │ └─ 0.001 from_bounds matplotlib/transforms.py:795
+│ │ │ │ │ │ └─ 0.001 from_extents matplotlib/transforms.py:804
+│ │ │ │ │ │ └─ 0.001 Bbox.__init__ matplotlib/transforms.py:749
+│ │ │ │ │ ├─ 0.002 Legend.get_tightbbox matplotlib/legend.py:1057
+│ │ │ │ │ │ └─ 0.002 VPacker.get_window_extent matplotlib/offsetbox.py:363
+│ │ │ │ │ │ ├─ 0.001 VPacker.get_offset matplotlib/offsetbox.py:54
+│ │ │ │ │ │ │ └─ 0.001 VPacker.get_offset matplotlib/offsetbox.py:291
+│ │ │ │ │ │ │ └─ 0.001 Legend._findoffset matplotlib/legend.py:717
+│ │ │ │ │ │ │ └─ 0.001 Legend._find_best_position matplotlib/legend.py:1147
+│ │ │ │ │ │ │ └─ 0.001 Bbox.count_overlaps matplotlib/transforms.py:576
+│ │ │ │ │ │ │ └─ 0.001 PyCapsule.count_bboxes_overlapping_bbox
+│ │ │ │ │ │ └─ 0.001 VPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ └─ 0.001 VPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:441
+│ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:452
+│ │ │ │ │ │ └─ 0.001 HPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ └─ 0.001 HPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:473
+│ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:479
+│ │ │ │ │ │ └─ 0.001 VPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ └─ 0.001 VPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:441
+│ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:452
+│ │ │ │ │ │ └─ 0.001 HPacker.get_bbox matplotlib/offsetbox.py:358
+│ │ │ │ │ │ └─ 0.001 HPacker._get_bbox_and_child_offsets matplotlib/offsetbox.py:473
+│ │ │ │ │ │ └─ 0.001 matplotlib/offsetbox.py:479
+│ │ │ │ │ │ └─ 0.001 TextArea.get_bbox matplotlib/offsetbox.py:762
+│ │ │ │ │ │ └─ 0.001 RendererAgg.get_text_width_height_descent matplotlib/backends/backend_agg.py:206
+│ │ │ │ │ │ └─ 0.001 get_hinting_flag matplotlib/backends/backend_agg.py:41
+│ │ │ │ │ └─ 0.001 Text.get_window_extent matplotlib/text.py:926
+│ │ │ │ │ └─ 0.001 Text._get_layout matplotlib/text.py:358
+│ │ │ │ └─ 0.001 [self] matplotlib/_tight_layout.py
+│ │ │ └─ 0.002 Figure._get_renderer matplotlib/figure.py:2848
+│ │ │ └─ 0.002 FigureCanvasTkAgg.get_renderer matplotlib/backends/backend_agg.py:387
+│ │ │ └─ 0.002 RendererAgg.__init__ matplotlib/backends/backend_agg.py:63
+│ │ ├─ 0.055 Figure.autofmt_xdate matplotlib/figure.py:175
+│ │ │ └─ 0.055 Axes.wrapper matplotlib/axes/_base.py:73
+│ │ │ └─ 0.055 XAxis.get_ticklabels matplotlib/axis.py:1479
+│ │ │ └─ 0.055 XAxis.get_majorticklabels matplotlib/axis.py:1463
+│ │ │ ├─ 0.054 XAxis._update_ticks matplotlib/axis.py:1287
+│ │ │ │ ├─ 0.048 XAxis.get_major_ticks matplotlib/axis.py:1655
+│ │ │ │ │ ├─ 0.029 XAxis._get_tick matplotlib/axis.py:1596
+│ │ │ │ │ │ └─ 0.029 XTick.__init__ matplotlib/axis.py:367
+│ │ │ │ │ │ ├─ 0.022 XTick.__init__ matplotlib/axis.py:59
+│ │ │ │ │ │ │ ├─ 0.013 Line2D.__init__ matplotlib/lines.py:287
+│ │ │ │ │ │ │ │ ├─ 0.005 Line2D._internal_update matplotlib/artist.py:1226
+│ │ │ │ │ │ │ │ │ └─ 0.005 Line2D._update_props matplotlib/artist.py:1188
+│ │ │ │ │ │ │ │ │ ├─ 0.002 _GeneratorContextManager.__exit__ contextlib.py:139
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] contextlib.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 _setattr_cm matplotlib/cbook.py:2010
+│ │ │ │ │ │ │ │ │ ├─ 0.001 helper contextlib.py:279
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 _GeneratorContextManager.__init__ contextlib.py:102
+│ │ │ │ │ │ │ │ │ ├─ 0.001 Line2D.set_zorder matplotlib/artist.py:1124
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 Line2D.pchanged matplotlib/artist.py:414
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 CallbackRegistry.process matplotlib/cbook.py:348
+│ │ │ │ │ │ │ │ │ └─ 0.001 [self] matplotlib/artist.py
+│ │ │ │ │ │ │ │ ├─ 0.002 [self] matplotlib/lines.py
+│ │ │ │ │ │ │ │ ├─ 0.001 Line2D.set_pickradius matplotlib/lines.py:506
+│ │ │ │ │ │ │ │ │ └─ 0.001 Real.__instancecheck__ abc.py:117
+│ │ │ │ │ │ │ │ │ └─ 0.001 _abc_instancecheck
+│ │ │ │ │ │ │ │ ├─ 0.001 Line2D.set_markerfacecolor matplotlib/lines.py:1227
+│ │ │ │ │ │ │ │ │ └─ 0.001 Line2D._set_markercolor matplotlib/lines.py:1203
+│ │ │ │ │ │ │ │ ├─ 0.001 Line2D.set_markerfacecoloralt matplotlib/lines.py:1237
+│ │ │ │ │ │ │ │ │ └─ 0.001 Line2D._set_markercolor matplotlib/lines.py:1203
+│ │ │ │ │ │ │ │ ├─ 0.001 Line2D.set_data matplotlib/lines.py:648
+│ │ │ │ │ │ │ │ │ └─ 0.001 Line2D.set_ydata matplotlib/lines.py:1295
+│ │ │ │ │ │ │ │ │ └─ 0.001 iterable numpy/lib/function_base.py:348
+│ │ │ │ │ │ │ │ │ └─ 0.001 iter
+│ │ │ │ │ │ │ │ ├─ 0.001 Line2D.__init__ matplotlib/artist.py:179
+│ │ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ │ │ │ │ └─ 0.001 RcParams._get matplotlib/__init__.py:698
+│ │ │ │ │ │ │ │ └─ 0.001 RcParams.__getitem__ matplotlib/__init__.py:778
+│ │ │ │ │ │ │ │ └─ 0.001 RcParams._get matplotlib/__init__.py:698
+│ │ │ │ │ │ │ ├─ 0.006 Text.__init__ matplotlib/text.py:104
+│ │ │ │ │ │ │ │ ├─ 0.004 Text.update matplotlib/text.py:194
+│ │ │ │ │ │ │ │ │ ├─ 0.003 Text.update matplotlib/artist.py:1215
+│ │ │ │ │ │ │ │ │ │ └─ 0.003 Text._update_props matplotlib/artist.py:1188
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 _GeneratorContextManager.__exit__ contextlib.py:139
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 list.append
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 _GeneratorContextManager.__enter__ contextlib.py:130
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 _setattr_cm matplotlib/cbook.py:2010
+│ │ │ │ │ │ │ │ │ └─ 0.001 normalize_kwargs matplotlib/cbook.py:1742
+│ │ │ │ │ │ │ │ ├─ 0.001 Text._reset_visual_defaults matplotlib/text.py:157
+│ │ │ │ │ │ │ │ │ └─ 0.001 Text.set_linespacing matplotlib/text.py:1040
+│ │ │ │ │ │ │ │ │ └─ 0.001 check_isinstance matplotlib/_api/__init__.py:65
+│ │ │ │ │ │ │ │ │ └─ 0.001 Real.__instancecheck__ abc.py:117
+│ │ │ │ │ │ │ │ └─ 0.001 Text.__init__ matplotlib/artist.py:179
+│ │ │ │ │ │ │ │ └─ 0.001 CallbackRegistry.__init__ matplotlib/cbook.py:259
+│ │ │ │ │ │ │ │ └─ 0.001 _UnhashDict.__init__ matplotlib/cbook.py:152
+│ │ │ │ │ │ │ ├─ 0.002 [self] matplotlib/axis.py
+│ │ │ │ │ │ │ └─ 0.001 XTick._apply_tickdir matplotlib/axis.py:395
+│ │ │ │ │ │ │ └─ 0.001 Line2D.set_marker matplotlib/lines.py:1189
+│ │ │ │ │ │ │ └─ 0.001 MarkerStyle.__init__ matplotlib/markers.py:220
+│ │ │ │ │ │ │ └─ 0.001 MarkerStyle._set_marker matplotlib/markers.py:299
+│ │ │ │ │ │ │ └─ 0.001 MarkerStyle._recache matplotlib/markers.py:250
+│ │ │ │ │ │ │ └─ 0.001 MarkerStyle._set_tickdown matplotlib/markers.py:776
+│ │ │ │ │ │ │ └─ 0.001 Affine2D.scale matplotlib/transforms.py:2040
+│ │ │ │ │ │ ├─ 0.005 Text. matplotlib/artist.py:146
+│ │ │ │ │ │ │ └─ 0.005 Text.set matplotlib/artist.py:1237
+│ │ │ │ │ │ │ ├─ 0.004 Text._internal_update matplotlib/artist.py:1226
+│ │ │ │ │ │ │ │ └─ 0.004 Text._update_props matplotlib/artist.py:1188
+│ │ │ │ │ │ │ │ ├─ 0.002 Line2D.set_transform matplotlib/lines.py:738
+│ │ │ │ │ │ │ │ │ ├─ 0.001 [self] matplotlib/lines.py
+│ │ │ │ │ │ │ │ │ └─ 0.001 Line2D.set_transform matplotlib/artist.py:435
+│ │ │ │ │ │ │ │ ├─ 0.001 Text.set_verticalalignment matplotlib/text.py:1259
+│ │ │ │ │ │ │ │ │ └─ 0.001 check_in_list matplotlib/_api/__init__.py:100
+│ │ │ │ │ │ │ │ └─ 0.001 Text.pchanged matplotlib/artist.py:414
+│ │ │ │ │ │ │ │ └─ 0.001 CallbackRegistry.process matplotlib/cbook.py:348
+│ │ │ │ │ │ │ │ └─ 0.001 check_in_list matplotlib/_api/__init__.py:100
+│ │ │ │ │ │ │ └─ 0.001 normalize_kwargs matplotlib/cbook.py:1742
+│ │ │ │ │ │ ├─ 0.001 XTick._get_text2_transform matplotlib/axis.py:392
+│ │ │ │ │ │ │ └─ 0.001 Axes.get_xaxis_text2_transform matplotlib/axes/_base.py:983
+│ │ │ │ │ │ │ └─ 0.001 BlendedGenericTransform.__add__ matplotlib/transforms.py:1340
+│ │ │ │ │ │ │ └─ 0.001 composite_transform_factory matplotlib/transforms.py:2498
+│ │ │ │ │ │ └─ 0.001 Axes.get_xaxis_transform matplotlib/axes/_base.py:928
+│ │ │ │ │ │ └─ 0.001 Spine.get_spine_transform matplotlib/spines.py:340
+│ │ │ │ │ │ └─ 0.001 len
+│ │ │ │ │ └─ 0.019 XAxis._copy_tick_props matplotlib/axis.py:1617
+│ │ │ │ │ ├─ 0.018 Line2D.update_from matplotlib/lines.py:1338
+│ │ │ │ │ │ ├─ 0.017 MarkerStyle.__init__ matplotlib/markers.py:220
+│ │ │ │ │ │ │ ├─ 0.016 MarkerStyle._set_marker matplotlib/markers.py:299
+│ │ │ │ │ │ │ │ └─ 0.016 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ └─ 0.016 _deepcopy_dict copy.py:227
+│ │ │ │ │ │ │ │ ├─ 0.013 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ │ ├─ 0.005 Path.__deepcopy__ matplotlib/path.py:279
+│ │ │ │ │ │ │ │ │ │ └─ 0.005 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ │ │ ├─ 0.004 _reconstruct copy.py:259
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.004 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.004 _deepcopy_dict copy.py:227
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.002 ndarray.__deepcopy__
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 _keep_alive copy.py:243
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 list.append
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] copy.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] copy.py
+│ │ │ │ │ │ │ │ │ ├─ 0.004 _deepcopy_method copy.py:237
+│ │ │ │ │ │ │ │ │ │ └─ 0.004 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 _keep_alive copy.py:243
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 _reconstruct copy.py:259
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 MarkerStyle.__newobj__ copyreg.py:100
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 isinstance
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] copy.py
+│ │ │ │ │ │ │ │ │ ├─ 0.001 dict.get
+│ │ │ │ │ │ │ │ │ ├─ 0.001 id
+│ │ │ │ │ │ │ │ │ ├─ 0.001 [self] copy.py
+│ │ │ │ │ │ │ │ │ └─ 0.001 _reconstruct copy.py:259
+│ │ │ │ │ │ │ │ │ └─ 0.001 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ │ └─ 0.001 _deepcopy_dict copy.py:227
+│ │ │ │ │ │ │ │ │ └─ 0.001 deepcopy copy.py:128
+│ │ │ │ │ │ │ │ │ └─ 0.001 issubclass
+│ │ │ │ │ │ │ │ └─ 0.003 [self] copy.py
+│ │ │ │ │ │ │ └─ 0.001 MarkerStyle._set_fillstyle matplotlib/markers.py:275
+│ │ │ │ │ │ └─ 0.001 Line2D.update_from matplotlib/artist.py:1167
+│ │ │ │ │ │ └─ 0.001 Line2D.pchanged matplotlib/artist.py:414
+│ │ │ │ │ └─ 0.001 Text.update_from matplotlib/text.py:342
+│ │ │ │ │ └─ 0.001 FontProperties.copy matplotlib/font_manager.py:961
+│ │ │ │ │ └─ 0.001 copy copy.py:66
+│ │ │ │ │ └─ 0.001 _reconstruct copy.py:259
+│ │ │ │ │ └─ 0.001 hasattr
+│ │ │ │ ├─ 0.002 DateFormatter.format_ticks matplotlib/ticker.py:214
+│ │ │ │ │ └─ 0.002 matplotlib/ticker.py:217
+│ │ │ │ │ └─ 0.002 DateFormatter.__call__ matplotlib/dates.py:589
+│ │ │ │ │ └─ 0.002 num2date matplotlib/dates.py:457
+│ │ │ │ │ └─ 0.002 vectorize.__call__ numpy/lib/function_base.py:2367
+│ │ │ │ │ └─ 0.002 tuple._call_as_normal numpy/lib/function_base.py:2337
+│ │ │ │ │ └─ 0.002 vectorize._vectorize_call numpy/lib/function_base.py:2443
+│ │ │ │ │ ├─ 0.001 _from_ordinalf matplotlib/dates.py:334
+│ │ │ │ │ └─ 0.001 [self] numpy/lib/function_base.py
+│ │ │ │ ├─ 0.001 XAxis.get_minorticklocs matplotlib/axis.py:1538
+│ │ │ │ │ └─ 0.001 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ └─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ └─ 0.001 rrule._iter dateutil/rrule.py:776
+│ │ │ │ ├─ 0.001 XAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ │ │ └─ 0.001 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ │ │ └─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ │ │ └─ 0.001 inner_func matplotlib/dates.py:1038
+│ │ │ │ │ └─ 0.001 rrule.between dateutil/rrule.py:271
+│ │ │ │ │ └─ 0.001 rrule._iter dateutil/rrule.py:776
+│ │ │ │ │ └─ 0.001 _iterinfo.mtimeset dateutil/rrule.py:1294
+│ │ │ │ ├─ 0.001 XTick.update_position matplotlib/axis.py:406
+│ │ │ │ └─ 0.001 XAxis.get_transform matplotlib/axis.py:753
+│ │ │ │ └─ 0.001 LinearScale.get_transform matplotlib/scale.py:115
+│ │ │ │ └─ 0.001 IdentityTransform.__init__ matplotlib/transforms.py:1769
+│ │ │ │ └─ 0.001 IdentityTransform.__init__ matplotlib/transforms.py:110
+│ │ │ └─ 0.001 XAxis.get_major_ticks matplotlib/axis.py:1655
+│ │ │ └─ 0.001 XAxis.get_majorticklocs matplotlib/axis.py:1534
+│ │ │ └─ 0.001 MinuteLocator.__call__ matplotlib/dates.py:1145
+│ │ │ └─ 0.001 MinuteLocator.tick_values matplotlib/dates.py:1154
+│ │ │ └─ 0.001 date2num matplotlib/dates.py:405
+│ │ │ └─ 0.001 _dt64_to_ordinalf matplotlib/dates.py:310
+│ │ ├─ 0.003 Axes.plot matplotlib/axes/_axes.py:1532
+│ │ │ ├─ 0.002 _process_plot_var_args.__call__ matplotlib/axes/_base.py:227
+│ │ │ │ └─ 0.002 _process_plot_var_args._plot_args matplotlib/axes/_base.py:396
+│ │ │ │ ├─ 0.001 _check_1d matplotlib/cbook.py:1348
+│ │ │ │ │ └─ 0.001 atleast_1d numpy/core/shape_base.py:23
+│ │ │ │ │ └─ 0.001 asanyarray
+│ │ │ │ └─ 0.001 matplotlib/axes/_base.py:546
+│ │ │ │ └─ 0.001 matplotlib/axes/_base.py:539
+│ │ │ │ └─ 0.001 _process_plot_var_args._make_line matplotlib/axes/_base.py:335
+│ │ │ │ └─ 0.001 Line2D.__init__ matplotlib/lines.py:287
+│ │ │ │ └─ 0.001 Line2D._internal_update matplotlib/artist.py:1226
+│ │ │ │ └─ 0.001 Line2D._update_props matplotlib/artist.py:1188
+│ │ │ │ └─ 0.001 Line2D.set_label matplotlib/artist.py:1105
+│ │ │ │ └─ 0.001 Line2D.pchanged matplotlib/artist.py:414
+│ │ │ │ └─ 0.001 CallbackRegistry.process matplotlib/cbook.py:348
+│ │ │ └─ 0.001 Axes.add_line matplotlib/axes/_base.py:2362
+│ │ │ └─ 0.001 Axes._update_line_limits matplotlib/axes/_base.py:2390
+│ │ │ └─ 0.001 Line2D.get_path matplotlib/lines.py:1035
+│ │ │ └─ 0.001 Line2D.recache matplotlib/lines.py:672
+│ │ │ └─ 0.001 Path.__init__ matplotlib/path.py:99
+│ │ │ └─ 0.001 check_shape matplotlib/_api/__init__.py:133
+│ │ ├─ 0.001 test_frbc_device.py:544
+│ │ ├─ 0.001 Axes.legend matplotlib/axes/_axes.py:218
+│ │ │ └─ 0.001 Legend.__init__ matplotlib/legend.py:354
+│ │ │ └─ 0.001 bool._init_legend_box matplotlib/legend.py:837
+│ │ │ └─ 0.001 HandlerLine2D.legend_artist matplotlib/legend_handler.py:103
+│ │ │ └─ 0.001 HandlerLine2D.create_artists matplotlib/legend_handler.py:285
+│ │ │ └─ 0.001 HandlerLine2D.update_prop matplotlib/legend_handler.py:86
+│ │ │ └─ 0.001 HandlerLine2D._update_prop matplotlib/legend_handler.py:77
+│ │ │ └─ 0.001 HandlerLine2D._default_update_prop matplotlib/legend_handler.py:83
+│ │ │ └─ 0.001 Line2D.update_from matplotlib/lines.py:1338
+│ │ │ └─ 0.001 MarkerStyle.__init__ matplotlib/markers.py:220
+│ │ │ └─ 0.001 MarkerStyle._set_marker matplotlib/markers.py:299
+│ │ │ └─ 0.001 deepcopy copy.py:128
+│ │ │ └─ 0.001 _deepcopy_dict copy.py:227
+│ │ │ └─ 0.001 deepcopy copy.py:128
+│ │ │ └─ 0.001 _deepcopy_method copy.py:237
+│ │ │ └─ 0.001 deepcopy copy.py:128
+│ │ └─ 0.001 Axes.grid matplotlib/axes/_base.py:3273
+│ │ └─ 0.001 YAxis.grid matplotlib/axis.py:1707
+│ │ └─ 0.001 YAxis.set_tick_params matplotlib/axis.py:957
+│ │ └─ 0.001 YTick._apply_params matplotlib/axis.py:302
+│ ├─ 1.667 PlanningServiceImpl.plan ../planning_service_impl.py:181
+│ │ ├─ 1.653 RootPlanner.plan ../root_planner.py:54
+│ │ │ ├─ 1.451 CongestionPointPlanner.create_improved_planning ../congestion_point_planner.py:142
+│ │ │ │ ├─ 1.446 S2FrbcDevicePlanner.create_improved_planning ../device_planner/frbc/s2_frbc_device_planner.py:98
+│ │ │ │ │ ├─ 1.445 OperationModeProfileTree.find_best_plan ../device_planner/frbc/operation_mode_profile_tree.py:257
+│ │ │ │ │ │ ├─ 1.438 FrbcState.generate_next_timestep_states ../device_planner/frbc/frbc_state.py:350
+│ │ │ │ │ │ │ ├─ 1.357 try_create_next_state ../device_planner/frbc/frbc_state.py:357
+│ │ │ │ │ │ │ │ ├─ 1.052 FrbcState.__init__ ../device_planner/frbc/frbc_state.py:29
+│ │ │ │ │ │ │ │ │ ├─ 0.282 [self] ../device_planner/frbc/frbc_state.py
+│ │ │ │ │ │ │ │ │ ├─ 0.156 UUID.__init__ uuid.py:138
+│ │ │ │ │ │ │ │ │ │ ├─ 0.109 [self] uuid.py
+│ │ │ │ │ │ │ │ │ │ ├─ 0.031 str.replace
+│ │ │ │ │ │ │ │ │ │ ├─ 0.010 str.strip
+│ │ │ │ │ │ │ │ │ │ ├─ 0.003 list.count
+│ │ │ │ │ │ │ │ │ │ └─ 0.003 len
+│ │ │ │ │ │ │ │ │ ├─ 0.130 FrbcTimestep.add_state ../device_planner/frbc/frbc_timestep.py:67
+│ │ │ │ │ │ │ │ │ │ ├─ 0.068 FrbcState.is_preferable_than ../device_planner/frbc/frbc_state.py:426
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.042 [self] ../device_planner/frbc/frbc_state.py
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.008 SelectionResult.__init__ ../device_planner/frbc/selection_reason_result.py:14
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.007 abs
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.006 FrbcState.get_sum_squared_distance ../device_planner/frbc/frbc_state.py:494
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 FrbcState.get_sum_squared_constraint_violation ../device_planner/frbc/frbc_state.py:497
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FrbcState.get_sum_squared_energy ../device_planner/frbc/frbc_state.py:503
+│ │ │ │ │ │ │ │ │ │ ├─ 0.041 [self] ../device_planner/frbc/frbc_timestep.py
+│ │ │ │ │ │ │ │ │ │ ├─ 0.012 FrbcState.is_within_fill_level_range ../device_planner/frbc/frbc_state.py:462
+│ │ │ │ │ │ │ │ │ │ ├─ 0.006 FrbcState.get_bucket ../device_planner/frbc/frbc_state.py:488
+│ │ │ │ │ │ │ │ │ │ └─ 0.003 FrbcState.set_selection_reason ../device_planner/frbc/frbc_state.py:509
+│ │ │ │ │ │ │ │ │ ├─ 0.096 S2FrbcDeviceStateWrapper.get_operation_mode_power ../device_planner/frbc/s2_frbc_device_state_wrapper.py:237
+│ │ │ │ │ │ │ │ │ │ ├─ 0.047 find_operation_mode_element ../device_planner/frbc/s2_frbc_device_state_wrapper.py:255
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.023 ../device_planner/frbc/s2_frbc_device_state_wrapper.py:258
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.013 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.005 FrbcOperationModeElementWrapper.get_fill_level_range ../device_planner/frbc/frbc_operation_mode_wrapper.py:30
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 NumberRangeWrapper.get_start_of_range ../s2_utils/number_range_wrapper.py:6
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 NumberRangeWrapper.get_end_of_range ../s2_utils/number_range_wrapper.py:9
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.017 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.006 FrbcOperationModeWrapper.get_elements ../device_planner/frbc/frbc_operation_mode_wrapper.py:70
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 next
+│ │ │ │ │ │ │ │ │ │ ├─ 0.042 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.007 FrbcOperationModeElementWrapper.get_power_ranges ../device_planner/frbc/frbc_operation_mode_wrapper.py:33
+│ │ │ │ │ │ │ │ │ ├─ 0.083 S2FrbcDeviceStateWrapper.get_operation_mode_fill_rate ../device_planner/frbc/s2_frbc_device_state_wrapper.py:277
+│ │ │ │ │ │ │ │ │ │ ├─ 0.054 find_operation_mode_element ../device_planner/frbc/s2_frbc_device_state_wrapper.py:255
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.032 ../device_planner/frbc/s2_frbc_device_state_wrapper.py:258
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.018 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.008 NumberRangeWrapper.get_start_of_range ../s2_utils/number_range_wrapper.py:6
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 NumberRangeWrapper.get_end_of_range ../s2_utils/number_range_wrapper.py:9
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.002 FrbcOperationModeElementWrapper.get_fill_level_range ../device_planner/frbc/frbc_operation_mode_wrapper.py:30
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.018 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.003 next
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FrbcOperationModeWrapper.get_elements ../device_planner/frbc/frbc_operation_mode_wrapper.py:70
+│ │ │ │ │ │ │ │ │ │ ├─ 0.021 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ ├─ 0.003 NumberRangeWrapper.get_end_of_range ../s2_utils/number_range_wrapper.py:9
+│ │ │ │ │ │ │ │ │ │ ├─ 0.003 NumberRangeWrapper.get_start_of_range ../s2_utils/number_range_wrapper.py:6
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 FrbcOperationModeElementWrapper.get_fill_rate ../device_planner/frbc/frbc_operation_mode_wrapper.py:24
+│ │ │ │ │ │ │ │ │ ├─ 0.064 get_leakage_rate ../device_planner/frbc/s2_frbc_device_state_wrapper.py:286
+│ │ │ │ │ │ │ │ │ │ ├─ 0.045 find_leakage_element ../device_planner/frbc/s2_frbc_device_state_wrapper.py:295
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.026 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.014 ../device_planner/frbc/s2_frbc_device_state_wrapper.py:301
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 next
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 FrbcTimestep.get_leakage_behaviour ../device_planner/frbc/frbc_timestep.py:100
+│ │ │ │ │ │ │ │ │ │ ├─ 0.017 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.002 FrbcTimestep.get_leakage_behaviour ../device_planner/frbc/frbc_timestep.py:100
+│ │ │ │ │ │ │ │ │ ├─ 0.049 S2FrbcDeviceStateWrapper.get_operation_mode ../device_planner/frbc/s2_frbc_device_state_wrapper.py:97
+│ │ │ │ │ │ │ │ │ │ ├─ 0.032 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.017 UUID.__str__ uuid.py:279
+│ │ │ │ │ │ │ │ │ ├─ 0.039 calculate_bucket ../device_planner/frbc/s2_frbc_device_state_wrapper.py:318
+│ │ │ │ │ │ │ │ │ │ ├─ 0.024 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ ├─ 0.011 FrbcTimestep.get_system_description ../device_planner/frbc/frbc_timestep.py:97
+│ │ │ │ │ │ │ │ │ │ └─ 0.004 FrbcTimestep.get_nr_of_buckets ../device_planner/frbc/frbc_timestep.py:57
+│ │ │ │ │ │ │ │ │ ├─ 0.024 UUID.__eq__ uuid.py:239
+│ │ │ │ │ │ │ │ │ │ ├─ 0.018 [self] uuid.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.006 isinstance
+│ │ │ │ │ │ │ │ │ ├─ 0.018 FrbcTimestep.get_duration_seconds ../device_planner/frbc/frbc_timestep.py:106
+│ │ │ │ │ │ │ │ │ │ ├─ 0.009 timedelta.total_seconds
+│ │ │ │ │ │ │ │ │ │ └─ 0.009 [self] ../device_planner/frbc/frbc_timestep.py
+│ │ │ │ │ │ │ │ │ ├─ 0.013 UUID.__hash__ uuid.py:267
+│ │ │ │ │ │ │ │ │ │ ├─ 0.009 [self] uuid.py
+│ │ │ │ │ │ │ │ │ │ └─ 0.004 hash
+│ │ │ │ │ │ │ │ │ ├─ 0.012 S2ActuatorConfiguration.get_operation_mode_id ../device_planner/frbc/s2_frbc_actuator_configuration.py:12
+│ │ │ │ │ │ │ │ │ ├─ 0.010 FrbcState.get_sum_squared_constraint_violation ../device_planner/frbc/frbc_state.py:497
+│ │ │ │ │ │ │ │ │ ├─ 0.009 FrbcState.get_actuator_configurations ../device_planner/frbc/frbc_state.py:482
+│ │ │ │ │ │ │ │ │ ├─ 0.008 get_timer_duration ../device_planner/frbc/s2_frbc_device_state_wrapper.py:350
+│ │ │ │ │ │ │ │ │ │ ├─ 0.007 get_timer_duration_milliseconds ../device_planner/frbc/s2_frbc_device_state_wrapper.py:332
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.005 get_actuator_description ../device_planner/frbc/s2_frbc_device_state_wrapper.py:360
+│ │ │ │ │ │ │ │ │ │ │ │ ├─ 0.004 ../device_planner/frbc/s2_frbc_device_state_wrapper.py:365
+│ │ │ │ │ │ │ │ │ │ │ │ │ └─ 0.004 UUID.__eq__ uuid.py:239
+│ │ │ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ │ ├─ 0.001 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 next
+│ │ │ │ │ │ │ │ │ │ └─ 0.001 [self] ../device_planner/frbc/s2_frbc_device_state_wrapper.py
+│ │ │ │ │ │ │ │ │ ├─ 0.008 isinstance
+│ │ │ │ │ │ │ │ │ ├─ 0.007 dict.copy
+│ │ │ │ │ │ │ │ │ ├─ 0.006 FrbcState.get_sum_squared_energy ../device_planner/frbc/frbc_state.py:503
+│ │ │ │ │ │ │ │ │ ├─ 0.005 FrbcTimestep.get_system_description ../device_planner/frbc/frbc_timestep.py:97
+│ │ │ │ │ │ │ │ │ ├─ 0.005 FrbcState.get_sum_energy_cost ../device_planner/frbc/frbc_state.py:500
+│ │ │ │ │ │ │ │ │ ├─ 0.005 S2ActuatorConfiguration.get_factor ../device_planner/frbc/s2_frbc_actuator_configuration.py:15
+│ │ │ │ │ │ │ │ │ ├─ 0.004 dict.items
+│ │ │ │ │ │ │ │ │ ├─ 0.004 FrbcState.get_timer_elapse_map ../device_planner/frbc/frbc_state.py:506
+│ │ │ │ │ │ │ │ │ ├─ 0.003 get_transition ../device_planner/frbc/s2_frbc_device_state_wrapper.py:202
+│ │ │ │ │ │ │ │ │ │ ├─ 0.001 get_actuator_description ../device_planner/frbc/s2_frbc_device_state_wrapper.py:360
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 ../device_planner/frbc/s2_frbc_device_state_wrapper.py:365
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 UUID.__eq__ uuid.py:239
+│ │ │ │ │ │ │ │ │ │ │ └─ 0.001 isinstance