vessim 0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ import multiprocessing
2
+ import json
3
+ from abc import ABC, abstractmethod
4
+ from time import sleep
5
+ from datetime import datetime
6
+ from typing import Optional, Dict, Type
7
+
8
+ import uvicorn # type: ignore
9
+ from fastapi import FastAPI, HTTPException # type: ignore
10
+ from pydantic import BaseModel # type: ignore
11
+
12
+ from vessim.sil.redis_docker import RedisDocker
13
+
14
+
15
+ class SilApi(ABC):
16
+ """Base class for the API running on the ApiServer in different process.
17
+
18
+ Initializes a FastApi instance with an endpoint `/shutdown` for executing
19
+ necessary cleanup tasks of the child process running the API.
20
+
21
+ Attributes:
22
+ app: The FastApi instance to be run.
23
+ """
24
+
25
+ def __init__(self) -> None:
26
+ self.app = FastAPI()
27
+
28
+ @self.app.put("/shutdown")
29
+ async def shutdown_server():
30
+ self.finalize()
31
+
32
+ @abstractmethod
33
+ def finalize(self) -> None:
34
+ """Complete necessary cleanup tasks."""
35
+
36
+
37
+ class ApiServer(multiprocessing.Process):
38
+ """Process that runs a given FastAPI application with a uvicorn server.
39
+
40
+ Args:
41
+ api_type: The type of the class containing the app to be executed.
42
+ host: The host address where the server will be accessible.
43
+ Defaults to '127.0.0.1'.
44
+ port: The port number on which the FastAPI application will run.
45
+ Defaults to 8000.
46
+ """
47
+
48
+ def __init__(
49
+ self, api_type: Type[SilApi], host: str = "127.0.0.1", port: int = 8000
50
+ ) -> None:
51
+ super().__init__()
52
+ self.api_type = api_type
53
+ self.host = host
54
+ self.port = port
55
+ self.startup_complete = multiprocessing.Value("b", False)
56
+
57
+ def wait_for_startup_complete(self):
58
+ """Waiting for completion of startup process.
59
+
60
+ To ensure the server is operational for the simulation, the startup
61
+ needs to complete before any requests can be made. Waits for the
62
+ uvicorn server to finish startup.
63
+ """
64
+ while not self.startup_complete.value:
65
+ sleep(1)
66
+
67
+ def run(self):
68
+ """Called with `multiprocessing.Process.start()`. Runs the uvicorn server."""
69
+ api = self.api_type()
70
+
71
+ @api.app.on_event("startup")
72
+ async def startup_event():
73
+ self.startup_complete.value = True
74
+
75
+ config = uvicorn.Config(
76
+ app=api.app, host=self.host, port=self.port, access_log=False
77
+ )
78
+ server = uvicorn.Server(config=config)
79
+ try:
80
+ server.run()
81
+ finally:
82
+ api.finalize()
83
+
84
+
85
+ class VessimApi(SilApi):
86
+ """Specialized Vessim API to be executed in a different process.
87
+
88
+ Initializes a FastAPI instance with specific routes related to the Vessim API.
89
+ This app is very specific for the use case of the `Vessim vision paper <https://arxiv.org/pdf/2306.09774.pdf>`_.
90
+
91
+ Attributes:
92
+ app: The FastApi instance to be run.
93
+ """
94
+
95
+ def __init__(self) -> None:
96
+ super().__init__()
97
+ self.redis_docker = RedisDocker()
98
+ self._init_get_routes(self.app)
99
+ self._init_put_routes(self.app)
100
+
101
+ def finalize(self) -> None:
102
+ """Terminate Docker container for cleanup."""
103
+ self.redis_docker.__del__()
104
+
105
+ def _init_get_routes(self, app: FastAPI) -> None:
106
+ """Initializes GET routes for a FastAPI.
107
+
108
+ Args:
109
+ app: The FastAPI app to add the GET routes to.
110
+ """
111
+ # /api/
112
+
113
+ class SolarModel(BaseModel):
114
+ solar: Optional[float]
115
+
116
+ @app.get("/api/solar", response_model=SolarModel)
117
+ async def get_solar() -> SolarModel:
118
+ solar = self.redis_docker.redis.get("solar")
119
+ if solar is not None:
120
+ solar = float(solar)
121
+ return SolarModel(solar=solar)
122
+
123
+ class CiModel(BaseModel):
124
+ ci: Optional[float]
125
+
126
+ @app.get("/api/ci", response_model=CiModel)
127
+ async def get_ci() -> CiModel:
128
+ ci = self.redis_docker.redis.get("ci")
129
+ if ci is not None:
130
+ ci = float(ci)
131
+ return CiModel(ci=ci)
132
+
133
+ class BatterySocModel(BaseModel):
134
+ battery_soc: Optional[float]
135
+
136
+ @app.get("/api/battery-soc", response_model=BatterySocModel)
137
+ async def get_battery_soc() -> BatterySocModel:
138
+ battery_soc = self.redis_docker.redis.get("battery_soc")
139
+ if battery_soc is not None:
140
+ battery_soc = float(battery_soc)
141
+ return BatterySocModel(battery_soc=battery_soc)
142
+
143
+ # /sim/
144
+
145
+ class CollectSetModel(BaseModel):
146
+ battery_min_soc: Optional[Dict[str, float]]
147
+ battery_grid_charge: Optional[Dict[str, float]]
148
+ nodes_power_mode: Optional[Dict[str, Dict[str, str]]]
149
+
150
+ @app.get("/sim/collect-set", response_model=CollectSetModel)
151
+ async def get_collect_set() -> CollectSetModel:
152
+ model = CollectSetModel(
153
+ battery_min_soc=self._deserialize_redis_hash("battery_min_soc_log"),
154
+ battery_grid_charge=self._deserialize_redis_hash(
155
+ "battery_grid_charge_log"
156
+ ),
157
+ nodes_power_mode=self._deserialize_redis_hash("power_mode_log"),
158
+ )
159
+ self._delete_all_keys_in_hash("battery_min_soc_log")
160
+ self._delete_all_keys_in_hash("battery_grid_charge_log")
161
+ self._delete_all_keys_in_hash("power_mode_log")
162
+ return model
163
+
164
+ def _deserialize_redis_hash(self, hash_name):
165
+ return {
166
+ key.decode(): json.loads(value.decode())
167
+ for key, value in self.redis_docker.redis.hgetall(hash_name).items()
168
+ }
169
+
170
+ def _delete_all_keys_in_hash(self, hash_name: str) -> None:
171
+ keys = self.redis_docker.redis.hkeys(hash_name)
172
+ for key in keys:
173
+ self.redis_docker.redis.hdel(hash_name, key)
174
+
175
+ def _init_put_routes(self, app: FastAPI) -> None:
176
+ """Initialize PUT routes for the FastAPI application.
177
+
178
+ Args:
179
+ app: FastAPI application instance to which PUT routes are added.
180
+ """
181
+ # /api/
182
+
183
+ class BatteryModel(BaseModel):
184
+ min_soc: float
185
+ grid_charge: float
186
+
187
+ @app.put("/api/battery", response_model=BatteryModel)
188
+ async def put_battery(battery: BatteryModel) -> BatteryModel:
189
+ timestamp = datetime.now().isoformat()
190
+ self.redis_docker.redis.hset(
191
+ "battery_min_soc_log", str(timestamp), battery.min_soc
192
+ )
193
+ self.redis_docker.redis.hset(
194
+ "battery_grid_charge_log", str(timestamp), battery.grid_charge
195
+ )
196
+ return battery
197
+
198
+ class NodeModel(BaseModel):
199
+ power_mode: str
200
+
201
+ @app.put("/api/nodes/{item_id}", response_model=NodeModel)
202
+ async def put_nodes(node: NodeModel, item_id: str) -> NodeModel:
203
+ power_modes = ["power-saving", "normal", "high performance"]
204
+ power_mode = node.power_mode
205
+ if power_mode not in power_modes:
206
+ raise HTTPException(
207
+ status_code=400,
208
+ detail=f"{power_mode} is not a valid power mode. "
209
+ f"Available power modes: {power_modes}",
210
+ )
211
+ timestamp = datetime.now().isoformat()
212
+ self.redis_docker.redis.hset(
213
+ "power_mode_log", str(timestamp), json.dumps({item_id: power_mode})
214
+ )
215
+ return node
216
+
217
+ # /sim/
218
+
219
+ class UpdateModel(BaseModel):
220
+ solar: float
221
+ ci: float
222
+ battery_soc: float
223
+
224
+ @app.put("/sim/update", response_model=UpdateModel)
225
+ async def put_update(update: UpdateModel) -> UpdateModel:
226
+ self.redis_docker.redis.set("solar", update.solar)
227
+ self.redis_docker.redis.set("ci", update.ci)
228
+ self.redis_docker.redis.set("battery_soc", update.battery_soc)
229
+ return update
@@ -0,0 +1,54 @@
1
+ import requests
2
+ import json
3
+ from typing import Any, Dict
4
+
5
+
6
+ class HttpClient:
7
+ """Class for making HTTP requests to the Vessim API server.
8
+
9
+ Args:
10
+ server_address: The address of the server to connect to.
11
+ e.g. http://localhost
12
+ """
13
+
14
+ def __init__(self, server_address: str, timeout: float = 5) -> None:
15
+ self.server_address = server_address
16
+ self.timeout = timeout
17
+
18
+ def get(self, route: str) -> dict:
19
+ """Sends a GET request to the server and retrieves data.
20
+
21
+ Args:
22
+ route: The path of the endpoint to send the request to.
23
+
24
+ Raises:
25
+ HTTPError: If response code is != 200.
26
+
27
+ Returns:
28
+ A dictionary containing the response.
29
+ """
30
+ response = requests.get(self.server_address + route, timeout=self.timeout)
31
+ if response.status_code != 200:
32
+ response.raise_for_status()
33
+ data = response.json() # assuming the response data is in JSON format
34
+ return data
35
+
36
+ def put(self, route: str, data: Dict[str, Any] = {}) -> None:
37
+ """Sends a PUT request to the server to update data.
38
+
39
+ Args:
40
+ route: The path of the endpoint to send the request to.
41
+ data: The data to be updated, in dictionary format.
42
+
43
+ Raises:
44
+ HTTPError: If response code is != 200.
45
+ """
46
+ headers = {"Content-type": "application/json"}
47
+ response = requests.put(
48
+ self.server_address + route,
49
+ data=json.dumps(data),
50
+ headers=headers,
51
+ timeout=self.timeout,
52
+ )
53
+ if response.status_code != 200:
54
+ response.raise_for_status()
@@ -0,0 +1,46 @@
1
+ import threading
2
+ import time
3
+ from typing import Callable
4
+
5
+ class LoopThread(threading.Thread):
6
+ """Thread subclass that runs a target function until `stop()` is called.
7
+
8
+ Also facilitates the propagation of exceptions to the main thread.
9
+
10
+ Args:
11
+ target_function: The function to be run in the thread.
12
+
13
+ Attributes:
14
+ target_function: The function to be run in the thread.
15
+ stop_signal: An event that can be set to signal the
16
+ thread to stop.
17
+ exc: Variable that is set to propagate an exception to the main thread.
18
+ """
19
+
20
+ def __init__(self, target_function: Callable[[], None], interval: float):
21
+ super().__init__()
22
+ self.target_function = target_function
23
+ self.stop_signal = threading.Event()
24
+ self.interval = interval
25
+ self.exc = None
26
+
27
+ def run(self):
28
+ """Run the target function in a loop until the stop signal is set."""
29
+ try:
30
+ while not self.stop_signal.is_set():
31
+ self.target_function()
32
+ time.sleep(self.interval)
33
+ except Exception as e:
34
+ self.exc = e
35
+
36
+ def stop(self):
37
+ """Set the stop signal to stop the thread."""
38
+ self.stop_signal.set()
39
+ self.join()
40
+ if self.exc:
41
+ raise self.exc
42
+
43
+ def propagate_exception(self):
44
+ """Raises an exception if the target function raised an exception."""
45
+ if self.exc:
46
+ raise self.exc
vessim/sil/node.py ADDED
@@ -0,0 +1,31 @@
1
+ from typing import Set
2
+
3
+ class Node:
4
+ """Represents a physical or virtual computing node.
5
+
6
+ This class keeps track of nodes and assigns unique IDs to each new
7
+ instance. It also allows the setting of a power meter and power mode.
8
+
9
+ Args:
10
+ id: A unique ID assigned to each node.
11
+ address: The network address of the node.
12
+ port: The application port of the node api.
13
+ power_mode: The power mode of the node. Default is `high performance`.
14
+ """
15
+
16
+ existing_ids: Set[str] = set()
17
+
18
+ def __init__(
19
+ self,
20
+ id: str,
21
+ address: str,
22
+ port: int = 8000,
23
+ power_mode: str = "high performance"
24
+ ) -> None:
25
+ if id in self.existing_ids:
26
+ raise ValueError(f"Node ID \"{id}\" already exists.")
27
+ self.existing_ids.add(id)
28
+ self.id = id
29
+ self.address = address
30
+ self.port = port
31
+ self.power_mode = power_mode
@@ -0,0 +1,44 @@
1
+ from vessim.core.consumer import PowerMeter
2
+ from vessim.sil.http_client import HttpClient
3
+ from vessim.sil.loop_thread import LoopThread
4
+
5
+
6
+ class HttpPowerMeter(PowerMeter):
7
+ """Power meter for an external node that implements the vessim node API.
8
+
9
+ This class represents a power meter for an external node. It creates a thread
10
+ that updates the power demand from the node API at a given interval.
11
+
12
+ Args:
13
+ interval: The interval in seconds to update the power demand.
14
+ server_address: The IP address of the node API.
15
+ port: The IP port of the node API.
16
+ name: The name of the power meter.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ name: str,
22
+ interval: float,
23
+ server_address: str,
24
+ port: int = 8000
25
+ ) -> None:
26
+ super().__init__(name)
27
+ self.http_client = HttpClient(f"{server_address}:{port}")
28
+ self.power = 0.0
29
+ self.update_thread = LoopThread(self._update_power, interval)
30
+ self.update_thread.start()
31
+
32
+ def _update_power(self) -> None:
33
+ """Gets the power demand every `interval` seconds from the API server."""
34
+ self.power = float(self.http_client.get("/power")["power"])
35
+
36
+ def measure(self) -> float:
37
+ """Returns the current power demand of the node."""
38
+ self.update_thread.propagate_exception()
39
+ return self.power
40
+
41
+ def finalize(self) -> None:
42
+ """Terminates the power update thread when the instance is finalized."""
43
+ self.update_thread.stop()
44
+ self.update_thread.join()
@@ -0,0 +1,63 @@
1
+ from time import sleep
2
+ import docker # type: ignore
3
+ import redis # type: ignore
4
+
5
+
6
+ class RedisDocker:
7
+ """Class for connection to a Docker container with Redis.
8
+
9
+ Args:
10
+ host: Host address, defaults to '127.0.0.1'.
11
+ port: Port for connection, defaults to 6379 as specified by Redis.
12
+
13
+ Attributes:
14
+ redis: The redis db that can be used to get and set key, value pairs.
15
+ """
16
+
17
+ def __init__(self, host: str = "127.0.0.1", port: int = 6379) -> None:
18
+ self.host = host
19
+ self.port = port
20
+ try:
21
+ self._init_docker()
22
+ except docker.errors.DockerException:
23
+ raise RuntimeError("Please start Docker before execution.")
24
+ self.redis = self._connect_redis()
25
+
26
+ def _init_docker(self) -> None:
27
+ """Initializes Docker client and starts Docker container with Redis."""
28
+ client = docker.from_env()
29
+ self.redis_container = client.containers.run(
30
+ "redis:latest",
31
+ auto_remove=True,
32
+ ports={f"{self.port}/tcp": self.port},
33
+ detach=True,
34
+ )
35
+
36
+ # Check if the container has started
37
+ while True:
38
+ container_info = client.containers.get(self.redis_container.id)
39
+ if container_info.status == "running":
40
+ break
41
+ sleep(1)
42
+
43
+ def _connect_redis(self) -> redis.Redis:
44
+ """Connects to the Redis instance in the Docker container.
45
+
46
+ Waits until a connection is established.
47
+ """
48
+ db = None
49
+ connected = False
50
+ while not connected:
51
+ try:
52
+ db = redis.Redis(host=self.host, port=self.port, db=0)
53
+ connected = db.ping()
54
+ except redis.exceptions.RedisError as redis_error:
55
+ print(f"Error connecting to Redis: {redis_error}")
56
+ sleep(1)
57
+ assert db is not None
58
+ return db
59
+
60
+ def __del__(self) -> None:
61
+ """Stops the Docker container with Redis when instance is deleted."""
62
+ if self.redis_container:
63
+ self.redis_container.stop()
@@ -0,0 +1,174 @@
1
+ from threading import Thread
2
+ from typing import List, Optional
3
+
4
+ from vessim.core.storage import SimpleBattery, DefaultStoragePolicy, StoragePolicy
5
+ from vessim.cosim._util import VessimSimulator, VessimModel, simplify_inputs
6
+ from vessim.sil.api_server import ApiServer, VessimApi
7
+ from vessim.sil.http_client import HttpClient
8
+ from vessim.sil.node import Node
9
+ from vessim.sil.loop_thread import LoopThread
10
+
11
+
12
+ class SilInterfaceSim(VessimSimulator):
13
+ """Software-in-the-Loop (SiL) interface simulator that executes the model."""
14
+
15
+ META = {
16
+ "type": "time-based",
17
+ "models": {
18
+ "SilInterface": {
19
+ "public": True,
20
+ "any_inputs": True,
21
+ "params": [
22
+ "nodes",
23
+ "battery",
24
+ "policy",
25
+ "collection_interval",
26
+ "api_host",
27
+ "api_port"
28
+ ],
29
+ "attrs": []
30
+ },
31
+ },
32
+ }
33
+
34
+ def __init__(self) -> None:
35
+ self.step_size = None
36
+ super().__init__(self.META, _SilInterfaceModel)
37
+
38
+ def init(self, sid, time_resolution, step_size, eid_prefix=None):
39
+ self.step_size = step_size
40
+ return super().init(sid, time_resolution, eid_prefix=eid_prefix)
41
+
42
+ def finalize(self) -> None:
43
+ """Stops the api server and the collector thread when the simulation finishes."""
44
+ super().finalize()
45
+ for model_instance in self.entities.values():
46
+ model_instance.collector_thread.stop() # type: ignore
47
+ model_instance.collector_thread.join() # type: ignore
48
+ model_instance.http_client.put("/shutdown") # type: ignore
49
+ model_instance.api_server.terminate() # type: ignore
50
+ model_instance.api_server.join() # type: ignore
51
+
52
+ def next_step(self, time):
53
+ return time + self.step_size
54
+
55
+
56
+ class _SilInterfaceModel(VessimModel):
57
+ """Software-in-the-Loop interface to the energy system simulation.
58
+
59
+ TODO this class is still very specific to our paper use case and does not
60
+ generalize well to other scenarios.
61
+
62
+ Args:
63
+ nodes: List of vessim SiL nodes.
64
+ battery: SimpleBattery battery used by the system.
65
+ policy: The (dis)charging policy used to control the battery.
66
+ nodes: List of physical or virtual computing nodes.
67
+ collection_interval: Interval in which `/sim/collet-set` in fetched from
68
+ the API server.
69
+ api_host: Server address for the API.
70
+ api_port: Server port for the API.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ nodes: List[Node],
76
+ battery: SimpleBattery,
77
+ collection_interval: float,
78
+ policy: Optional[StoragePolicy] = None,
79
+ api_host: str = "127.0.0.1",
80
+ api_port: int = 8000
81
+ ):
82
+ self.nodes = nodes
83
+ self.updated_nodes: List[Node] = []
84
+ self.battery = battery
85
+ self.policy = policy if policy is not None else DefaultStoragePolicy()
86
+ self.p_cons = 0
87
+ self.p_gen = 0
88
+ self.p_grid = 0
89
+ self.ci = 0
90
+
91
+ # start server process
92
+ self.api_server = ApiServer(VessimApi, api_host, api_port)
93
+ self.api_server.start()
94
+ self.api_server.wait_for_startup_complete()
95
+
96
+ # init server values and wait for put request confirmation
97
+ self.http_client = HttpClient(f"http://{api_host}:{api_port}")
98
+ self.http_client.put("/sim/update", {
99
+ "solar": self.p_gen,
100
+ "ci": self.ci,
101
+ "battery_soc": self.battery.soc(),
102
+ })
103
+
104
+ self.collector_thread = LoopThread(self._api_collector, collection_interval)
105
+ self.collector_thread.start()
106
+
107
+ def _api_collector(self):
108
+ """Collects data from the API server.
109
+
110
+ The endpoint "/sim/collect-set" yields the 3 fields `battery_min_soc`,
111
+ `battery_grid_charge` and `nodes_power_mode`. Since multiple set
112
+ request of these values from different actors are possible, each of
113
+ these fields is a list of dictionaries with the timestamp (formated as
114
+ `datetime.now().isoformat()`) and the set value at that time.
115
+
116
+ TODO implement user defined collection method. Current static
117
+ collection method: most recent entry.
118
+ """
119
+ collection = self.http_client.get("/sim/collect-set")
120
+
121
+ # The value of the keys is None by default if no set request was made
122
+ # by an actor => Check if not None first.
123
+ if collection["battery_min_soc"]:
124
+ # The newest entry is the one with the highest iso timestamp.
125
+ newest_key = max(collection["battery_min_soc"].keys())
126
+ self.battery.min_soc = float(collection["battery_min_soc"][newest_key])
127
+
128
+ if collection["battery_grid_charge"]:
129
+ newest_key = max(collection["battery_grid_charge"].keys())
130
+ self.policy.grid_power = float(collection["battery_grid_charge"][newest_key])
131
+
132
+ if collection["nodes_power_mode"]:
133
+ newest_key = max(collection["nodes_power_mode"].keys())
134
+ nodes_power_mode = dict(collection['nodes_power_mode'][newest_key].items())
135
+ # nodes_power_mode looks e.g. like {"gcp": "normal",...}
136
+ # Loop through all nodes,
137
+ for node in self.nodes:
138
+ if node.id in nodes_power_mode:
139
+ # update the power_mode if it changed
140
+ node.power_mode = nodes_power_mode[node.id]
141
+ # and save whatever node had its powermode updated to
142
+ # remotely update only that nodes' power mode in step().
143
+ self.updated_nodes.append(node)
144
+
145
+ def step(self, time: int, inputs: dict) -> None:
146
+ self.collector_thread.propagate_exception()
147
+ inputs = simplify_inputs(inputs)
148
+ self.p_cons = inputs["p_cons"]
149
+ self.p_gen = inputs["p_gen"]
150
+ self.ci = inputs["ci"]
151
+ self.p_grid = inputs["p_grid"]
152
+
153
+ # update values to the api server
154
+ def update_api_server():
155
+ self.http_client.put("/sim/update", {
156
+ "solar": self.p_gen,
157
+ "ci": self.ci,
158
+ "battery_soc": self.battery.soc(),
159
+ })
160
+ # use thread to not slow down simulation
161
+ api_server_update_thread = Thread(target=update_api_server)
162
+ api_server_update_thread.start()
163
+
164
+ # update power mode for the node remotely
165
+ for node in self.updated_nodes:
166
+ http_client = HttpClient(f"{node.address}:{node.port}")
167
+
168
+ def update_node_power_model():
169
+ http_client.put("/power_mode", {"power_mode": node.power_mode})
170
+ # use thread to not slow down simulation
171
+ node_update_thread = Thread(target=update_node_power_model)
172
+ node_update_thread.start()
173
+ self.updated_nodes.clear()
174
+