vessim 0.9__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,34 @@
1
+ """A simulator for carbon-aware applications and systems."""
2
+
3
+ from vessim.actor import ActorBase, Actor, ComputingSystem
4
+ from vessim.controller import Controller, Monitor
5
+ from vessim.cosim import Microgrid, Environment
6
+ from vessim.policy import MicrogridPolicy, DefaultMicrogridPolicy
7
+ from vessim.signal import Signal, HistoricalSignal, MockSignal, CollectorSignal
8
+ from vessim.storage import Storage, SimpleBattery, ClcBattery
9
+
10
+ __all__ = [
11
+ "ActorBase",
12
+ "Actor",
13
+ "ComputingSystem",
14
+ "Controller",
15
+ "Monitor",
16
+ "Microgrid",
17
+ "Environment",
18
+ "MicrogridPolicy",
19
+ "DefaultMicrogridPolicy",
20
+ "CollectorSignal",
21
+ "MockSignal",
22
+ "Signal",
23
+ "HistoricalSignal",
24
+ "Storage",
25
+ "ClcBattery",
26
+ "SimpleBattery",
27
+ ]
28
+
29
+ try:
30
+ from vessim.sil import Broker, SilController, WatttimeSignal, get_latest_event # noqa: F401
31
+
32
+ __all__.extend(["Broker", "SilController", "WatttimeSignal", "get_latest_event"])
33
+ except ImportError:
34
+ pass
vessim/_data.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import urllib.request
5
+ from datetime import timedelta
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from zipfile import ZipFile
9
+
10
+ import pandas as pd
11
+
12
+ VESSIM_DATASETS: dict[str, dict[str, str]] = {
13
+ "solcast2022_germany": {
14
+ "actual": "solcast2022_germany_actual.csv",
15
+ "forecast": "solcast2022_germany_forecast.csv",
16
+ "fill_method": "bfill",
17
+ "url": "https://raw.githubusercontent.com/dos-group/vessim/main/datasets/solcast2022_germany.zip",
18
+ },
19
+ "solcast2022_global": {
20
+ "actual": "solcast2022_global_actual.csv",
21
+ "forecast": "solcast2022_global_forecast.csv",
22
+ "fill_method": "bfill",
23
+ "url": "https://raw.githubusercontent.com/dos-group/vessim/main/datasets/solcast2022_global.zip",
24
+ },
25
+ "watttime2023_caiso-north": {
26
+ "actual": "watttime2023_caiso-north_actual.csv",
27
+ "forecast": "watttime2023_caiso-north_forecast.csv",
28
+ "fill_method": "ffill",
29
+ "url": "https://raw.githubusercontent.com/dos-group/vessim/main/datasets/watttime2023_caiso-north.zip",
30
+ },
31
+ }
32
+
33
+
34
+ def load_dataset(dataset: str, dir_path: Path, params: Optional[dict] = None) -> dict:
35
+ """Downloads a dataset from the vessim repository, unpacks it and loads data."""
36
+ if dataset not in VESSIM_DATASETS:
37
+ raise ValueError(f"Dataset '{dataset}' not found. Available datasets are: "
38
+ f"{', '.join(list(VESSIM_DATASETS.keys()))}")
39
+
40
+ if params is not None:
41
+ allowed_parameters = ["scale", "start_time", "use_forecast"]
42
+ for key in params.keys():
43
+ if key not in allowed_parameters:
44
+ raise ValueError(f"Parameter '{key}' not allowed. "
45
+ f"Allowed parameters are: {allowed_parameters}.")
46
+
47
+ scale = _get_parameter(params, "scale", default=1.0)
48
+ start_time = _get_parameter(params, "start_time", default=None)
49
+ use_forecast = _get_parameter(params, "use_forecast", default=True)
50
+
51
+ dataset_config = VESSIM_DATASETS[dataset]
52
+ required_files = [dataset_config["actual"]]
53
+ if use_forecast:
54
+ required_files.append(dataset_config["forecast"])
55
+
56
+ if not _check_files(required_files, dir_path):
57
+ print("Required data files not present locally. Try downloading...")
58
+ os.makedirs(dir_path, exist_ok=True)
59
+ zip_path = dir_path / "dataset.zip"
60
+ try:
61
+ urllib.request.urlretrieve(dataset_config["url"], zip_path)
62
+ except Exception:
63
+ raise RuntimeError(f"Dataset could not be retrieved from url: "
64
+ f"{dataset_config['url']}")
65
+
66
+ with ZipFile(zip_path, "r") as zip_ref:
67
+ zip_ref.extractall(path=dir_path)
68
+ os.remove(zip_path)
69
+ print("Successfully downloaded and unpacked data files.")
70
+
71
+ actual = _read_data_from_csv(
72
+ dir_path / dataset_config["actual"], index_cols=[0], scale=scale
73
+ )
74
+
75
+ forecast: Optional[pd.Series | pd.DataFrame] = None
76
+ if use_forecast:
77
+ forecast = _read_data_from_csv(
78
+ dir_path / dataset_config["forecast"], index_cols=[0, 1], scale=scale
79
+ )
80
+
81
+ if start_time is not None:
82
+ shift = pd.to_datetime(start_time) - actual.index[0]
83
+ print(f"Data is being shifted by {shift}")
84
+ actual.index += shift
85
+ if use_forecast:
86
+ forecast = _shift(forecast, shift) # type: ignore
87
+
88
+ return dict(
89
+ actual=actual,
90
+ forecast=None if not use_forecast else forecast,
91
+ fill_method=dataset_config.get("fill_method", "ffill"),
92
+ )
93
+
94
+
95
+ def _get_parameter(params: Optional[dict], key: str, default):
96
+ if params is None:
97
+ return default
98
+ return params.get(key, default)
99
+
100
+
101
+ def _check_files(files: list[str], base_dir: Path) -> bool:
102
+ """Check whether files are present in specified base directory."""
103
+ for file in files:
104
+ path = os.path.join(base_dir, file)
105
+ if not os.path.isfile(path):
106
+ return False
107
+ return True
108
+
109
+
110
+ def _read_data_from_csv(
111
+ path: Path, index_cols: list[int], scale: float = 1.0
112
+ ) -> pd.Series | pd.DataFrame:
113
+ """Retrieves a dataframe from a csv file and transforms it."""
114
+ df = pd.read_csv(path, index_col=index_cols)
115
+ if isinstance(df.index, pd.MultiIndex):
116
+ index: pd.MultiIndex = df.index
117
+ for i, level in enumerate(index.levels):
118
+ index = index.set_levels(pd.to_datetime(level), level=i)
119
+ df.index = index
120
+ else:
121
+ df.index = pd.to_datetime(df.index)
122
+
123
+ df.sort_index(inplace=True)
124
+ return (df * scale).astype(float)
125
+
126
+
127
+ def _shift(df: pd.Series | pd.DataFrame, shift: timedelta) -> pd.Series | pd.DataFrame:
128
+ """Shifts indices of the given DataFrame by a timedelta."""
129
+ if isinstance(df.index, pd.MultiIndex):
130
+ index: pd.MultiIndex = df.index
131
+ for i, level in enumerate(index.levels):
132
+ index = index.set_levels(level + shift, level=i)
133
+ df.index = index
134
+ else:
135
+ df.index += shift
136
+ return df
vessim/_util.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta
4
+ from typing import Union
5
+ from loguru import logger
6
+ import sys
7
+
8
+ import pandas as pd
9
+ import numpy as np
10
+
11
+ DatetimeLike = Union[str, datetime, np.datetime64]
12
+
13
+
14
+ class Clock:
15
+ def __init__(self, sim_start: str | datetime):
16
+ self.sim_start = pd.to_datetime(sim_start)
17
+
18
+ def to_datetime(self, simtime: int) -> datetime:
19
+ return self.sim_start + timedelta(seconds=simtime)
20
+
21
+ def to_simtime(self, dt: datetime) -> int:
22
+ return int((dt - self.sim_start).total_seconds())
23
+
24
+
25
+ def disable_rt_warnings(behind_threshold: float):
26
+ """Disables Mosaik's rt_check warnings.
27
+
28
+ Mosaik's current implementation of the real-time checks are faulty and a new
29
+ implementation is already outlined in
30
+ https://gitlab.com/mosaik/mosaik/-/issues/172. As this issue was opened in
31
+ June 2023, it is unlikely that the issue will be resolved soon. Therefore,
32
+ we disable the warnings with a threshold after which these offsets are
33
+ logged.
34
+
35
+ Args:
36
+ behind_threshold: Time the simulation is allowed to be behind schedule.
37
+ """
38
+
39
+ def filter_record(record):
40
+ is_warning = record["level"].name == "WARNING"
41
+ is_mosaik_log = record["name"].startswith("mosaik")
42
+ is_below_threshold = (
43
+ record["function"] == "rt_check"
44
+ and float(record["message"].split(" - ")[1].split("s")[0]) < behind_threshold
45
+ )
46
+ return not (is_warning and is_mosaik_log and is_below_threshold)
47
+
48
+ # Add the filter to the logger
49
+ logger.remove()
50
+ logger.add(sys.stdout, filter=filter_record)
vessim/actor.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from itertools import count
5
+ from typing import Optional
6
+ from abc import ABC, abstractmethod
7
+
8
+ import mosaik_api_v3 # type: ignore
9
+
10
+ from vessim.signal import Signal
11
+
12
+
13
+ class ActorBase(ABC):
14
+ """Abstract base class representing a power consumer or producer."""
15
+
16
+ def __init__(self, name: str, step_size: Optional[int] = None) -> None:
17
+ self.name = name
18
+ self.step_size = step_size
19
+
20
+ @abstractmethod
21
+ def p(self, now: datetime) -> float:
22
+ """Current power consumption/production to be used in the grid simulation."""
23
+
24
+ def state(self, now: datetime) -> dict:
25
+ """Current state of the actor to be used in controllers."""
26
+ return {}
27
+
28
+ def finalize(self) -> None:
29
+ """Perform any finalization tasks for the consumer.
30
+
31
+ This method can be overridden by subclasses to implement necessary
32
+ finalization steps.
33
+ """
34
+ return
35
+
36
+
37
+ class Actor(ActorBase):
38
+ """Actor that represents a consumer of producer based on a single Signal."""
39
+
40
+ def __init__(self, name: str, signal: Signal, step_size: Optional[int] = None) -> None:
41
+ super().__init__(name, step_size)
42
+ self.signal = signal
43
+
44
+ def p(self, now: datetime) -> float:
45
+ return self.signal.now(at=now)
46
+
47
+ def state(self, now: datetime) -> dict:
48
+ return {
49
+ "p": self.p(now),
50
+ }
51
+
52
+
53
+ class ComputingSystem(ActorBase):
54
+ """Model of the computing system.
55
+
56
+ This model considers the power usage effectiveness (PUE) and power
57
+ consumption of a list of power meters.
58
+
59
+ Args:
60
+ nodes: list of consumer node signals that constitute the computing system's demand.
61
+ pue: The power usage effectiveness of the system.
62
+ """
63
+
64
+ _ids = count(0)
65
+
66
+ def __init__(
67
+ self,
68
+ nodes: list[Signal],
69
+ name: Optional[str] = None,
70
+ step_size: Optional[int] = None,
71
+ pue: float = 1,
72
+ ):
73
+ if name is None:
74
+ name = f"ComputingSystem-{next(self._ids)}"
75
+ super().__init__(name, step_size)
76
+ self.nodes = nodes
77
+ node_ids = count(0)
78
+ for node in self.nodes:
79
+ if not node.name:
80
+ node.name = f"Node-{next(node_ids)}"
81
+ self.pue = pue
82
+
83
+ def p(self, now: datetime) -> float:
84
+ return self.pue * sum(-signal.now(at=now) for signal in self.nodes)
85
+
86
+ def state(self, now: datetime) -> dict:
87
+ return {
88
+ "p": self.p(now),
89
+ "nodes": {signal.name: -signal.now(at=now) for signal in self.nodes},
90
+ }
91
+
92
+
93
+ class _ActorSim(mosaik_api_v3.Simulator):
94
+ META = {
95
+ "type": "time-based",
96
+ "models": {
97
+ "Actor": {
98
+ "public": True,
99
+ "params": ["actor"],
100
+ "attrs": ["p", "state"],
101
+ },
102
+ },
103
+ }
104
+
105
+ def __init__(self):
106
+ super().__init__(self.META)
107
+ self.eid = None
108
+ self.step_size = None
109
+ self.clock = None
110
+ self.actor = None
111
+ self.p = 0
112
+ self.state = {}
113
+
114
+ def init(self, sid, time_resolution=1.0, **sim_params):
115
+ self.step_size = sim_params["step_size"]
116
+ self.clock = sim_params["clock"]
117
+ return self.meta
118
+
119
+ def create(self, num, model, **model_params):
120
+ assert num == 1, "Only one instance per simulation is supported"
121
+ self.actor = model_params["actor"]
122
+ self.eid = self.actor.name
123
+ return [{"eid": self.eid, "type": model}]
124
+
125
+ def step(self, time, inputs, max_advance):
126
+ assert self.clock is not None
127
+ now = self.clock.to_datetime(time)
128
+ assert self.actor is not None
129
+ self.p = self.actor.p(now)
130
+ self.state = self.actor.state(now)
131
+ assert self.step_size is not None
132
+ return time + self.step_size
133
+
134
+ def get_data(self, outputs):
135
+ return {self.eid: {"p": self.p, "state": self.state}}
vessim/controller.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections import defaultdict
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from csv import DictWriter
8
+ from itertools import count
9
+ from collections.abc import Iterator
10
+ from typing import Any, MutableMapping, Optional, Callable, TYPE_CHECKING
11
+
12
+ import mosaik_api_v3 # type: ignore
13
+ import pandas as pd
14
+
15
+ from vessim.signal import Signal
16
+
17
+ if TYPE_CHECKING:
18
+ from vessim.cosim import Microgrid
19
+
20
+
21
+ class Controller(ABC):
22
+ _counters: dict[type[Controller], Iterator[int]] = {}
23
+
24
+ def __init_subclass__(cls, **kwargs) -> None:
25
+ """Initializes the subclass and sets up a counter for naming."""
26
+ super().__init_subclass__(**kwargs)
27
+ cls._counters[cls] = count()
28
+
29
+ def __init__(self, step_size: Optional[int] = None) -> None:
30
+ cls = self.__class__
31
+ self.name: str = f"{cls.__name__}-{next(cls._counters[cls])}"
32
+ self.step_size = step_size
33
+ self.set_parameters: dict[str, Any] = {}
34
+
35
+ def start(self, microgrid: Microgrid) -> None:
36
+ """Function to be executed before simulation is started. Can be overridden."""
37
+ pass
38
+
39
+ @abstractmethod
40
+ def step(self, time: datetime, p_delta: float, e_delta: float, state: dict) -> None:
41
+ """Performs a simulation step.
42
+
43
+ Args:
44
+ time: Current datetime.
45
+ p_delta: Power delta in W based on the consumption and production of all actors.
46
+ e_delta: Total energy in Ws that has been drawn from/ fed to the utility grid
47
+ in the previous time step.
48
+ state: Contains the last state dictionaries by all actors, the policy, and the storage
49
+ in the microgrid. The state dictionary is defined by the microgrid components and
50
+ can contain any information about their custom defined state.
51
+ The keys are the actor names, `policy`, and `storage` respectively.
52
+ """
53
+
54
+ def finalize(self) -> None:
55
+ """Function to be executed after simulation has ended. Can be overridden for clean-up."""
56
+ pass
57
+
58
+
59
+ class Monitor(Controller):
60
+ def __init__(
61
+ self,
62
+ step_size: Optional[int] = None,
63
+ outfile: Optional[str | Path] = None,
64
+ grid_signals: Optional[dict[str, Signal]] = None,
65
+ ):
66
+ super().__init__(step_size=step_size)
67
+ self.outpath: Optional[Path] = None
68
+ if outfile:
69
+ self.outpath = Path(outfile).expanduser()
70
+ self._fieldnames: Optional[list] = None
71
+
72
+ self.monitor_log: dict[datetime, dict] = defaultdict(dict)
73
+ self.custom_monitor_fns: list[Callable] = []
74
+
75
+ if grid_signals is not None:
76
+ for signal_name, signal_api in grid_signals.items():
77
+
78
+ def fn(time):
79
+ return {signal_name: signal_api.now(time)}
80
+
81
+ self.add_monitor_fn(fn)
82
+
83
+ def add_monitor_fn(self, fn: Callable[[float], dict[str, Any]]):
84
+ self.custom_monitor_fns.append(fn)
85
+
86
+ def step(self, time: datetime, p_delta: float, e_delta: float, state: dict) -> None:
87
+ log_entry = dict(
88
+ p_delta=p_delta,
89
+ e_delta=e_delta,
90
+ )
91
+ log_entry.update(state)
92
+ for monitor_fn in self.custom_monitor_fns:
93
+ log_entry.update(monitor_fn(time))
94
+ self.monitor_log[time] = log_entry
95
+
96
+ if self.outpath:
97
+ if not self._fieldnames:
98
+ log_dict = _flatten_dict(log_entry)
99
+ self._fieldnames = list(log_dict.keys())
100
+ self._fieldnames.insert(0, "time")
101
+ log_dict["time"] = time
102
+ with self.outpath.open("w") as csvfile:
103
+ writer = DictWriter(csvfile, fieldnames=self._fieldnames)
104
+ writer.writeheader()
105
+ writer.writerow(log_dict)
106
+ else:
107
+ with self.outpath.open('a', newline='') as csvfile:
108
+ writer = DictWriter(csvfile, fieldnames=self._fieldnames)
109
+ log_dict = _flatten_dict(log_entry)
110
+ log_dict["time"] = time
111
+ writer.writerow(log_dict)
112
+
113
+ def to_csv(self, out_path: str):
114
+ df = pd.DataFrame({k: _flatten_dict(v) for k, v in self.monitor_log.items()}).T
115
+ df.to_csv(out_path)
116
+
117
+
118
+ def _flatten_dict(d: MutableMapping, parent_key: str = "") -> MutableMapping:
119
+ items: list[tuple[str, Any]] = []
120
+ for k, v in d.items():
121
+ new_key = parent_key + "." + k if parent_key else k
122
+ if isinstance(v, MutableMapping):
123
+ items.extend(_flatten_dict(v, str(new_key)).items())
124
+ else:
125
+ items.append((new_key, v))
126
+ return dict(items)
127
+
128
+
129
+ class _ControllerSim(mosaik_api_v3.Simulator):
130
+ META = {
131
+ "type": "time-based",
132
+ "models": {
133
+ "Controller": {
134
+ "public": True,
135
+ "any_inputs": True,
136
+ "params": ["controller"],
137
+ "attrs": ["set_parameters"],
138
+ },
139
+ },
140
+ }
141
+
142
+ def __init__(self):
143
+ super().__init__(self.META)
144
+ self.eid = "Controller"
145
+ self.step_size = None
146
+ self.clock = None
147
+ self.controller = None
148
+ self.e = 0.0
149
+
150
+ def init(self, sid, time_resolution=1.0, **sim_params):
151
+ self.step_size = sim_params["step_size"]
152
+ self.clock = sim_params["clock"]
153
+ return self.meta
154
+
155
+ def create(self, num, model, **model_params):
156
+ assert num == 1, "Only one instance per simulation is supported"
157
+ self.controller = model_params["controller"]
158
+ return [{"eid": self.eid, "type": model}]
159
+
160
+ def step(self, time, inputs, max_advance):
161
+ assert self.controller is not None
162
+ assert self.clock is not None
163
+ assert self.step_size is not None
164
+ now = self.clock.to_datetime(time)
165
+ self.controller.step(now, *self._parse_controller_inputs(inputs[self.eid]))
166
+ self.set_parameters = self.controller.set_parameters.copy()
167
+ self.controller.set_parameters = {}
168
+ return time + self.step_size
169
+
170
+ def get_data(self, outputs):
171
+ return {self.eid: {"set_parameters": self.set_parameters}}
172
+
173
+ def finalize(self) -> None:
174
+ """Stops the api server and the collector thread when the simulation finishes."""
175
+ assert self.controller is not None
176
+ self.controller.finalize()
177
+
178
+ def _parse_controller_inputs(
179
+ self, inputs: dict[str, dict[str, Any]]
180
+ ) -> tuple[float, float, dict]:
181
+ p_delta = _get_val(inputs, "p_delta")
182
+ last_e = self.e
183
+ self.e = _get_val(inputs, "e")
184
+ actor_keys = [k for k in inputs.keys() if k.startswith("actor")]
185
+ actors: defaultdict[str, Any] = defaultdict(dict)
186
+ for k in actor_keys:
187
+ _, actor_name = k.split(".")
188
+ actors[actor_name] = _get_val(inputs, k)
189
+ state = dict(actors)
190
+ state.update(_get_val(inputs, "state"))
191
+ return p_delta, self.e - last_e, state
192
+
193
+
194
+ def _get_val(inputs: dict, key: str) -> Any:
195
+ return list(inputs[key].values())[0]