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.
- vessim/__init__.py +327 -0
- vessim/analysis.py +111 -0
- vessim/core/__init__.py +1 -0
- vessim/core/consumer.py +99 -0
- vessim/core/microgrid.py +39 -0
- vessim/core/storage.py +122 -0
- vessim/cosim/__init__.py +15 -0
- vessim/cosim/_util.py +162 -0
- vessim/cosim/carbon_api.py +52 -0
- vessim/cosim/consumer.py +53 -0
- vessim/cosim/generator.py +48 -0
- vessim/cosim/microgrid.py +38 -0
- vessim/cosim/monitor.py +71 -0
- vessim/sil/__init__.py +4 -0
- vessim/sil/api_server.py +229 -0
- vessim/sil/http_client.py +54 -0
- vessim/sil/loop_thread.py +46 -0
- vessim/sil/node.py +31 -0
- vessim/sil/power_meter.py +44 -0
- vessim/sil/redis_docker.py +63 -0
- vessim/sil/sil_interface.py +174 -0
- vessim-0.dist-info/METADATA +146 -0
- vessim-0.dist-info/RECORD +24 -0
- vessim-0.dist-info/WHEEL +4 -0
vessim/sil/api_server.py
ADDED
|
@@ -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
|
+
|