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.
- phyelds_simulator-8.0.0/PKG-INFO +48 -0
- phyelds_simulator-8.0.0/README.md +24 -0
- phyelds_simulator-8.0.0/pyproject.toml +39 -0
- phyelds_simulator-8.0.0/src/phyelds/simulator/__init__.py +238 -0
- phyelds_simulator-8.0.0/src/phyelds/simulator/actions.py +38 -0
- phyelds_simulator-8.0.0/src/phyelds/simulator/deployments.py +64 -0
- phyelds_simulator-8.0.0/src/phyelds/simulator/effects.py +262 -0
- phyelds_simulator-8.0.0/src/phyelds/simulator/exporter.py +73 -0
- phyelds_simulator-8.0.0/src/phyelds/simulator/neighborhood.py +61 -0
- phyelds_simulator-8.0.0/src/phyelds/simulator/render.py +156 -0
- phyelds_simulator-8.0.0/src/phyelds/simulator/runner.py +84 -0
|
@@ -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
|
+
)
|