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 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()
@@ -0,0 +1 @@
1
+ """Vessim core components."""
@@ -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()
@@ -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)