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/__init__.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""A simulator for carbon-aware applications and systems."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Union, List, Optional, Any, Literal
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
DatetimeLike = Union[str, datetime]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TimeSeriesApi:
|
|
12
|
+
"""Simulates an API for time series data like solar irradiance or carbon intensity.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
actual: The actual time-series data to be used. It should contain a datetime-like
|
|
16
|
+
index marking the time, and each column should represent a different zone
|
|
17
|
+
containing the data. The name of the zone is equal to the column name.
|
|
18
|
+
Note that while interpolation is possible when retrieving forecasts, the
|
|
19
|
+
actual data between timestamps is computed using either `ffill` or `bfill`.
|
|
20
|
+
If you wish a different behavior, you have to change your actual data
|
|
21
|
+
beforehand (e.g. by resampling into a different frequency).
|
|
22
|
+
|
|
23
|
+
forecast: An optional time-series dataset representing forecasted values. The
|
|
24
|
+
data should contain two datetime-like indices. One is the
|
|
25
|
+
`Request Timestamp`, marking the time when the forecast was made. One is the
|
|
26
|
+
`Forecast Timestamp`, indicating the time the forecast is made for.
|
|
27
|
+
|
|
28
|
+
- If data does not include a `Request Timestamp`, it is treated as a static
|
|
29
|
+
forecast that does not change over time.
|
|
30
|
+
|
|
31
|
+
- If `forecast` is not provided, predictions are derived from the actual
|
|
32
|
+
data when requesting forecasts (actual data is treated as static forecast).
|
|
33
|
+
|
|
34
|
+
- When using a non-static forecast, you have to be aware that all the
|
|
35
|
+
zones/column names also have to appear in the actual dataframe because some
|
|
36
|
+
actual values are used for interpolation.
|
|
37
|
+
|
|
38
|
+
fill_method: Either `ffill` or `bfill`. Determines how actual data is acquired in
|
|
39
|
+
between timestamps. Some data providers like `Solcast` index their data with a
|
|
40
|
+
timestamp marking the end of the time-period that the data is valid for.
|
|
41
|
+
In those cases, `bfill` should be chosen, but the default is `ffill`.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> actual_data = [
|
|
45
|
+
... ["2020-01-01T00:00:00", 100, 800],
|
|
46
|
+
... ["2020-01-01T00:30:00", 110, 850],
|
|
47
|
+
... ["2020-01-01T01:00:00", 105, 820],
|
|
48
|
+
... ["2020-01-01T01:30:00", 108, 840],
|
|
49
|
+
... ]
|
|
50
|
+
>>> actual = pd.DataFrame(
|
|
51
|
+
... actual_data, columns=["timestamp", "zone_a", "zone_b"]
|
|
52
|
+
... )
|
|
53
|
+
>>> actual.set_index(["timestamp"], inplace=True)
|
|
54
|
+
|
|
55
|
+
>>> forecast_data = [
|
|
56
|
+
... ["2020-01-01T00:00:00", "2020-01-01T00:30:00", 115, 850],
|
|
57
|
+
... ["2020-01-01T00:00:00", "2020-01-01T01:00:00", 110, 870],
|
|
58
|
+
... ["2020-01-01T00:00:00", "2020-01-01T01:30:00", 110, 860],
|
|
59
|
+
... ["2020-01-01T00:00:00", "2020-01-01T02:00:00", 120, 830],
|
|
60
|
+
... ["2020-01-01T01:00:00", "2020-01-01T01:30:00", 115, 840],
|
|
61
|
+
... ["2020-01-01T01:00:00", "2020-01-01T02:00:00", 110, 830],
|
|
62
|
+
... ]
|
|
63
|
+
>>> forecast = pd.DataFrame(
|
|
64
|
+
... forecast_data, columns=["req_time", "forecast_time", "zone_a", "zone_b"]
|
|
65
|
+
... )
|
|
66
|
+
>>> forecast.set_index(["req_time", "forecast_time"], inplace=True)
|
|
67
|
+
|
|
68
|
+
>>> time_series = TimeSeriesApi(actual, forecast)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
actual: Union[pd.Series, pd.DataFrame],
|
|
74
|
+
forecast: Optional[Union[pd.Series, pd.DataFrame]] = None,
|
|
75
|
+
fill_method: Literal["ffill", "bfill"] = "ffill",
|
|
76
|
+
):
|
|
77
|
+
actual.index = pd.to_datetime(actual.index)
|
|
78
|
+
actual.sort_index(inplace=True)
|
|
79
|
+
if isinstance(actual, pd.Series):
|
|
80
|
+
self._actual = actual.to_frame()
|
|
81
|
+
elif isinstance(actual, pd.DataFrame):
|
|
82
|
+
self._actual = actual
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError(f"Incompatible type {type(actual)} for 'actual'.")
|
|
85
|
+
|
|
86
|
+
if isinstance(forecast, (pd.Series, pd.DataFrame)):
|
|
87
|
+
# Convert all indices (either one or two columns) to datetime
|
|
88
|
+
if isinstance(forecast.index, pd.MultiIndex):
|
|
89
|
+
forecast_index: pd.MultiIndex = forecast.index
|
|
90
|
+
for level in range(forecast.index.nlevels):
|
|
91
|
+
forecast.index = forecast_index.set_levels(
|
|
92
|
+
pd.to_datetime(forecast_index.levels[level]), level=level
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
forecast.index = pd.to_datetime(forecast.index)
|
|
96
|
+
|
|
97
|
+
forecast.sort_index(inplace=True)
|
|
98
|
+
self._forecast: Optional[pd.DataFrame]
|
|
99
|
+
if isinstance(forecast, pd.Series):
|
|
100
|
+
self._forecast = forecast.to_frame()
|
|
101
|
+
else:
|
|
102
|
+
self._forecast = forecast # type: ignore
|
|
103
|
+
self._fill_method = fill_method
|
|
104
|
+
|
|
105
|
+
def zones(self) -> List:
|
|
106
|
+
"""Returns a list of all zones, where actual data is available."""
|
|
107
|
+
return list(self._actual.columns)
|
|
108
|
+
|
|
109
|
+
def actual(self, dt: DatetimeLike, zone: Optional[str] = None) -> Any:
|
|
110
|
+
"""Retrieves actual data point of zone at given time.
|
|
111
|
+
|
|
112
|
+
If queried timestamp is not available in the `actual` dataframe, the last valid
|
|
113
|
+
datapoint is being returned.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
dt: Timestamp, at which the data is needed.
|
|
117
|
+
zone: Optional zone for the data. Has to be provided if there is more than one
|
|
118
|
+
zone specified in the data. Defaults to None.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
ValueError: If there is no available data at specified zone or time.
|
|
122
|
+
"""
|
|
123
|
+
data_at_time = self._get_actual_values_at_time(dt)
|
|
124
|
+
zone_index = self._get_zone_index_from_dataframe(data_at_time, zone)
|
|
125
|
+
|
|
126
|
+
data_point = data_at_time.iloc[0, zone_index]
|
|
127
|
+
if pd.isna(data_point):
|
|
128
|
+
raise ValueError(f"Cannot retrieve actual data at '{dt}' in zone '{zone}'.")
|
|
129
|
+
return data_point
|
|
130
|
+
|
|
131
|
+
def forecast(
|
|
132
|
+
self,
|
|
133
|
+
start_time: DatetimeLike,
|
|
134
|
+
end_time: DatetimeLike,
|
|
135
|
+
zone: Optional[str] = None,
|
|
136
|
+
frequency: Optional[Union[str, pd.DateOffset, timedelta]] = None,
|
|
137
|
+
resample_method: Optional[str] = None,
|
|
138
|
+
) -> pd.Series:
|
|
139
|
+
"""Retrieves of forecasted data points within window at a frequency.
|
|
140
|
+
|
|
141
|
+
- If no forecast time-series data is provided, actual data is used.
|
|
142
|
+
- Specified timestamps are always rounded down to the nearest existing.
|
|
143
|
+
- If frequency is not specified, all existing data in the window will be returned.
|
|
144
|
+
- For various resampling methods, the actual value valid at `start_time` is used.
|
|
145
|
+
So you have to make sure that there is a valid actual value.
|
|
146
|
+
- If there is more than one zone present in the data, zone has to be specified.
|
|
147
|
+
(Note that zone name must also appear in actual data for non-static forecast.)
|
|
148
|
+
- The forecast does not include the value at `start_time` (see example).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
start_time: Starting time of the window.
|
|
152
|
+
end_time: End time of the window.
|
|
153
|
+
zone: Optional geographical zone of the forecast. Defaults to None.
|
|
154
|
+
frequency: Optional interval, in which the forecast data is to be provided.
|
|
155
|
+
Defaults to None.
|
|
156
|
+
resample_method: Optional method, to deal with holes in resampled data.
|
|
157
|
+
Can be either `bfill`, `ffill` or an interpolation method.
|
|
158
|
+
For more information on interpolation methods, see the
|
|
159
|
+
`pandas documentation <https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.interpolate.html>`_.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
pd.Series of forecasted data with timestamps of forecast as index.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ValueError: If no data is available for the specified zone or time, or if
|
|
166
|
+
insufficient data exists for the frequency, without `resample_method`
|
|
167
|
+
specified.
|
|
168
|
+
|
|
169
|
+
Example:
|
|
170
|
+
>>> index = pd.date_range(
|
|
171
|
+
... "2020-01-01T00:00:00", "2020-01-01T03:00:00", freq="1H"
|
|
172
|
+
... )
|
|
173
|
+
>>> actual = pd.DataFrame({"zone_a": [4, 6, 2, 8]}, index=index)
|
|
174
|
+
|
|
175
|
+
>>> forecast_data = [
|
|
176
|
+
... ["2020-01-01T00:00:00", "2020-01-01T01:00:00", 5],
|
|
177
|
+
... ["2020-01-01T00:00:00", "2020-01-01T02:00:00", 2],
|
|
178
|
+
... ["2020-01-01T00:00:00", "2020-01-01T03:00:00", 6],
|
|
179
|
+
... ]
|
|
180
|
+
>>> forecast = pd.DataFrame(
|
|
181
|
+
... forecast_data, columns=["req_time", "forecast_time", "zone_a"]
|
|
182
|
+
... )
|
|
183
|
+
>>> forecast.set_index(["req_time", "forecast_time"], inplace=True)
|
|
184
|
+
|
|
185
|
+
>>> time_series = TimeSeriesApi(actual, forecast)
|
|
186
|
+
|
|
187
|
+
Forward-fill resampling between 2020-01-01T00:00:00 (actual value = 4.0) and
|
|
188
|
+
forecasted values between 2020-01-01T01:00:00 and 2020-01-01T02:00:00:
|
|
189
|
+
|
|
190
|
+
>>> time_series.forecast(
|
|
191
|
+
... start_time="2020-01-01T00:00:00",
|
|
192
|
+
... end_time="2020-01-01T02:00:00",
|
|
193
|
+
... frequency="30T",
|
|
194
|
+
... resample_method="ffill",
|
|
195
|
+
... )
|
|
196
|
+
2020-01-01 00:30:00 4.0
|
|
197
|
+
2020-01-01 01:00:00 5.0
|
|
198
|
+
2020-01-01 01:30:00 5.0
|
|
199
|
+
2020-01-01 02:00:00 2.0
|
|
200
|
+
Freq: 30T, Name: zone_a, dtype: float64
|
|
201
|
+
|
|
202
|
+
Time interpolation between 2020-01-01T01:10:00 (actual value = 6.0) and
|
|
203
|
+
2020-01-01T02:00:00 (forecasted value = 2.0):
|
|
204
|
+
|
|
205
|
+
>>> time_series.forecast(
|
|
206
|
+
... start_time="2020-01-01T01:10:00",
|
|
207
|
+
... end_time="2020-01-01T01:55:00",
|
|
208
|
+
... zone="zone_a",
|
|
209
|
+
... frequency=timedelta(minutes=20),
|
|
210
|
+
... resample_method="time",
|
|
211
|
+
... )
|
|
212
|
+
2020-01-01 01:30:00 4.4
|
|
213
|
+
2020-01-01 01:50:00 2.8
|
|
214
|
+
Freq: 20T, Name: zone_a, dtype: float64
|
|
215
|
+
"""
|
|
216
|
+
data_src = self._get_forecast_data_source(start_time)
|
|
217
|
+
|
|
218
|
+
zone_index = self._get_zone_index_from_dataframe(data_src, zone)
|
|
219
|
+
|
|
220
|
+
# Get data of zone and convert to pd.Series
|
|
221
|
+
forecast: pd.Series = data_src.iloc[:, zone_index].squeeze()
|
|
222
|
+
|
|
223
|
+
# Resample the data to get the data to specified frequency
|
|
224
|
+
if frequency is not None:
|
|
225
|
+
forecast = self._resample_to_frequency(
|
|
226
|
+
forecast, start_time, end_time, frequency, resample_method=resample_method
|
|
227
|
+
)
|
|
228
|
+
return forecast.iloc[1:]
|
|
229
|
+
|
|
230
|
+
return forecast.loc[(forecast.index > start_time) & (forecast.index <= end_time)]
|
|
231
|
+
|
|
232
|
+
def _get_forecast_data_source(self, start_time: DatetimeLike):
|
|
233
|
+
"""Returns dataframe used to derive forecast prediction."""
|
|
234
|
+
data_src: pd.DataFrame
|
|
235
|
+
if self._forecast is None:
|
|
236
|
+
# Get all data points beginning at the nearest existing timestamp
|
|
237
|
+
# lower than start time from actual data
|
|
238
|
+
data_src = self._actual.loc[self._actual.index.asof(start_time) :]
|
|
239
|
+
elif self._forecast.index.nlevels == 1:
|
|
240
|
+
# Forecast data does not include request_timestamp
|
|
241
|
+
data_src = self._forecast.loc[self._forecast.index.asof(start_time) :]
|
|
242
|
+
else:
|
|
243
|
+
# Get forecasts of the nearest existing timestamp lower than start time
|
|
244
|
+
first_index = self._forecast.index.get_level_values(0)
|
|
245
|
+
request_time = first_index[first_index <= start_time][-1]
|
|
246
|
+
data_src = self._forecast.loc[request_time] # type: ignore
|
|
247
|
+
|
|
248
|
+
# Retrieve the actual value at start_time and attach it to the front of the
|
|
249
|
+
# forecast data with the timestamp (for interpolation at a later stage)
|
|
250
|
+
actual_values = self._get_actual_values_at_time(start_time)
|
|
251
|
+
data_src = pd.concat(
|
|
252
|
+
[actual_values, data_src.loc[(data_src.index > start_time)]]
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if data_src.empty:
|
|
256
|
+
raise ValueError(f"Cannot retrieve forecast data at '{start_time}'.")
|
|
257
|
+
|
|
258
|
+
return data_src
|
|
259
|
+
|
|
260
|
+
def _get_actual_values_at_time(self, dt: DatetimeLike) -> pd.DataFrame:
|
|
261
|
+
"""Return actual data that is valid at a specific time."""
|
|
262
|
+
return self._actual.reindex(index=[pd.to_datetime(dt)], method=self._fill_method)
|
|
263
|
+
|
|
264
|
+
def _get_zone_index_from_dataframe(self, df: pd.DataFrame, zone: Optional[str]):
|
|
265
|
+
"""Return column index of zone from given dataframe.
|
|
266
|
+
|
|
267
|
+
If zone is not specified, but there is only one column in the dataframe, the
|
|
268
|
+
first index is returned.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
ValueError: If zone index can not be determined.
|
|
272
|
+
"""
|
|
273
|
+
if zone is None:
|
|
274
|
+
if len(df.columns) == 1:
|
|
275
|
+
zone_index = 0
|
|
276
|
+
else:
|
|
277
|
+
raise ValueError("Zone needs to be specified.")
|
|
278
|
+
else:
|
|
279
|
+
try:
|
|
280
|
+
zone_index = df.columns.get_loc(zone)
|
|
281
|
+
except KeyError:
|
|
282
|
+
raise ValueError(f"Cannot retrieve data for zone '{zone}'.")
|
|
283
|
+
|
|
284
|
+
return zone_index
|
|
285
|
+
|
|
286
|
+
def _resample_to_frequency(
|
|
287
|
+
self,
|
|
288
|
+
df: pd.Series,
|
|
289
|
+
start_time: DatetimeLike,
|
|
290
|
+
end_time: DatetimeLike,
|
|
291
|
+
frequency: Optional[Union[str, pd.DateOffset, timedelta]] = None,
|
|
292
|
+
resample_method: Optional[str] = None,
|
|
293
|
+
):
|
|
294
|
+
"""Transform series into the desired frequency between start and end time."""
|
|
295
|
+
frequency = pd.tseries.frequencies.to_offset(frequency)
|
|
296
|
+
if frequency is None:
|
|
297
|
+
raise ValueError(f"Frequency '{frequency}' invalid.")
|
|
298
|
+
|
|
299
|
+
# Add NaN values in the specified frequency
|
|
300
|
+
new_index = pd.date_range(start=start_time, end=end_time, freq=frequency)
|
|
301
|
+
combined_index = df.index.union(new_index).sort_values()
|
|
302
|
+
df = df.reindex(combined_index)
|
|
303
|
+
|
|
304
|
+
# Use specific resample method if specified to fill NaN values
|
|
305
|
+
if resample_method == "ffill":
|
|
306
|
+
df.ffill(inplace=True)
|
|
307
|
+
elif resample_method == "bfill":
|
|
308
|
+
df.bfill(inplace=True)
|
|
309
|
+
elif resample_method is not None:
|
|
310
|
+
df.interpolate(method=resample_method, inplace=True) # type: ignore
|
|
311
|
+
elif df.isnull().values.any(): # type: ignore
|
|
312
|
+
# Check if there are NaN values if resample method is not specified
|
|
313
|
+
raise ValueError(
|
|
314
|
+
f"Not enough data at frequency '{frequency}'. Specify resample_method"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Get the data to the desired frequency after interpolation
|
|
318
|
+
return df.loc[(df.index >= start_time) & (df.index <= end_time)].asfreq(frequency)
|
|
319
|
+
|
|
320
|
+
def next_update(self, dt: DatetimeLike) -> datetime:
|
|
321
|
+
"""Returns the next time of when the actual trace will change.
|
|
322
|
+
|
|
323
|
+
This method is being called in the time-based simulation model for Mosaik.
|
|
324
|
+
"""
|
|
325
|
+
current_index = self._actual.index.asof(dt)
|
|
326
|
+
next_iloc = self._actual.index.get_loc(current_index) + 1
|
|
327
|
+
return self._actual.index[next_iloc]
|
vessim/analysis.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Utility functions for data analysis and plotting."""
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt # type: ignore
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def plot_basic_evaluation(data: pd.DataFrame, title: str = "Evaluation"):
|
|
8
|
+
"""Simple function for plotting of the simulation data.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
data: The dataframe containing the simulation data.
|
|
12
|
+
title: The title of the plot. Defaults to `Evaluation`.
|
|
13
|
+
"""
|
|
14
|
+
data.index = pd.to_datetime(data.index)
|
|
15
|
+
|
|
16
|
+
# Setup graph
|
|
17
|
+
fig, axs = plt.subplots(ncols=1, nrows=5, sharex="col",sharey="row", figsize=(10,5.5))
|
|
18
|
+
fig.tight_layout(pad=0)
|
|
19
|
+
fig.align_ylabels()
|
|
20
|
+
axs[0].set_title(title)
|
|
21
|
+
|
|
22
|
+
# Create plots from data
|
|
23
|
+
plot_production_and_consumption(axs[0], data["p_solar"], data["p_computing_system"])
|
|
24
|
+
|
|
25
|
+
plot_production_consumption_delta(axs[1], data["p_solar"], data["p_computing_system"])
|
|
26
|
+
|
|
27
|
+
plot_battery_soc(axs[2], data["battery_soc"], data["battery_min_soc"])
|
|
28
|
+
|
|
29
|
+
plot_grid_power_usage(axs[3], data["p_grid"])
|
|
30
|
+
|
|
31
|
+
ax3_twin = axs[3].twinx()
|
|
32
|
+
plot_carbon_intensity(ax3_twin, data["carbon_intensity"])
|
|
33
|
+
h1, l1 = axs[3].get_legend_handles_labels()
|
|
34
|
+
h2, l2 = ax3_twin.get_legend_handles_labels()
|
|
35
|
+
axs[3].legend(
|
|
36
|
+
[h1[0], h2[0]], ["grid power usage", "carbon emission"], loc="best", frameon=False
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
plot_accumulated_emissions(axs[4], data["p_grid"], data["carbon_intensity"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def plot_production_and_consumption(
|
|
43
|
+
ax, p_prod, p_cons, prod_color="#6B9A6E", cons_color="#D65D62"
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Create plot for showing production and consumption."""
|
|
46
|
+
p_prod.plot(ax=ax, color=prod_color, label="production")
|
|
47
|
+
(- p_cons).plot(ax=ax, color=cons_color, label="consumption", linestyle=":")
|
|
48
|
+
ax.set_ylabel("power (W)")
|
|
49
|
+
ax.legend(loc="best", frameon=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def plot_production_consumption_delta(
|
|
53
|
+
ax, p_prod, p_cons, pos_color="#79AE7C", neg_color="#D65D62"
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Create a plot for the power delta from production and consumption."""
|
|
56
|
+
power_delta = p_prod + p_cons
|
|
57
|
+
power_delta.plot(ax=ax, alpha=0)
|
|
58
|
+
ax.fill_between(power_delta.index, 0, power_delta.values,
|
|
59
|
+
where=power_delta.values > 0, color=pos_color) # type: ignore
|
|
60
|
+
ax.fill_between(power_delta.index, 0, power_delta.values,
|
|
61
|
+
where=power_delta.values < 0, color=neg_color) # type: ignore
|
|
62
|
+
ax.set_ylabel("power\ndelta (W)")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def plot_battery_soc(
|
|
66
|
+
ax, battery_soc, battery_min_soc, soc_color="#79AE7C", min_soc_color="black"
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Create plot for battery state of charge with maximum discharge."""
|
|
69
|
+
battery_soc.plot(ax=ax, alpha=0)
|
|
70
|
+
ax.fill_between(battery_soc.index, 0, battery_soc.values * 100, color=soc_color)
|
|
71
|
+
ax.set_ylim(0, 100)
|
|
72
|
+
ax.set_ylabel("battery state\nof charge (%)")
|
|
73
|
+
(battery_min_soc * 100).plot(
|
|
74
|
+
ax=ax,
|
|
75
|
+
linestyle="--",
|
|
76
|
+
linewidth=.8,
|
|
77
|
+
color=min_soc_color,
|
|
78
|
+
label="minimum state of charge"
|
|
79
|
+
)
|
|
80
|
+
handles, lables = ax.get_legend_handles_labels()
|
|
81
|
+
ax.legend(handles[1:], lables[1:], frameon=False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def plot_grid_power_usage(ax, p_grid, power_color="#D65D62") -> None:
|
|
85
|
+
"""Create plot of used power from grid over time."""
|
|
86
|
+
p_grid[p_grid > 0] = 0
|
|
87
|
+
(-p_grid).plot(ax=ax, color=power_color, linestyle="-")
|
|
88
|
+
ax.fill_between(p_grid.index, 0, (-p_grid).values.astype(float), color=power_color)
|
|
89
|
+
ax.set_ylabel("grid power\nusage (W)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def plot_carbon_intensity(ax, carbon_intensity, carbon_color="#333") -> None:
|
|
93
|
+
"""Create plot of carbon intensity over time."""
|
|
94
|
+
carbon_intensity.plot(ax=ax, color=carbon_color, linewidth=.8)
|
|
95
|
+
ax.set_ylabel("carbon intensity\n(gCO2/kWh)")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def plot_accumulated_emissions(
|
|
99
|
+
ax, p_grid, carbon_intensity, carbon_color="#999"
|
|
100
|
+
) -> float:
|
|
101
|
+
"""Create plot of total carbon emission over time.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The total emission over the whole simulation as float.
|
|
105
|
+
"""
|
|
106
|
+
emissions = (-p_grid) * carbon_intensity # 60 Ws * gCO2/kWh
|
|
107
|
+
emissions /= 60000
|
|
108
|
+
emissions.cumsum().plot(ax=ax, color="#555", linewidth=.8)
|
|
109
|
+
ax.fill_between(emissions.index, emissions.cumsum().values, color=carbon_color)
|
|
110
|
+
ax.set_ylabel("accumulated\nemissions\n(gCO2)")
|
|
111
|
+
return emissions.sum()
|
vessim/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Vessim core components."""
|
vessim/core/consumer.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Dict, List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PowerMeter(ABC):
|
|
6
|
+
"""Abstract base class for power meters.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
name: The name of the power meter.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, name: str):
|
|
13
|
+
self.name = name
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def measure(self) -> float:
|
|
17
|
+
"""Abstract method to measure and return the current node power demand.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
float: The current power demand of the node.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def finalize(self) -> None:
|
|
25
|
+
"""Perform necessary finalization tasks of specific power meter."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MockPowerMeter(PowerMeter):
|
|
29
|
+
"""Simulates the behavior of a power meter with fixed power readings.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
name: The name of the power meter.
|
|
33
|
+
p: Base factor for the measured power value. It is scaled by the consumption
|
|
34
|
+
factors in the different power modes specified in the power config.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
factor: Scaling factor, multiplied with the base factor. Defaults to 1.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If p is less than 0.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, name: str, p: float):
|
|
44
|
+
super().__init__(name)
|
|
45
|
+
if p < 0:
|
|
46
|
+
raise ValueError("p must not be less than 0")
|
|
47
|
+
self.p = p
|
|
48
|
+
self.factor = 1.0
|
|
49
|
+
|
|
50
|
+
def measure(self) -> float:
|
|
51
|
+
return self.p * self.factor
|
|
52
|
+
|
|
53
|
+
def finalize(self) -> None:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
class Consumer(ABC):
|
|
57
|
+
"""Abstract base class representing a consumer of power."""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def consumption(self) -> float:
|
|
61
|
+
"""Calculates and returns the power consumption of the consumer."""
|
|
62
|
+
|
|
63
|
+
def info(self) -> Dict:
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def finalize(self) -> None:
|
|
68
|
+
"""Perform any finalization tasks for the consumer.
|
|
69
|
+
|
|
70
|
+
This method should be overridden by subclasses to implement necessary
|
|
71
|
+
finalization steps.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ComputingSystem(Consumer):
|
|
76
|
+
"""Model of the computing system.
|
|
77
|
+
|
|
78
|
+
This model considers the power usage effectiveness (PUE) and power
|
|
79
|
+
consumption of a list of power meters.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
power_meters: A list of PowerMeter objects
|
|
83
|
+
representing power meters in the system.
|
|
84
|
+
pue: The power usage effectiveness of the system.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, power_meters: List[PowerMeter], pue: float = 1):
|
|
88
|
+
self.power_meters = power_meters
|
|
89
|
+
self.pue = pue
|
|
90
|
+
|
|
91
|
+
def consumption(self) -> float:
|
|
92
|
+
return self.pue * sum(pm.measure() for pm in self.power_meters)
|
|
93
|
+
|
|
94
|
+
def info(self) -> Dict:
|
|
95
|
+
return {pm.name: pm.measure() for pm in self.power_meters}
|
|
96
|
+
|
|
97
|
+
def finalize(self) -> None:
|
|
98
|
+
for power_meter in self.power_meters:
|
|
99
|
+
power_meter.finalize()
|
vessim/core/microgrid.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Dict, Optional
|
|
3
|
+
|
|
4
|
+
from vessim.core.storage import Storage, StoragePolicy, DefaultStoragePolicy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Microgrid(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def power_flow(self, p: Dict[str, float], duration: int) -> float:
|
|
10
|
+
"""Calculates the microgrids power flow.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
p: Maps power consumers/producers to the amount of power they demand/supply
|
|
14
|
+
duration: Duration of the power flow (relevant for battery (dis)charge)
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Power delta (excess power if positive, power demand if negative)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SimpleMicrogrid(Microgrid):
|
|
22
|
+
def __init__(
|
|
23
|
+
self, storage: Optional[Storage] = None, policy: Optional[StoragePolicy] = None
|
|
24
|
+
):
|
|
25
|
+
"""Simply aggregates all supplied and demanded power.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
storage: Optional energy storage to be used to (dis)charge a power delta
|
|
29
|
+
policy: Storage policy on when and how to use the battery
|
|
30
|
+
"""
|
|
31
|
+
self.storage = storage
|
|
32
|
+
self.policy = policy if policy is not None else DefaultStoragePolicy()
|
|
33
|
+
|
|
34
|
+
def power_flow(self, p: Dict[str, float], duration: int) -> float:
|
|
35
|
+
p_delta = sum(p.values())
|
|
36
|
+
if self.storage is None:
|
|
37
|
+
return p_delta
|
|
38
|
+
else:
|
|
39
|
+
return self.policy.apply(self.storage, p_delta, duration)
|