phyelds-simulator 8.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.1
2
+ Name: phyelds-simulator
3
+ Version: 8.0.0
4
+ Summary: A discrete-event simulator, with live rendering, for phyelds programs
5
+ Home-page: https://github.com/phyelds/phyelds
6
+ License: Apache-2.0
7
+ Author: Davide Domini
8
+ Author-email: davide.domini@unibo.it
9
+ Requires-Python: >=3.12,<3.14
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Provides-Extra: jupyter
16
+ Requires-Dist: ipython (>=7.34.0,<8.0.0) ; extra == "jupyter"
17
+ Requires-Dist: matplotlib (>=3.10.1,<3.11.0)
18
+ Requires-Dist: pandas (>=2.2.3,<3.0.0)
19
+ Requires-Dist: phyelds (>=8.0.0,<9.0.0)
20
+ Requires-Dist: pydantic (==2.12.5)
21
+ Project-URL: Repository, https://github.com/phyelds/phyelds
22
+ Description-Content-Type: text/markdown
23
+
24
+ # phyelds-simulator
25
+
26
+ A discrete-event simulator for [phyelds](https://pypi.org/project/phyelds/) programs,
27
+ with live rendering, animation export and data export.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install phyelds-simulator # simulator and a compatible phyelds
33
+ pip install "phyelds-simulator[jupyter]" # live rendering inside Jupyter
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from phyelds.calculus import aggregate, neighbors
40
+ from phyelds.simulator import Simulator
41
+ from phyelds.simulator.deployments import grid_generation
42
+ from phyelds.simulator.neighborhood import radius_neighborhood
43
+ from phyelds.simulator.runner import schedule_program_for_all
44
+ ```
45
+
46
+ The import paths are those of phyelds 7, where the simulator was part of `phyelds`.
47
+ See the [documentation](https://phyelds.github.io/phyelds/simulator/).
48
+
@@ -0,0 +1,24 @@
1
+ # phyelds-simulator
2
+
3
+ A discrete-event simulator for [phyelds](https://pypi.org/project/phyelds/) programs,
4
+ with live rendering, animation export and data export.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install phyelds-simulator # simulator and a compatible phyelds
10
+ pip install "phyelds-simulator[jupyter]" # live rendering inside Jupyter
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from phyelds.calculus import aggregate, neighbors
17
+ from phyelds.simulator import Simulator
18
+ from phyelds.simulator.deployments import grid_generation
19
+ from phyelds.simulator.neighborhood import radius_neighborhood
20
+ from phyelds.simulator.runner import schedule_program_for_all
21
+ ```
22
+
23
+ The import paths are those of phyelds 7, where the simulator was part of `phyelds`.
24
+ See the [documentation](https://phyelds.github.io/phyelds/simulator/).
@@ -0,0 +1,39 @@
1
+ [tool.poetry]
2
+ name = "phyelds-simulator"
3
+ version = "8.0.0"
4
+ description = "A discrete-event simulator, with live rendering, for phyelds programs"
5
+ license = "Apache-2.0"
6
+ authors = [
7
+ "Davide Domini <davide.domini@unibo.it>",
8
+ "Gianluca Aguzzi <gianluca.aguzzi@unibo.it>"
9
+ ]
10
+ homepage = "https://github.com/phyelds/phyelds"
11
+ repository = "https://github.com/phyelds/phyelds"
12
+ readme = "README.md"
13
+
14
+ packages = [
15
+ { include = "phyelds/simulator", from = "src" },
16
+ ]
17
+
18
+ classifiers = [
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+
23
+ [tool.poetry.dependencies]
24
+ python = ">=3.12,<3.14"
25
+ phyelds = ">=8.0.0,<9.0.0"
26
+ matplotlib = ">=3.10.1,<3.11.0"
27
+ pandas = "^2.2.3"
28
+ pydantic = "2.12.5"
29
+ ipython = { version = "^7.34.0", optional = true }
30
+
31
+ [tool.poetry.extras]
32
+ jupyter = ["ipython"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+
37
+ [build-system]
38
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
39
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,238 @@
1
+ """
2
+ A simple simulator for aggregate computing systems in phyelds.
3
+ This simulator allows you to create nodes, set their positions and data,
4
+ and define their neighborhoods.
5
+ It also provides a way to schedule events and run the simulation.
6
+ """
7
+
8
+ from abc import ABC
9
+ import heapq
10
+ import uuid
11
+ from typing import Dict, Callable, Any, Optional, Tuple, List
12
+
13
+
14
+ class Node:
15
+ """
16
+ A class to represent a node in the simulation.
17
+ """
18
+
19
+ def __init__(
20
+ self, position: Tuple[float, ...], data: Any = None, node_id: any = None
21
+ ):
22
+ if node_id == 0:
23
+ self.id = 0
24
+ else:
25
+ self.id = node_id or str(uuid.uuid4())
26
+ self.position = position
27
+ if data is None:
28
+ data = {}
29
+ self.data = data
30
+ self.environment = None
31
+
32
+ def update(
33
+ self, new_position: Optional[Tuple[float, ...]] = None, new_data: Any = None
34
+ ):
35
+ """Update node position and/or data"""
36
+ if new_position is not None:
37
+ self.position = new_position
38
+ if new_data is not None:
39
+ self.data = new_data
40
+
41
+ def get_neighbors(self):
42
+ """Get neighboring nodes from the environment"""
43
+ if self.environment:
44
+ return self.environment.get_neighbors(self)
45
+ return []
46
+
47
+ def __eq__(self, other):
48
+ """Define equality based on node id"""
49
+ if not isinstance(other, Node):
50
+ return False
51
+ return self.id == other.id
52
+
53
+ def __hash__(self):
54
+ """Hash based on node id"""
55
+ return hash(self.id)
56
+
57
+ def __str__(self):
58
+ return f"Node(id={self.id}, position={self.position}, data={self.data})"
59
+
60
+
61
+ class Environment:
62
+ """
63
+ A class to represent the environment in which nodes exist.
64
+ It manages the nodes and their neighborhoods.
65
+ It also provides a way to set the neighborhood function.
66
+ """
67
+
68
+ def __init__(
69
+ self, neighborhood_function: Callable[[Node, "Environment"], List[Node]] = None
70
+ ):
71
+ self.nodes: Dict[any, Node] = {}
72
+ self.neighborhood_function = neighborhood_function or self.no_neighbors
73
+
74
+ def add_data_for_all_nodes(self, data: Dict[str, Any]):
75
+ """Add data to all nodes in the environment"""
76
+ for node in self.nodes.values():
77
+ node.data.update(data)
78
+
79
+ def node_list(self) -> List[Node]:
80
+ """Return a list of all nodes in the environment"""
81
+ return list(self.nodes.values())
82
+
83
+ def add_node(self, node: Node):
84
+ """Add a node to the environment"""
85
+ self.nodes[node.id] = node
86
+ node.environment = self
87
+
88
+ def remove_node(self, node_id: str):
89
+ """Remove a node from the environment"""
90
+ if node_id in self.nodes:
91
+ self.nodes[node_id].environment = None
92
+ del self.nodes[node_id]
93
+
94
+ def set_neighborhood_function(self, func: Callable[[Node, "Environment"], List[Node]]):
95
+ """Set the function that determines neighborhoods"""
96
+ self.neighborhood_function = func
97
+
98
+ def get_neighbors(self, node: Node) -> List[Node]:
99
+ """Get neighbors for a node using the neighborhood function"""
100
+ return self.neighborhood_function(node, self)
101
+
102
+ @staticmethod
103
+ def no_neighbors(
104
+ node: Node, # pylint: disable=unused-argument
105
+ all_node: List[Node] # pylint: disable=unused-argument
106
+ ) -> List[Node]:
107
+ """Default neighborhood function (no neighbors)"""
108
+ return []
109
+
110
+
111
+ class Event:
112
+ """
113
+ A class to represent an event in the simulation.
114
+ An event has a time, an action (function), and optional arguments.
115
+ """
116
+
117
+ def __init__(self, time: float, action: Callable[..., None], *args, **kwargs):
118
+ self.time = time
119
+ self.action = action
120
+ self.args = args
121
+ self.kwargs = kwargs
122
+
123
+ def execute(self):
124
+ """Execute the event's action"""
125
+ return self.action(*self.args, **self.kwargs)
126
+
127
+ def __lt__(self, other):
128
+ """For priority queue ordering"""
129
+ return self.time < other.time
130
+
131
+ def __str__(self):
132
+ return f"Event(time={self.time}, action={self.action.__name__})"
133
+
134
+ def __repr__(self):
135
+ return self.__str__()
136
+
137
+
138
+ class Monitor(ABC):
139
+ """
140
+ An abstract base class for monitors that can observe the simulation.
141
+ """
142
+ def __init__(self, simulator):
143
+ self.simulator = simulator
144
+ self.simulator.add_monitor(monitor=self)
145
+
146
+ def on_start(self) -> None:
147
+ """Called when the simulation starts"""
148
+
149
+ def on_finish(self) -> None:
150
+ """Called when the simulation finishes"""
151
+
152
+ def update(self) -> None:
153
+ """Called after each event execution"""
154
+
155
+
156
+ class Simulator:
157
+ """
158
+ A class to represent the simulator for a simple aggregate computing system.
159
+ """
160
+ def __init__(self, environment: Environment = None):
161
+ self.event_queue = []
162
+ self.current_time = 0.0
163
+ self.running = False
164
+ if environment is None:
165
+ self.environment = Environment()
166
+ else:
167
+ self.environment = environment
168
+ self.monitors = []
169
+
170
+ def schedule_event(
171
+ self, time_delta: float, action: Callable[..., None], *args, **kwargs
172
+ ):
173
+ """Schedule an event to occur after time_delta"""
174
+ event_time = self.current_time + time_delta
175
+ event = Event(event_time, action, *args, **kwargs)
176
+ heapq.heappush(self.event_queue, event)
177
+ return event
178
+
179
+ def run(self, until_time: Optional[float] = None):
180
+ """Run the simulation until the specified time or until no more events"""
181
+ self.running = True
182
+
183
+ for monitor in self.monitors:
184
+ monitor.on_start()
185
+
186
+ while self.running and self.event_queue:
187
+ event = heapq.heappop(self.event_queue)
188
+ if until_time is not None and event.time > until_time:
189
+ heapq.heappush(self.event_queue, event) # Put the event back
190
+ break
191
+
192
+ self.current_time = event.time
193
+ event.execute()
194
+
195
+ for monitor in self.monitors:
196
+ monitor.update()
197
+
198
+ self.running = False
199
+
200
+ for monitor in self.monitors:
201
+ monitor.on_finish()
202
+
203
+ def continue_run(self, until_time: Optional[float] = None):
204
+ """Continue running the simulation until the specified time or until no more events"""
205
+ if self.running:
206
+ return # Already running
207
+ self.run(self.current_time + until_time)
208
+
209
+ def add_monitor(self, monitor: Monitor) -> None:
210
+ """Add a monitor to the simulator"""
211
+ self.monitors.append(monitor)
212
+
213
+ def stop(self):
214
+ """Stop the simulation"""
215
+ self.running = False
216
+
217
+ def cancel_event(self, event: Event):
218
+ """Cancel a scheduled event"""
219
+ if event in self.event_queue:
220
+ self.event_queue.remove(event)
221
+ heapq.heapify(self.event_queue)
222
+ else:
223
+ raise ValueError("Event not found in the queue")
224
+
225
+ def reset(self):
226
+ """Reset the simulator"""
227
+ self.event_queue = []
228
+ self.current_time = 0.0
229
+ self.running = False
230
+ self.environment = Environment()
231
+
232
+ def create_node(
233
+ self, position: Tuple[float, ...], data: Any = None, node_id=None
234
+ ) -> Node:
235
+ """Helper method to create and add a node to the environment"""
236
+ node = Node(position, data, node_id)
237
+ self.environment.add_node(node)
238
+ return node
@@ -0,0 +1,38 @@
1
+ """
2
+ Sent of actions related to node movements and interactions in the simulator
3
+ """
4
+ import random
5
+ from typing import Tuple
6
+
7
+ from phyelds.simulator import Simulator, Node
8
+
9
+
10
+ def move_with_velocity(
11
+ simulator: Simulator,
12
+ delta_time: float,
13
+ node: Node,
14
+ velocity: Tuple[float, tuple]
15
+ ):
16
+ """
17
+ Move the node with a given velocity.
18
+ """
19
+ # Update the node's position based on its velocity and the delta time
20
+ (x, y) = node.position
21
+ (vx, vy) = velocity
22
+ new_position = (x + vx * delta_time, y + vy * delta_time)
23
+ node.update(new_position)
24
+ simulator.schedule_event(
25
+ delta_time, move_with_velocity, simulator, delta_time, node, velocity
26
+ )
27
+
28
+
29
+ def gaussian_movement(
30
+ simulator: Simulator, node: Node, mean: Tuple[float, ...], stddev: float
31
+ ):
32
+ """
33
+ Move a node according to a Gaussian distribution.
34
+ """
35
+ new_position = tuple(random.gauss(mean[i], stddev) for i in range(len(mean)))
36
+ node.update(new_position)
37
+ # next schedule the event
38
+ simulator.schedule_event(1.0, gaussian_movement, simulator, node, mean, stddev)
@@ -0,0 +1,64 @@
1
+ """
2
+ This module contains various functions to generate different types of node distributions
3
+ in a simulation environment.
4
+ These functions can be used to create specific patterns or random distributions of nodes
5
+ for testing and simulation purposes.
6
+ """
7
+
8
+ import math
9
+ import random
10
+
11
+ from phyelds.simulator import Simulator
12
+
13
+
14
+ def grid_generation(simulator: Simulator, width: int, height: int, spacing: float):
15
+ """
16
+ Generate a grid of nodes in the simulator's environment.
17
+ """
18
+ for x in range(0, width):
19
+ for y in range(0, height):
20
+ position = (x * spacing, y * spacing)
21
+ simulator.create_node(position, node_id=x * height + y)
22
+
23
+
24
+ def deformed_lattice(
25
+ simulator: Simulator,
26
+ width: int,
27
+ height: int,
28
+ spacing: float,
29
+ deformation_factor: float,
30
+ ):
31
+ """
32
+ Generate a deformed lattice of nodes in the simulator's environment.
33
+ """
34
+ for x in range(0, width):
35
+ for y in range(0, height):
36
+ # Randomly deform the position
37
+ dx = random.uniform(-deformation_factor, deformation_factor)
38
+ dy = random.uniform(-deformation_factor, deformation_factor)
39
+ position = (x * spacing + dx, y * spacing + dy)
40
+ simulator.create_node(position, node_id=x * height + y)
41
+
42
+
43
+ def random_walk(simulator: Simulator, num_steps: int, step_size: float):
44
+ """
45
+ Perform a random walk and create nodes at each step.
46
+ """
47
+ position = (0.0, 0.0)
48
+ for _ in range(num_steps):
49
+ dx = random.uniform(-step_size, step_size)
50
+ dy = random.uniform(-step_size, step_size)
51
+ position = (position[0] + dx, position[1] + dy)
52
+ simulator.create_node(position)
53
+
54
+
55
+ def random_in_circle(simulator: Simulator, num_nodes: int, radius: float):
56
+ """
57
+ Generate nodes randomly distributed within a circle.
58
+ """
59
+ for index in range(num_nodes):
60
+ angle = random.uniform(0, 2 * math.pi)
61
+ r = random.uniform(0, radius)
62
+ x = r * math.cos(angle)
63
+ y = r * math.sin(angle)
64
+ simulator.create_node((x, y), node_id=index)
@@ -0,0 +1,262 @@
1
+ """
2
+ This module contains the effects that can be applied to the simulation rendering.
3
+ """
4
+
5
+ import math
6
+ from abc import ABC, abstractmethod
7
+ from enum import Enum
8
+ from typing import Literal, Optional, Any, Annotated, Tuple, List
9
+ from matplotlib import colormaps
10
+ from matplotlib.colors import Normalize, to_rgba
11
+ from pydantic import BaseModel, Field, BeforeValidator, SerializeAsAny
12
+ from phyelds.simulator import Environment
13
+
14
+
15
+ class Link:
16
+ """
17
+ A class to represent a link between two nodes.
18
+ The link is represented as a tuple of two nodes.
19
+ Note!
20
+ Link(node1, node2) == Link(node2, node1)
21
+ """
22
+
23
+ def __init__(self, node1: Tuple[float, ...], node2: Tuple[float, ...]):
24
+ self.node1 = node1
25
+ self.node2 = node2
26
+
27
+ def __hash__(self):
28
+ return hash(frozenset((self.node1, self.node2)))
29
+
30
+ def __eq__(self, other):
31
+ if isinstance(other, Link):
32
+ return frozenset((self.node1, self.node2)) == frozenset(
33
+ (other.node1, other.node2)
34
+ )
35
+ return False
36
+
37
+
38
+ class Effect(BaseModel, ABC):
39
+ """
40
+ Abstract base class for effects.
41
+ """
42
+ z_order: int = 0
43
+
44
+ @abstractmethod
45
+ def apply(self, ax, environment: Environment):
46
+ """
47
+ Apply the effect to the matplotlib axis.
48
+ """
49
+
50
+
51
+ class DrawEdges(Effect):
52
+ """
53
+ Draw edges between nodes.
54
+ """
55
+ type: Literal["DrawEdges"] = "DrawEdges"
56
+ alpha: float = 0.6
57
+ z_order: int = 0
58
+
59
+ def apply(self, ax, environment: Environment):
60
+ """
61
+ Draw edges between nodes.
62
+ """
63
+ all_neighbors_tuple = set()
64
+ for node in environment.nodes.values():
65
+ neighbors = node.get_neighbors()
66
+ for neighbor in neighbors:
67
+ all_neighbors_tuple.add(Link(node.position, neighbor.position))
68
+ for link in all_neighbors_tuple:
69
+ ax.plot(
70
+ [link.node1[0], link.node2[0]],
71
+ [link.node1[1], link.node2[1]],
72
+ alpha=self.alpha,
73
+ color="gray",
74
+ zorder=self.z_order,
75
+ )
76
+
77
+
78
+ def _is_finite_number(value: Any) -> bool:
79
+ """True for real numbers that are not infinite or NaN (booleans excluded)."""
80
+ return (
81
+ isinstance(value, (int, float))
82
+ and not isinstance(value, bool)
83
+ and math.isfinite(value)
84
+ )
85
+
86
+
87
+ class DrawNodes(Effect):
88
+ """
89
+ Draw nodes.
90
+
91
+ With ``color_from`` alone, ``node.data[color_from]`` is used as a matplotlib color.
92
+ With ``cmap`` too, it is read as a number and mapped onto that colormap between
93
+ ``vmin`` and ``vmax`` (by default, the finite values on screen); nodes whose value is
94
+ missing, infinite or not a number are drawn in ``missing_color``.
95
+ """
96
+ type: Literal["DrawNodes"] = "DrawNodes"
97
+ color_from: Optional[str] = None
98
+ cmap: Optional[str] = None
99
+ vmin: Optional[float] = None
100
+ vmax: Optional[float] = None
101
+ missing_color: str = "lightgray"
102
+ z_order: int = 10
103
+
104
+ def apply(self, ax, environment: Environment):
105
+ """
106
+ Draw nodes.
107
+ """
108
+ positions = [node.position for node in environment.nodes.values()]
109
+ if not positions:
110
+ return
111
+ x, y = zip(*positions)
112
+
113
+ if self.color_from and self.cmap:
114
+ values = [node.data.get(self.color_from) for node in environment.nodes.values()]
115
+ ax.scatter(x, y, c=self._map_to_colors(values), zorder=self.z_order)
116
+ elif self.color_from:
117
+ colors = [
118
+ node.data.get(self.color_from, "blue")
119
+ for node in environment.nodes.values()
120
+ ]
121
+ ax.scatter(x, y, c=colors, zorder=self.z_order)
122
+ else:
123
+ ax.scatter(x, y, c="blue", zorder=self.z_order)
124
+
125
+ def _map_to_colors(self, values: List[Any]) -> List[Tuple[float, float, float, float]]:
126
+ finite = [value for value in values if _is_finite_number(value)]
127
+ vmin = self.vmin if self.vmin is not None else min(finite, default=0.0)
128
+ vmax = self.vmax if self.vmax is not None else max(finite, default=1.0)
129
+ normalize = Normalize(vmin=vmin, vmax=vmax, clip=True)
130
+ colormap = colormaps[self.cmap]
131
+ missing = to_rgba(self.missing_color)
132
+ return [
133
+ colormap(normalize(value)) if _is_finite_number(value) else missing
134
+ for value in values
135
+ ]
136
+
137
+
138
+ class DrawIDs(Effect):
139
+ """
140
+ Draw node IDs.
141
+ """
142
+ type: Literal["DrawIDs"] = "DrawIDs"
143
+ z_order: int = 20
144
+
145
+ def apply(self, ax, environment: Environment):
146
+ """
147
+ Draw node IDs.
148
+ """
149
+ # Add node IDs as text labels
150
+ for node in environment.nodes.values():
151
+ ax.text(
152
+ node.position[0] + 0.02,
153
+ node.position[1],
154
+ str(node.id),
155
+ fontsize=8,
156
+ ha="center",
157
+ va="center",
158
+ bbox={"facecolor": "white", "alpha": 0.1, "edgecolor": "none"},
159
+ zorder=self.z_order,
160
+ )
161
+
162
+
163
+ class DrawValues(Effect):
164
+ """
165
+ Draw the value ``node.data[value_from]`` next to each node.
166
+ Floats are rounded to ``precision`` decimals, infinity is shown as ``∞``
167
+ and nodes without a value are skipped. ``offset`` is the vertical distance
168
+ between a node and its label, in data units.
169
+ """
170
+ type: Literal["DrawValues"] = "DrawValues"
171
+ value_from: str = "result"
172
+ precision: int = 1
173
+ offset: float = 0.15
174
+ fontsize: int = 8
175
+ z_order: int = 20
176
+
177
+ def apply(self, ax, environment: Environment):
178
+ """
179
+ Draw node values.
180
+ """
181
+ for node in environment.nodes.values():
182
+ value = node.data.get(self.value_from)
183
+ if value is None:
184
+ continue
185
+ ax.text(
186
+ node.position[0],
187
+ node.position[1] + self.offset,
188
+ self._format(value),
189
+ fontsize=self.fontsize,
190
+ ha="center",
191
+ va="bottom",
192
+ zorder=self.z_order,
193
+ )
194
+
195
+ def _format(self, value: Any) -> str:
196
+ if isinstance(value, float) and math.isinf(value):
197
+ return "∞" if value > 0 else "-∞"
198
+ if isinstance(value, float):
199
+ return f"{value:.{self.precision}f}"
200
+ return str(value)
201
+
202
+
203
+ def validate_effect(v: Any) -> Any:
204
+ """
205
+ Validate the effect type.
206
+ """
207
+ if isinstance(v, dict):
208
+ effect_type = v.get("type")
209
+ for sub in Effect.__subclasses__():
210
+ # pylint: disable=unsupported-membership-test, unsubscriptable-object
211
+ if (
212
+ "type" in sub.model_fields
213
+ and sub.model_fields["type"].default == effect_type
214
+ ):
215
+ return sub.model_validate(v)
216
+ raise ValueError(f"Unknown effect type: {effect_type}")
217
+ return v
218
+
219
+
220
+ EffectType = Annotated[Effect, BeforeValidator(validate_effect)]
221
+
222
+
223
+ class RenderMode(str, Enum):
224
+ """
225
+ Enum for render modes.
226
+ """
227
+ SHOW = "show"
228
+ SAVE = "save"
229
+ SAVE_ALL = "save_all"
230
+
231
+
232
+ class RenderConfig(BaseModel):
233
+ """
234
+ Configuration for the RenderMonitor.
235
+ """
236
+
237
+ effects: List[SerializeAsAny[EffectType]] = Field(default_factory=list)
238
+ dt: float = 1.0
239
+ skip: float = 0.0
240
+ pause_duration: float = 0.001
241
+ xlim: Optional[Tuple[float, float]] = None
242
+ ylim: Optional[Tuple[float, float]] = None
243
+ mode: RenderMode = RenderMode.SHOW
244
+ save_as: str = "simulation.mp4"
245
+ snapshot_prefix: str = "snapshot"
246
+ show_axis: bool = True
247
+ title: Optional[str] = None
248
+
249
+ def save(self, path: str):
250
+ """
251
+ Save the configuration to a file.
252
+ """
253
+ with open(path, "w", encoding="utf-8") as f:
254
+ f.write(self.model_dump_json(indent=4))
255
+
256
+ @classmethod
257
+ def load(cls, path: str):
258
+ """
259
+ Load the configuration from a file.
260
+ """
261
+ with open(path, "r", encoding="utf-8") as f:
262
+ return cls.model_validate_json(f.read())
@@ -0,0 +1,73 @@
1
+ """Exporter module for saving simulation metrics to CSV during simulation."""
2
+
3
+ import os
4
+ import itertools
5
+ from pathlib import Path
6
+ from dataclasses import dataclass, replace
7
+ import pandas as pd
8
+ from phyelds.simulator import Simulator
9
+
10
+
11
+ __all__ = ["csv_exporter", "ExporterConfig"]
12
+
13
+
14
+ @dataclass
15
+ class ExporterConfig:
16
+ """Configuration for CSV exporting of simulation metrics."""
17
+ output_directory: str
18
+ experiment_name: str
19
+ metrics: list[str]
20
+ aggregators: list[str]
21
+ precision: int
22
+ initial: bool = True
23
+
24
+
25
+ def csv_exporter(
26
+ simulator: Simulator, time_delta: float, config: ExporterConfig, **kwargs
27
+ ):
28
+ """Exporter module for saving simulation metrics to CSV."""
29
+ file_path = f'{config.output_directory}{config.experiment_name}.csv'
30
+ if not os.path.exists(file_path) or config.initial:
31
+ Path(config.output_directory).mkdir(parents=True, exist_ok=True)
32
+ df = init_dataframe(config, file_path)
33
+ else:
34
+ df = pd.read_csv(file_path)
35
+ new_data = {}
36
+ nodes = simulator.environment.nodes.values()
37
+ for metric in config.metrics:
38
+ nodes_data = [node.data['outputs'][metric] for node in nodes]
39
+ for aggregator in config.aggregators:
40
+ column = f'{metric}_{aggregator}'
41
+ new_data[column] = aggregate_values(nodes_data, config.precision, aggregator)
42
+ df = pd.concat([df, pd.DataFrame([new_data])], ignore_index=True)
43
+ df.to_csv(file_path, mode='w', index=False)
44
+ config = replace(config, initial=False)
45
+ simulator.schedule_event(
46
+ time_delta, csv_exporter, simulator, time_delta, config, **kwargs
47
+ )
48
+
49
+
50
+ def aggregate_values(values: list[float], precision, aggregator: str):
51
+ """Aggregates a list of values using the given method and rounds to the specified precision."""
52
+ if aggregator == "mean":
53
+ value = sum(values) / len(values)
54
+ elif aggregator == "std":
55
+ mean = sum(values) / len(values)
56
+ value = sum((v - mean) ** 2 for v in values) / len(values)
57
+ elif aggregator == "min":
58
+ value = min(values)
59
+ elif aggregator == "max":
60
+ value = max(values)
61
+ else:
62
+ raise ValueError(f"Invalid aggregator: {aggregator}")
63
+ return round(value, precision)
64
+
65
+
66
+ def init_dataframe(config, file_path):
67
+ """Initializes an empty DataFrame with appropriate columns based on config."""
68
+ columns = list(f'{s}_{a}' for s, a in itertools.product(config.metrics, config.aggregators))
69
+ try:
70
+ os.remove(file_path)
71
+ except OSError:
72
+ pass
73
+ return pd.DataFrame(columns=columns).astype('float64')
@@ -0,0 +1,61 @@
1
+ """
2
+ Neighborhood functions for the phyelds simulator.
3
+ A neighborhood function is simply a function that takes a node and a list of all nodes,
4
+ and returns a list of neighbors.
5
+ """
6
+
7
+ from typing import List
8
+ from phyelds.simulator import Node, Environment
9
+
10
+
11
+ def radius_neighborhood(radius: float):
12
+ """Create a neighborhood function that includes nodes within a certain radius"""
13
+
14
+ def neighborhood_func(node: Node, environment: Environment) -> List[Node]:
15
+ all_nodes: List[Node] = environment.node_list()
16
+ neighbors = []
17
+ for other in all_nodes:
18
+ if other.id == node.id: # Skip self
19
+ continue
20
+
21
+ # Calculate Euclidean distance
22
+ distance = (
23
+ sum((a - b) ** 2 for a, b in zip(node.position, other.position)) ** 0.5
24
+ )
25
+
26
+ if distance <= radius:
27
+ neighbors.append(other)
28
+ return neighbors
29
+
30
+ return neighborhood_func
31
+
32
+
33
+ def k_nearest_neighbors(k: int):
34
+ """Create a neighborhood function that includes the k nearest nodes"""
35
+
36
+ def neighborhood_func(node: Node, environment: Environment) -> List[Node]:
37
+ all_nodes: List[Node] = environment.node_list()
38
+ others = [n for n in all_nodes if n.id != node.id]
39
+ if not others:
40
+ return []
41
+
42
+ # Calculate distances
43
+ distances = [
44
+ (
45
+ sum((a - b) ** 2 for a, b in zip(node.position, other.position)) ** 0.5,
46
+ other,
47
+ )
48
+ for other in others
49
+ ]
50
+
51
+ # Sort by distance and take k nearest
52
+ distances.sort()
53
+ return [other for _, other in distances[:k]]
54
+
55
+ return neighborhood_func
56
+
57
+
58
+ def full_neighborhood(node: Node, environment: Environment) -> List[Node]:
59
+ """Include all nodes as neighbors"""
60
+ all_nodes: List[Node] = environment.node_list()
61
+ return [n for n in all_nodes if n.id != node.id]
@@ -0,0 +1,156 @@
1
+ """
2
+ Render the nodes in the simulator's environment.
3
+ """
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from matplotlib import pyplot as plt
8
+ from matplotlib.animation import AbstractMovieWriter, FFMpegWriter, PillowWriter
9
+ from matplotlib.figure import Figure
10
+
11
+ from phyelds.simulator import Simulator, Monitor
12
+ from phyelds.simulator.effects import RenderConfig, RenderMode
13
+
14
+
15
+ def make_writer(config: RenderConfig) -> AbstractMovieWriter:
16
+ """
17
+ Create the movie writer matching the extension of ``config.save_as``.
18
+
19
+ Args:
20
+ config: the render configuration.
21
+
22
+ Returns:
23
+ A ``PillowWriter`` for ``.gif`` files, an ``FFMpegWriter`` otherwise.
24
+ """
25
+ fps = 1 / config.dt if config.dt > 0 else 15
26
+ if Path(config.save_as).suffix.lower() == ".gif":
27
+ return PillowWriter(fps=fps)
28
+ metadata = {"title": "Simulation", "artist": "Phyelds"}
29
+ return FFMpegWriter(fps=fps, metadata=metadata)
30
+
31
+
32
+ def _padded_limits(values: tuple[float, ...], pad: float = 0.05) -> tuple[float, float]:
33
+ """
34
+ Axis limits enclosing ``values`` with a margin of ``pad`` times their range.
35
+ When all values coincide (e.g. nodes on a line) the margin is 1, so the limits
36
+ never collapse into a single point.
37
+ """
38
+ low, high = min(values), max(values)
39
+ margin = pad * (high - low) if high > low else 1.0
40
+ return low - margin, high + margin
41
+
42
+
43
+ def _show_in_notebook(figure: Figure) -> None:
44
+ """
45
+ Replace the output of the current notebook cell with ``figure``.
46
+ IPython is imported here, and not at module level, because it is only
47
+ available inside Jupyter: rendering to a file must work without it.
48
+ """
49
+ # IPython is the optional `jupyter` extra, so it may be missing where pylint runs.
50
+ # pylint: disable=import-outside-toplevel,import-error
51
+ from IPython.display import clear_output, display
52
+
53
+ clear_output(wait=True)
54
+ display(figure)
55
+
56
+
57
+ class RenderMonitor(Monitor):
58
+ """
59
+ Render the nodes in the simulator's environment.
60
+ """
61
+
62
+ def __init__(self, simulator: Simulator, config: RenderConfig):
63
+ super().__init__(simulator)
64
+ self.config = config
65
+ self.config.effects.sort(key=lambda e: e.z_order)
66
+ self.last_render_time = 0
67
+ self.fig, self.ax = plt.subplots()
68
+ self.writer = None
69
+
70
+ def on_start(self):
71
+ if self.config.mode in [RenderMode.SAVE, RenderMode.SAVE_ALL]:
72
+ self.writer = make_writer(self.config)
73
+ self.writer.setup(self.fig, self.config.save_as, dpi=100)
74
+ elif self.config.mode == RenderMode.SHOW:
75
+ plt.ion()
76
+ plt.show()
77
+
78
+ def update(self):
79
+ if self.simulator.current_time < self.config.skip or self._instant_in_progress():
80
+ return
81
+ if self.simulator.current_time - self.last_render_time >= self.config.dt:
82
+ self._render()
83
+ self.last_render_time = self.simulator.current_time
84
+
85
+ def _instant_in_progress(self) -> bool:
86
+ """True while other events are still queued for the current time."""
87
+ queue = self.simulator.event_queue
88
+ return bool(queue) and queue[0].time <= self.simulator.current_time
89
+
90
+ def on_finish(self):
91
+ if self.config.mode in [RenderMode.SAVE, RenderMode.SAVE_ALL]:
92
+ self.writer.finish()
93
+ if self.config.mode == RenderMode.SAVE:
94
+ self.fig.savefig(Path(self.config.save_as).with_suffix(".png"))
95
+ elif self.config.mode == RenderMode.SHOW:
96
+ if 'ipykernel' in sys.modules:
97
+ _show_in_notebook(self.fig)
98
+ else:
99
+ plt.ioff()
100
+ plt.show()
101
+ plt.close(self.fig)
102
+
103
+ def _render(self):
104
+ self.ax.clear()
105
+
106
+ for effect in self.config.effects:
107
+ effect.apply(self.ax, self.simulator.environment)
108
+
109
+ self._setup_axis()
110
+ self._setup_limits()
111
+
112
+ if self.config.mode == RenderMode.SAVE_ALL:
113
+ self.fig.savefig(
114
+ f"{self.config.snapshot_prefix}_{self.simulator.current_time:.2f}.png"
115
+ )
116
+
117
+ if self.config.mode in [RenderMode.SAVE, RenderMode.SAVE_ALL]:
118
+ self.writer.grab_frame()
119
+ elif self.config.mode == RenderMode.SHOW:
120
+ if 'ipykernel' in sys.modules:
121
+ _show_in_notebook(self.fig)
122
+ else:
123
+ plt.draw()
124
+ plt.pause(self.config.pause_duration)
125
+
126
+ def _setup_axis(self):
127
+ if self.config.show_axis:
128
+ if self.config.title:
129
+ self.ax.set_title(self.config.title)
130
+ self.ax.set_xlabel("X Position")
131
+ self.ax.set_ylabel("Y Position")
132
+ self.ax.axis("on")
133
+ else:
134
+ self.ax.axis("off")
135
+ if self.config.title:
136
+ self.ax.set_title(self.config.title)
137
+ self.fig.subplots_adjust(left=0.01, right=0.99, bottom=0.01, top=0.9)
138
+ else:
139
+ self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)
140
+
141
+ def _setup_limits(self):
142
+ if self.config.xlim:
143
+ self.ax.set_xlim(self.config.xlim)
144
+ if self.config.ylim:
145
+ self.ax.set_ylim(self.config.ylim)
146
+ elif not self.config.xlim and not self.config.ylim:
147
+ positions = [
148
+ node.position for node in self.simulator.environment.nodes.values()
149
+ ]
150
+ if positions:
151
+ x, y = zip(*positions)
152
+ self.ax.set_xlim(*_padded_limits(x))
153
+ self.ax.set_ylim(*_padded_limits(y))
154
+ self.ax.set_aspect("equal")
155
+ else:
156
+ self.ax.axis("equal")
@@ -0,0 +1,84 @@
1
+ """
2
+ This module contains the event to run the aggregate program in the simulator
3
+ """
4
+ from abc import ABC
5
+
6
+ from phyelds.vm.internal import MutableEngine
7
+
8
+ from phyelds import engine
9
+ from phyelds.vm import NodeContext
10
+ from phyelds.data import State
11
+ from phyelds.simulator import Simulator, Node
12
+
13
+
14
+ class SimulatorNodeContext(NodeContext, ABC):
15
+ """
16
+ A class to represent the context of a node in the simulator.
17
+ This class is used to pass the node's data and messages to the aggregate program.
18
+ """
19
+
20
+ @staticmethod
21
+ def from_node(node: Node):
22
+ """
23
+ Create a SimulatorNodeContext from a Node.
24
+ :param node: The node to create the context from.
25
+ :return: The SimulatorNodeContext.
26
+ """
27
+ sensors = {
28
+ "position": node.position,
29
+ **node.data,
30
+ }
31
+ return SimulatorNodeContext(node.id, sensors)
32
+
33
+
34
+ def aggregate_program_runner(
35
+ simulator: Simulator, time_delta: float, node: Node, program: callable, **kwargs
36
+ ):
37
+ """
38
+ Run the program for a node.
39
+ """
40
+ if node.id not in simulator.environment.nodes.keys():
41
+ # If the node is not in the environment, do not run the program
42
+ return
43
+ # get neighbors
44
+ all_neighbors = simulator.environment.get_neighbors(node)
45
+ # take the messages from the neighbors, create a dict like id -> messages (that is a dict)
46
+ neighbors_messages = {
47
+ neighbor.id: neighbor.data.get("messages", {}) for neighbor in all_neighbors
48
+ }
49
+ node.data["time"] = simulator.current_time # prepare time sensor
50
+ engine.set(MutableEngine())
51
+ engine.get().setup(
52
+ SimulatorNodeContext.from_node(node),
53
+ neighbors_messages,
54
+ node.data.get("state", {})
55
+ )
56
+ result = program(**kwargs)
57
+ if isinstance(result, State):
58
+ result = result.value
59
+ node.data["result"] = result
60
+ node.data["messages"] = engine.get().cooldown()
61
+ node.data["state"] = engine.get().state_trace()
62
+ node.data["outputs"] = engine.get().node_context.outputs
63
+ simulator.schedule_event(
64
+ time_delta, aggregate_program_runner, simulator, time_delta, node, program, **kwargs
65
+ )
66
+
67
+
68
+ def schedule_program_for_all(
69
+ simulator: Simulator,
70
+ start: float,
71
+ frequency: float,
72
+ program: callable, **kwargs
73
+ ):
74
+ """
75
+ Schedule the program for all nodes in the simulator.
76
+ :param simulator: The simulator to schedule the program for.
77
+ :param start: The time to start the program.
78
+ :param frequency: The frequency to run the program.
79
+ :param program: The program to run.
80
+ """
81
+ for node in simulator.environment.nodes.values():
82
+ simulator.schedule_event(
83
+ start, aggregate_program_runner, simulator, frequency, node, program, **kwargs
84
+ )