Skip to content

Documentation for Topologymanager Module

TopologyManager

Source code in nebula/addons/topologymanager.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
class TopologyManager:
    def __init__(
        self,
        scenario_name=None,
        n_nodes=5,
        b_symmetric=True,
        undirected_neighbor_num=5,
        topology=None,
    ):
        """
        Initializes a network topology for the scenario.

        This constructor sets up a network topology with a given number of nodes, neighbors, and other parameters.
        It includes options to specify whether the topology should be symmetric and the number of undirected neighbors for each node.
        It also checks for constraints on the number of neighbors and the structure of the network.

        Parameters:
            - scenario_name (str, optional): Name of the scenario.
            - n_nodes (int): Number of nodes in the network (default 5).
            - b_symmetric (bool): Whether the topology is symmetric (default True).
            - undirected_neighbor_num (int): Number of undirected neighbors for each node (default 5).
            - topology (list, optional): Predefined topology, a list of nodes and connections (default None).

        Raises:
            - ValueError: If `undirected_neighbor_num` is less than 2.

        Attributes:
            - scenario_name (str): Name of the scenario.
            - n_nodes (int): Number of nodes in the network.
            - b_symmetric (bool): Whether the topology is symmetric.
            - undirected_neighbor_num (int): Number of undirected neighbors.
            - topology (list): Topology of the network.
            - nodes (np.ndarray): Array of nodes initialized with zeroes.
            - b_fully_connected (bool): Flag indicating if the topology is fully connected.
        """
        self.scenario_name = scenario_name
        if topology is None:
            topology = []
        self.n_nodes = n_nodes
        self.b_symmetric = b_symmetric
        self.undirected_neighbor_num = undirected_neighbor_num
        self.topology = topology
        # Initialize nodes with array of tuples (0,0,0) with size n_nodes
        self.nodes = np.zeros((n_nodes, 3), dtype=np.int32)

        self.b_fully_connected = False
        if self.undirected_neighbor_num < 2:
            raise ValueError("undirected_neighbor_num must be greater than 2")  # noqa: TRY003
        # If the number of neighbors is larger than the number of nodes, then the topology is fully connected
        if self.undirected_neighbor_num >= self.n_nodes - 1 and self.b_symmetric:
            self.b_fully_connected = True

    def __getstate__(self):
        """
        Serializes the object state for saving.

        This method defines which attributes of the class should be serialized when the object is pickled (saved to a file).
        It returns a dictionary containing the attributes that need to be preserved.

        Returns:
            dict: A dictionary containing the relevant attributes of the object for serialization.
                - scenario_name (str): Name of the scenario.
                - n_nodes (int): Number of nodes in the network.
                - topology (list): Topology of the network.
                - nodes (np.ndarray): Array of nodes in the network.
        """
        # Return the attributes of the class that should be serialized
        return {
            "scenario_name": self.scenario_name,
            "n_nodes": self.n_nodes,
            "topology": self.topology,
            "nodes": self.nodes,
        }

    def __setstate__(self, state):
        """
        Restores the object state from the serialized data.

        This method is called during deserialization (unpickling) to restore the object's state
        by setting the attributes using the provided state dictionary.

        Args:
            state (dict): A dictionary containing the serialized data, including:
                - scenario_name (str): Name of the scenario.
                - n_nodes (int): Number of nodes in the network.
                - topology (list): Topology of the network.
                - nodes (np.ndarray): Array of nodes in the network.
        """
        # Set the attributes of the class from the serialized state
        self.scenario_name = state["scenario_name"]
        self.n_nodes = state["n_nodes"]
        self.topology = state["topology"]
        self.nodes = state["nodes"]

    def get_node_color(self, role):
        """
        Returns the color associated with a given role.

        The method maps roles to specific colors for visualization or representation purposes.

        Args:
            role (Role): The role for which the color is to be determined.

        Returns:
            str: The color associated with the given role. Defaults to "red" if the role is not recognized.
        """
        role_colors = {
            Role.AGGREGATOR: "orange",
            Role.SERVER: "green",
            Role.TRAINER: "#6182bd",
            Role.PROXY: "purple",
        }
        return role_colors.get(role, "red")

    def add_legend(self, roles):
        """
        Adds a legend to the plot for different roles, associating each role with a color.

        The method iterates through the provided roles and assigns the corresponding color to each one.
        The colors are predefined in the legend_map, which associates each role with a specific color.

        Args:
            roles (iterable): A collection of roles for which the legend should be displayed.

        Returns:
            None: The function modifies the plot directly by adding the legend.
        """
        legend_map = {
            Role.AGGREGATOR: "orange",
            Role.SERVER: "green",
            Role.TRAINER: "#6182bd",
            Role.PROXY: "purple",
            Role.IDLE: "red",
        }
        for role, color in legend_map.items():
            if role in roles:
                plt.scatter([], [], c=color, label=role)
        plt.legend()

    def draw_graph(self, plot=False, path=None):
        """
        Draws the network graph based on the topology and saves it as an image.

        This method generates a visualization of the network's topology using NetworkX and Matplotlib.
        It assigns colors to the nodes based on their role, draws the network's nodes and edges,
        adds labels to the nodes, and includes a legend for clarity.
        The resulting plot is saved as an image file.

        Args:
            plot (bool, optional): Whether to display the plot. Default is False.
            path (str, optional): The file path where the image will be saved. If None, the image is saved
                                  to a default location based on the scenario name.

        Returns:
            None: The method saves the plot as an image at the specified path.
        """
        g = nx.from_numpy_array(self.topology)
        pos = nx.spring_layout(g, k=0.15, iterations=20, seed=42)

        fig = plt.figure(num="Network topology", dpi=100, figsize=(6, 6), frameon=False)
        ax = fig.add_axes([0, 0, 1, 1])
        ax.set_xlim([-1.3, 1.3])
        ax.set_ylim([-1.3, 1.3])
        labels = {}
        color_map = []
        for k in range(self.n_nodes):
            role = str(self.nodes[k][2])
            color_map.append(self.get_node_color(role))
            labels[k] = f"P{k}\n" + str(self.nodes[k][0]) + ":" + str(self.nodes[k][1])

        nx.draw_networkx_nodes(g, pos, node_color=color_map, linewidths=2)
        nx.draw_networkx_labels(g, pos, labels, font_size=10, font_weight="bold")
        nx.draw_networkx_edges(g, pos, width=2)

        self.add_legend([str(node[2]) for node in self.nodes])
        plt.savefig(f"{path}", dpi=100, bbox_inches="tight", pad_inches=0)
        plt.close()

    def generate_topology(self):
        """
        Generates the network topology based on the configured settings.

        This method generates the network topology for the given scenario. It checks whether the topology
        should be fully connected, symmetric, or asymmetric and then generates the network accordingly.

        - If the topology is fully connected, all nodes will be directly connected to each other.
        - If the topology is symmetric, neighbors will be chosen symmetrically between nodes.
        - If the topology is asymmetric, neighbors will be picked randomly without symmetry.

        Returns:
            None: The method modifies the internal topology of the network.
        """
        if self.b_fully_connected:
            self.__fully_connected()
            return

        if self.topology is not None and len(self.topology) > 0:
            # Topology was already provided
            return

        if self.b_symmetric:
            self.__randomly_pick_neighbors_symmetric()
        else:
            self.__randomly_pick_neighbors_asymmetric()

    def generate_server_topology(self):
        """
        Generates a server topology where the first node (usually the server) is connected to all other nodes.

        This method initializes a topology matrix where the first node (typically the server) is connected to
        every other node in the network. The first row and the first column of the matrix are set to 1, representing
        connections to and from the server. The diagonal is set to 0 to indicate that no node is connected to itself.

        Returns:
            None: The method modifies the internal `self.topology` matrix.
        """
        self.topology = np.zeros((self.n_nodes, self.n_nodes), dtype=np.float32)
        self.topology[0, :] = 1
        self.topology[:, 0] = 1
        np.fill_diagonal(self.topology, 0)

    def generate_ring_topology(self, increase_convergence=False):
        """
        Generates a ring topology for the network.

        In a ring topology, each node is connected to two other nodes in a circular fashion, forming a closed loop.
        This method uses a private method to generate the topology, with an optional parameter to control whether
        the convergence speed of the network should be increased.

        Args:
            increase_convergence (bool): Optional flag to increase the convergence speed in the topology.
                                          Defaults to False.

        Returns:
            None: The method modifies the internal `self.topology` matrix to reflect the generated ring topology.
        """
        self.__ring_topology(increase_convergence=increase_convergence)

    def generate_random_topology(self, probability):
        """
        Generates a random topology using Erdos-Renyi model with given probability.

        Args:
            probability (float): Probability of edge creation between any two nodes (0-1)

        Returns:
            None: Updates self.topology with the generated random topology
        """
        random_graph = nx.erdos_renyi_graph(self.n_nodes, probability)
        self.topology = nx.to_numpy_array(random_graph, dtype=np.float32)
        np.fill_diagonal(self.topology, 0)  # No self-loops

    @staticmethod
    def get_coordinates(random_geo=True):
        """
        Generates random geographical coordinates within predefined bounds for either Spain or Switzerland.

        The method returns a random geographical coordinate (latitude, longitude). The bounds for random coordinates are
        defined for two regions: Spain and Switzerland. The region is chosen randomly, and then the latitude and longitude
        are selected within the corresponding bounds.

        Parameters:
            random_geo (bool): If set to True, the method generates random coordinates within the predefined bounds
                                for Spain or Switzerland. If set to False, this method could be modified to return fixed
                                coordinates.

        Returns:
            tuple: A tuple containing the latitude and longitude of the generated point.
        """
        if random_geo:
            #  España min_lat, max_lat, min_lon, max_lon                  Suiza min_lat, max_lat, min_lon, max_lon
            bounds = (36.0, 43.0, -9.0, 3.3) if random.randint(0, 1) == 0 else (45.8, 47.8, 5.9, 10.5)  # noqa: S311

            min_latitude, max_latitude, min_longitude, max_longitude = bounds
            latitude = random.uniform(min_latitude, max_latitude)  # noqa: S311
            longitude = random.uniform(min_longitude, max_longitude)  # noqa: S311

            return latitude, longitude

    def add_nodes(self, nodes):
        """
        Sets the nodes of the topology.

        This method updates the `nodes` attribute with the given list or array of nodes.

        Parameters:
            nodes (array-like): The new set of nodes to be assigned to the topology. It should be in a format compatible
                                 with the existing `nodes` structure, typically an array or list.

        Returns:
            None
        """
        self.nodes = nodes

    def update_nodes(self, config_participants):
        """
        Updates the nodes of the topology based on the provided configuration.

        This method assigns a new set of nodes to the `nodes` attribute, typically based on the configuration of the participants.

        Parameters:
            config_participants (array-like): A new set of nodes, usually derived from the participants' configuration, to be assigned to the topology.

        Returns:
            None
        """
        self.nodes = config_participants

    def get_neighbors_string(self, node_idx):
        """
        Retrieves the neighbors of a given node as a string representation.

        This method checks the `topology` attribute to find the neighbors of the node at the specified index (`node_idx`). It then returns a string that lists the coordinates of each neighbor.

        Parameters:
            node_idx (int): The index of the node for which neighbors are to be retrieved.

        Returns:
            str: A space-separated string of neighbors' coordinates in the format "latitude:longitude".
        """
        # logging.info(f"Topology: {self.topology}")
        # logging.info(f"Nodes: {self.nodes}")
        neighbors_data = []
        for i, node in enumerate(self.topology[node_idx]):
            if node == 1:
                neighbors_data.append(self.nodes[i])

        neighbors_data_strings = [f"{i[0]}:{i[1]}" for i in neighbors_data]
        neighbors_data_string = " ".join(neighbors_data_strings)
        logging.info(f"Neighbors of node participant_{node_idx}: {neighbors_data_string}")
        return neighbors_data_string

    def __ring_topology(self, increase_convergence=False):
        """
        Generates a ring topology for the nodes.

        This method creates a ring topology for the network using the Watts-Strogatz model. Each node is connected to two neighbors, forming a ring. Optionally, additional random connections are added to increase convergence, making the network more connected.

        Parameters:
            increase_convergence (bool): If set to True, random connections will be added between nodes to increase the network's connectivity.

        Returns:
            None: The `topology` attribute of the class is updated with the generated ring topology.
        """
        topology_ring = np.array(
            nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, 2, 0)),
            dtype=np.float32,
        )

        if increase_convergence:
            # Create random links between nodes in topology_ring
            for i in range(self.n_nodes):
                for j in range(self.n_nodes):
                    if topology_ring[i][j] == 0 and random.random() < 0.1:  # noqa: S311
                        topology_ring[i][j] = 1
                        topology_ring[j][i] = 1

        np.fill_diagonal(topology_ring, 0)
        self.topology = topology_ring

    def __randomly_pick_neighbors_symmetric(self):
        """
        Generates a symmetric random topology by combining a ring topology with additional random links.

        This method first creates a ring topology using the Watts-Strogatz model, where each node is connected to two neighbors. Then, it randomly adds links to each node (up to the specified number of neighbors) to form a symmetric topology. The result is a topology where each node has a fixed number of undirected neighbors, and the connections are symmetric between nodes.

        Parameters:
            None

        Returns:
            None: The `topology` attribute of the class is updated with the generated symmetric topology.
        """
        # First generate a ring topology
        topology_ring = np.array(
            nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, 2, 0)),
            dtype=np.float32,
        )

        np.fill_diagonal(topology_ring, 0)

        # After, randomly add some links for each node (symmetric)
        # If undirected_neighbor_num is X, then each node has X links to other nodes
        k = int(self.undirected_neighbor_num)
        topology_random_link = np.array(
            nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, k, 0)),
            dtype=np.float32,
        )

        # generate symmetric topology
        topology_symmetric = topology_ring.copy()
        for i in range(self.n_nodes):
            for j in range(self.n_nodes):
                if topology_symmetric[i][j] == 0 and topology_random_link[i][j] == 1:
                    topology_symmetric[i][j] = topology_random_link[i][j]

        np.fill_diagonal(topology_symmetric, 0)

        self.topology = topology_symmetric

    def __randomly_pick_neighbors_asymmetric(self):
        """
        Generates an asymmetric random topology by combining a ring topology with additional random links and random deletions.

        This method first creates a ring topology using the Watts-Strogatz model, where each node is connected to two neighbors. Then, it randomly adds links to each node to create a topology with a specified number of undirected neighbors. After that, it randomly deletes some of the links to introduce asymmetry. The result is a topology where nodes have a varying number of directed and undirected links, and the structure is asymmetric.

        Parameters:
            None

        Returns:
            None: The `topology` attribute of the class is updated with the generated asymmetric topology.
        """
        # randomly add some links for each node (symmetric)
        k = self.undirected_neighbor_num
        topology_random_link = np.array(
            nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, k, 0)),
            dtype=np.float32,
        )

        np.fill_diagonal(topology_random_link, 0)

        # first generate a ring topology
        topology_ring = np.array(
            nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, 2, 0)),
            dtype=np.float32,
        )

        np.fill_diagonal(topology_ring, 0)

        for i in range(self.n_nodes):
            for j in range(self.n_nodes):
                if topology_ring[i][j] == 0 and topology_random_link[i][j] == 1:
                    topology_ring[i][j] = topology_random_link[i][j]

        np.fill_diagonal(topology_ring, 0)

        # randomly delete some links
        out_link_set = set()
        for i in range(self.n_nodes):
            len_row_zero = 0
            for j in range(self.n_nodes):
                if topology_ring[i][j] == 0:
                    len_row_zero += 1
            random_selection = np.random.randint(2, size=len_row_zero)
            index_of_zero = 0
            for j in range(self.n_nodes):
                out_link = j * self.n_nodes + i
                if topology_ring[i][j] == 0:
                    if random_selection[index_of_zero] == 1 and out_link not in out_link_set:
                        topology_ring[i][j] = 1
                        out_link_set.add(i * self.n_nodes + j)
                    index_of_zero += 1

        np.fill_diagonal(topology_ring, 0)

        self.topology = topology_ring

    def __fully_connected(self):
        """
        Generates a fully connected topology where each node is connected to every other node.

        This method creates a fully connected network by generating a Watts-Strogatz graph with the number of nodes set to `n_nodes` and the number of neighbors set to `n_nodes - 1`. The resulting graph is then converted into a numpy matrix and all missing links (i.e., non-ones in the adjacency matrix) are set to 1 to ensure complete connectivity. The diagonal elements are filled with zeros to avoid self-loops.

        Parameters:
            None

        Returns:
            None: The `topology` attribute of the class is updated with the generated fully connected topology.
        """
        topology_fully_connected = np.array(
            nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, self.n_nodes - 1, 0)),
            dtype=np.float32,
        )

        np.fill_diagonal(topology_fully_connected, 0)

        for i in range(self.n_nodes):
            for j in range(self.n_nodes):
                if topology_fully_connected[i][j] != 1:
                    topology_fully_connected[i][j] = 1

        np.fill_diagonal(topology_fully_connected, 0)

        self.topology = topology_fully_connected

__fully_connected()

Generates a fully connected topology where each node is connected to every other node.

This method creates a fully connected network by generating a Watts-Strogatz graph with the number of nodes set to n_nodes and the number of neighbors set to n_nodes - 1. The resulting graph is then converted into a numpy matrix and all missing links (i.e., non-ones in the adjacency matrix) are set to 1 to ensure complete connectivity. The diagonal elements are filled with zeros to avoid self-loops.

Returns:

Name Type Description
None

The topology attribute of the class is updated with the generated fully connected topology.

Source code in nebula/addons/topologymanager.py
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
def __fully_connected(self):
    """
    Generates a fully connected topology where each node is connected to every other node.

    This method creates a fully connected network by generating a Watts-Strogatz graph with the number of nodes set to `n_nodes` and the number of neighbors set to `n_nodes - 1`. The resulting graph is then converted into a numpy matrix and all missing links (i.e., non-ones in the adjacency matrix) are set to 1 to ensure complete connectivity. The diagonal elements are filled with zeros to avoid self-loops.

    Parameters:
        None

    Returns:
        None: The `topology` attribute of the class is updated with the generated fully connected topology.
    """
    topology_fully_connected = np.array(
        nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, self.n_nodes - 1, 0)),
        dtype=np.float32,
    )

    np.fill_diagonal(topology_fully_connected, 0)

    for i in range(self.n_nodes):
        for j in range(self.n_nodes):
            if topology_fully_connected[i][j] != 1:
                topology_fully_connected[i][j] = 1

    np.fill_diagonal(topology_fully_connected, 0)

    self.topology = topology_fully_connected

__getstate__()

Serializes the object state for saving.

This method defines which attributes of the class should be serialized when the object is pickled (saved to a file). It returns a dictionary containing the attributes that need to be preserved.

Returns:

Name Type Description
dict

A dictionary containing the relevant attributes of the object for serialization. - scenario_name (str): Name of the scenario. - n_nodes (int): Number of nodes in the network. - topology (list): Topology of the network. - nodes (np.ndarray): Array of nodes in the network.

Source code in nebula/addons/topologymanager.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def __getstate__(self):
    """
    Serializes the object state for saving.

    This method defines which attributes of the class should be serialized when the object is pickled (saved to a file).
    It returns a dictionary containing the attributes that need to be preserved.

    Returns:
        dict: A dictionary containing the relevant attributes of the object for serialization.
            - scenario_name (str): Name of the scenario.
            - n_nodes (int): Number of nodes in the network.
            - topology (list): Topology of the network.
            - nodes (np.ndarray): Array of nodes in the network.
    """
    # Return the attributes of the class that should be serialized
    return {
        "scenario_name": self.scenario_name,
        "n_nodes": self.n_nodes,
        "topology": self.topology,
        "nodes": self.nodes,
    }

__init__(scenario_name=None, n_nodes=5, b_symmetric=True, undirected_neighbor_num=5, topology=None)

Initializes a network topology for the scenario.

This constructor sets up a network topology with a given number of nodes, neighbors, and other parameters. It includes options to specify whether the topology should be symmetric and the number of undirected neighbors for each node. It also checks for constraints on the number of neighbors and the structure of the network.

Parameters:

Name Type Description Default
- scenario_name (str

Name of the scenario.

required
- n_nodes (int

Number of nodes in the network (default 5).

required
- b_symmetric (bool

Whether the topology is symmetric (default True).

required
- undirected_neighbor_num (int

Number of undirected neighbors for each node (default 5).

required
- topology (list

Predefined topology, a list of nodes and connections (default None).

required

Raises:

Type Description
-ValueError

If undirected_neighbor_num is less than 2.

Attributes:

Name Type Description
- scenario_name (str

Name of the scenario.

- n_nodes (int

Number of nodes in the network.

- b_symmetric (bool

Whether the topology is symmetric.

- undirected_neighbor_num (int

Number of undirected neighbors.

- topology (list

Topology of the network.

- nodes (np.ndarray

Array of nodes initialized with zeroes.

- b_fully_connected (bool

Flag indicating if the topology is fully connected.

Source code in nebula/addons/topologymanager.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(
    self,
    scenario_name=None,
    n_nodes=5,
    b_symmetric=True,
    undirected_neighbor_num=5,
    topology=None,
):
    """
    Initializes a network topology for the scenario.

    This constructor sets up a network topology with a given number of nodes, neighbors, and other parameters.
    It includes options to specify whether the topology should be symmetric and the number of undirected neighbors for each node.
    It also checks for constraints on the number of neighbors and the structure of the network.

    Parameters:
        - scenario_name (str, optional): Name of the scenario.
        - n_nodes (int): Number of nodes in the network (default 5).
        - b_symmetric (bool): Whether the topology is symmetric (default True).
        - undirected_neighbor_num (int): Number of undirected neighbors for each node (default 5).
        - topology (list, optional): Predefined topology, a list of nodes and connections (default None).

    Raises:
        - ValueError: If `undirected_neighbor_num` is less than 2.

    Attributes:
        - scenario_name (str): Name of the scenario.
        - n_nodes (int): Number of nodes in the network.
        - b_symmetric (bool): Whether the topology is symmetric.
        - undirected_neighbor_num (int): Number of undirected neighbors.
        - topology (list): Topology of the network.
        - nodes (np.ndarray): Array of nodes initialized with zeroes.
        - b_fully_connected (bool): Flag indicating if the topology is fully connected.
    """
    self.scenario_name = scenario_name
    if topology is None:
        topology = []
    self.n_nodes = n_nodes
    self.b_symmetric = b_symmetric
    self.undirected_neighbor_num = undirected_neighbor_num
    self.topology = topology
    # Initialize nodes with array of tuples (0,0,0) with size n_nodes
    self.nodes = np.zeros((n_nodes, 3), dtype=np.int32)

    self.b_fully_connected = False
    if self.undirected_neighbor_num < 2:
        raise ValueError("undirected_neighbor_num must be greater than 2")  # noqa: TRY003
    # If the number of neighbors is larger than the number of nodes, then the topology is fully connected
    if self.undirected_neighbor_num >= self.n_nodes - 1 and self.b_symmetric:
        self.b_fully_connected = True

__randomly_pick_neighbors_asymmetric()

Generates an asymmetric random topology by combining a ring topology with additional random links and random deletions.

This method first creates a ring topology using the Watts-Strogatz model, where each node is connected to two neighbors. Then, it randomly adds links to each node to create a topology with a specified number of undirected neighbors. After that, it randomly deletes some of the links to introduce asymmetry. The result is a topology where nodes have a varying number of directed and undirected links, and the structure is asymmetric.

Returns:

Name Type Description
None

The topology attribute of the class is updated with the generated asymmetric topology.

Source code in nebula/addons/topologymanager.py
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
def __randomly_pick_neighbors_asymmetric(self):
    """
    Generates an asymmetric random topology by combining a ring topology with additional random links and random deletions.

    This method first creates a ring topology using the Watts-Strogatz model, where each node is connected to two neighbors. Then, it randomly adds links to each node to create a topology with a specified number of undirected neighbors. After that, it randomly deletes some of the links to introduce asymmetry. The result is a topology where nodes have a varying number of directed and undirected links, and the structure is asymmetric.

    Parameters:
        None

    Returns:
        None: The `topology` attribute of the class is updated with the generated asymmetric topology.
    """
    # randomly add some links for each node (symmetric)
    k = self.undirected_neighbor_num
    topology_random_link = np.array(
        nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, k, 0)),
        dtype=np.float32,
    )

    np.fill_diagonal(topology_random_link, 0)

    # first generate a ring topology
    topology_ring = np.array(
        nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, 2, 0)),
        dtype=np.float32,
    )

    np.fill_diagonal(topology_ring, 0)

    for i in range(self.n_nodes):
        for j in range(self.n_nodes):
            if topology_ring[i][j] == 0 and topology_random_link[i][j] == 1:
                topology_ring[i][j] = topology_random_link[i][j]

    np.fill_diagonal(topology_ring, 0)

    # randomly delete some links
    out_link_set = set()
    for i in range(self.n_nodes):
        len_row_zero = 0
        for j in range(self.n_nodes):
            if topology_ring[i][j] == 0:
                len_row_zero += 1
        random_selection = np.random.randint(2, size=len_row_zero)
        index_of_zero = 0
        for j in range(self.n_nodes):
            out_link = j * self.n_nodes + i
            if topology_ring[i][j] == 0:
                if random_selection[index_of_zero] == 1 and out_link not in out_link_set:
                    topology_ring[i][j] = 1
                    out_link_set.add(i * self.n_nodes + j)
                index_of_zero += 1

    np.fill_diagonal(topology_ring, 0)

    self.topology = topology_ring

__randomly_pick_neighbors_symmetric()

Generates a symmetric random topology by combining a ring topology with additional random links.

This method first creates a ring topology using the Watts-Strogatz model, where each node is connected to two neighbors. Then, it randomly adds links to each node (up to the specified number of neighbors) to form a symmetric topology. The result is a topology where each node has a fixed number of undirected neighbors, and the connections are symmetric between nodes.

Returns:

Name Type Description
None

The topology attribute of the class is updated with the generated symmetric topology.

Source code in nebula/addons/topologymanager.py
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
def __randomly_pick_neighbors_symmetric(self):
    """
    Generates a symmetric random topology by combining a ring topology with additional random links.

    This method first creates a ring topology using the Watts-Strogatz model, where each node is connected to two neighbors. Then, it randomly adds links to each node (up to the specified number of neighbors) to form a symmetric topology. The result is a topology where each node has a fixed number of undirected neighbors, and the connections are symmetric between nodes.

    Parameters:
        None

    Returns:
        None: The `topology` attribute of the class is updated with the generated symmetric topology.
    """
    # First generate a ring topology
    topology_ring = np.array(
        nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, 2, 0)),
        dtype=np.float32,
    )

    np.fill_diagonal(topology_ring, 0)

    # After, randomly add some links for each node (symmetric)
    # If undirected_neighbor_num is X, then each node has X links to other nodes
    k = int(self.undirected_neighbor_num)
    topology_random_link = np.array(
        nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, k, 0)),
        dtype=np.float32,
    )

    # generate symmetric topology
    topology_symmetric = topology_ring.copy()
    for i in range(self.n_nodes):
        for j in range(self.n_nodes):
            if topology_symmetric[i][j] == 0 and topology_random_link[i][j] == 1:
                topology_symmetric[i][j] = topology_random_link[i][j]

    np.fill_diagonal(topology_symmetric, 0)

    self.topology = topology_symmetric

__ring_topology(increase_convergence=False)

Generates a ring topology for the nodes.

This method creates a ring topology for the network using the Watts-Strogatz model. Each node is connected to two neighbors, forming a ring. Optionally, additional random connections are added to increase convergence, making the network more connected.

Parameters:

Name Type Description Default
increase_convergence bool

If set to True, random connections will be added between nodes to increase the network's connectivity.

False

Returns:

Name Type Description
None

The topology attribute of the class is updated with the generated ring topology.

Source code in nebula/addons/topologymanager.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
def __ring_topology(self, increase_convergence=False):
    """
    Generates a ring topology for the nodes.

    This method creates a ring topology for the network using the Watts-Strogatz model. Each node is connected to two neighbors, forming a ring. Optionally, additional random connections are added to increase convergence, making the network more connected.

    Parameters:
        increase_convergence (bool): If set to True, random connections will be added between nodes to increase the network's connectivity.

    Returns:
        None: The `topology` attribute of the class is updated with the generated ring topology.
    """
    topology_ring = np.array(
        nx.to_numpy_matrix(nx.watts_strogatz_graph(self.n_nodes, 2, 0)),
        dtype=np.float32,
    )

    if increase_convergence:
        # Create random links between nodes in topology_ring
        for i in range(self.n_nodes):
            for j in range(self.n_nodes):
                if topology_ring[i][j] == 0 and random.random() < 0.1:  # noqa: S311
                    topology_ring[i][j] = 1
                    topology_ring[j][i] = 1

    np.fill_diagonal(topology_ring, 0)
    self.topology = topology_ring

__setstate__(state)

Restores the object state from the serialized data.

This method is called during deserialization (unpickling) to restore the object's state by setting the attributes using the provided state dictionary.

Parameters:

Name Type Description Default
state dict

A dictionary containing the serialized data, including: - scenario_name (str): Name of the scenario. - n_nodes (int): Number of nodes in the network. - topology (list): Topology of the network. - nodes (np.ndarray): Array of nodes in the network.

required
Source code in nebula/addons/topologymanager.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def __setstate__(self, state):
    """
    Restores the object state from the serialized data.

    This method is called during deserialization (unpickling) to restore the object's state
    by setting the attributes using the provided state dictionary.

    Args:
        state (dict): A dictionary containing the serialized data, including:
            - scenario_name (str): Name of the scenario.
            - n_nodes (int): Number of nodes in the network.
            - topology (list): Topology of the network.
            - nodes (np.ndarray): Array of nodes in the network.
    """
    # Set the attributes of the class from the serialized state
    self.scenario_name = state["scenario_name"]
    self.n_nodes = state["n_nodes"]
    self.topology = state["topology"]
    self.nodes = state["nodes"]

add_legend(roles)

Adds a legend to the plot for different roles, associating each role with a color.

The method iterates through the provided roles and assigns the corresponding color to each one. The colors are predefined in the legend_map, which associates each role with a specific color.

Parameters:

Name Type Description Default
roles iterable

A collection of roles for which the legend should be displayed.

required

Returns:

Name Type Description
None

The function modifies the plot directly by adding the legend.

Source code in nebula/addons/topologymanager.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def add_legend(self, roles):
    """
    Adds a legend to the plot for different roles, associating each role with a color.

    The method iterates through the provided roles and assigns the corresponding color to each one.
    The colors are predefined in the legend_map, which associates each role with a specific color.

    Args:
        roles (iterable): A collection of roles for which the legend should be displayed.

    Returns:
        None: The function modifies the plot directly by adding the legend.
    """
    legend_map = {
        Role.AGGREGATOR: "orange",
        Role.SERVER: "green",
        Role.TRAINER: "#6182bd",
        Role.PROXY: "purple",
        Role.IDLE: "red",
    }
    for role, color in legend_map.items():
        if role in roles:
            plt.scatter([], [], c=color, label=role)
    plt.legend()

add_nodes(nodes)

Sets the nodes of the topology.

This method updates the nodes attribute with the given list or array of nodes.

Parameters:

Name Type Description Default
nodes array - like

The new set of nodes to be assigned to the topology. It should be in a format compatible with the existing nodes structure, typically an array or list.

required

Returns:

Type Description

None

Source code in nebula/addons/topologymanager.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def add_nodes(self, nodes):
    """
    Sets the nodes of the topology.

    This method updates the `nodes` attribute with the given list or array of nodes.

    Parameters:
        nodes (array-like): The new set of nodes to be assigned to the topology. It should be in a format compatible
                             with the existing `nodes` structure, typically an array or list.

    Returns:
        None
    """
    self.nodes = nodes

draw_graph(plot=False, path=None)

Draws the network graph based on the topology and saves it as an image.

This method generates a visualization of the network's topology using NetworkX and Matplotlib. It assigns colors to the nodes based on their role, draws the network's nodes and edges, adds labels to the nodes, and includes a legend for clarity. The resulting plot is saved as an image file.

Parameters:

Name Type Description Default
plot bool

Whether to display the plot. Default is False.

False
path str

The file path where the image will be saved. If None, the image is saved to a default location based on the scenario name.

None

Returns:

Name Type Description
None

The method saves the plot as an image at the specified path.

Source code in nebula/addons/topologymanager.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def draw_graph(self, plot=False, path=None):
    """
    Draws the network graph based on the topology and saves it as an image.

    This method generates a visualization of the network's topology using NetworkX and Matplotlib.
    It assigns colors to the nodes based on their role, draws the network's nodes and edges,
    adds labels to the nodes, and includes a legend for clarity.
    The resulting plot is saved as an image file.

    Args:
        plot (bool, optional): Whether to display the plot. Default is False.
        path (str, optional): The file path where the image will be saved. If None, the image is saved
                              to a default location based on the scenario name.

    Returns:
        None: The method saves the plot as an image at the specified path.
    """
    g = nx.from_numpy_array(self.topology)
    pos = nx.spring_layout(g, k=0.15, iterations=20, seed=42)

    fig = plt.figure(num="Network topology", dpi=100, figsize=(6, 6), frameon=False)
    ax = fig.add_axes([0, 0, 1, 1])
    ax.set_xlim([-1.3, 1.3])
    ax.set_ylim([-1.3, 1.3])
    labels = {}
    color_map = []
    for k in range(self.n_nodes):
        role = str(self.nodes[k][2])
        color_map.append(self.get_node_color(role))
        labels[k] = f"P{k}\n" + str(self.nodes[k][0]) + ":" + str(self.nodes[k][1])

    nx.draw_networkx_nodes(g, pos, node_color=color_map, linewidths=2)
    nx.draw_networkx_labels(g, pos, labels, font_size=10, font_weight="bold")
    nx.draw_networkx_edges(g, pos, width=2)

    self.add_legend([str(node[2]) for node in self.nodes])
    plt.savefig(f"{path}", dpi=100, bbox_inches="tight", pad_inches=0)
    plt.close()

generate_random_topology(probability)

Generates a random topology using Erdos-Renyi model with given probability.

Parameters:

Name Type Description Default
probability float

Probability of edge creation between any two nodes (0-1)

required

Returns:

Name Type Description
None

Updates self.topology with the generated random topology

Source code in nebula/addons/topologymanager.py
253
254
255
256
257
258
259
260
261
262
263
264
265
def generate_random_topology(self, probability):
    """
    Generates a random topology using Erdos-Renyi model with given probability.

    Args:
        probability (float): Probability of edge creation between any two nodes (0-1)

    Returns:
        None: Updates self.topology with the generated random topology
    """
    random_graph = nx.erdos_renyi_graph(self.n_nodes, probability)
    self.topology = nx.to_numpy_array(random_graph, dtype=np.float32)
    np.fill_diagonal(self.topology, 0)  # No self-loops

generate_ring_topology(increase_convergence=False)

Generates a ring topology for the network.

In a ring topology, each node is connected to two other nodes in a circular fashion, forming a closed loop. This method uses a private method to generate the topology, with an optional parameter to control whether the convergence speed of the network should be increased.

Parameters:

Name Type Description Default
increase_convergence bool

Optional flag to increase the convergence speed in the topology. Defaults to False.

False

Returns:

Name Type Description
None

The method modifies the internal self.topology matrix to reflect the generated ring topology.

Source code in nebula/addons/topologymanager.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def generate_ring_topology(self, increase_convergence=False):
    """
    Generates a ring topology for the network.

    In a ring topology, each node is connected to two other nodes in a circular fashion, forming a closed loop.
    This method uses a private method to generate the topology, with an optional parameter to control whether
    the convergence speed of the network should be increased.

    Args:
        increase_convergence (bool): Optional flag to increase the convergence speed in the topology.
                                      Defaults to False.

    Returns:
        None: The method modifies the internal `self.topology` matrix to reflect the generated ring topology.
    """
    self.__ring_topology(increase_convergence=increase_convergence)

generate_server_topology()

Generates a server topology where the first node (usually the server) is connected to all other nodes.

This method initializes a topology matrix where the first node (typically the server) is connected to every other node in the network. The first row and the first column of the matrix are set to 1, representing connections to and from the server. The diagonal is set to 0 to indicate that no node is connected to itself.

Returns:

Name Type Description
None

The method modifies the internal self.topology matrix.

Source code in nebula/addons/topologymanager.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def generate_server_topology(self):
    """
    Generates a server topology where the first node (usually the server) is connected to all other nodes.

    This method initializes a topology matrix where the first node (typically the server) is connected to
    every other node in the network. The first row and the first column of the matrix are set to 1, representing
    connections to and from the server. The diagonal is set to 0 to indicate that no node is connected to itself.

    Returns:
        None: The method modifies the internal `self.topology` matrix.
    """
    self.topology = np.zeros((self.n_nodes, self.n_nodes), dtype=np.float32)
    self.topology[0, :] = 1
    self.topology[:, 0] = 1
    np.fill_diagonal(self.topology, 0)

generate_topology()

Generates the network topology based on the configured settings.

This method generates the network topology for the given scenario. It checks whether the topology should be fully connected, symmetric, or asymmetric and then generates the network accordingly.

  • If the topology is fully connected, all nodes will be directly connected to each other.
  • If the topology is symmetric, neighbors will be chosen symmetrically between nodes.
  • If the topology is asymmetric, neighbors will be picked randomly without symmetry.

Returns:

Name Type Description
None

The method modifies the internal topology of the network.

Source code in nebula/addons/topologymanager.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def generate_topology(self):
    """
    Generates the network topology based on the configured settings.

    This method generates the network topology for the given scenario. It checks whether the topology
    should be fully connected, symmetric, or asymmetric and then generates the network accordingly.

    - If the topology is fully connected, all nodes will be directly connected to each other.
    - If the topology is symmetric, neighbors will be chosen symmetrically between nodes.
    - If the topology is asymmetric, neighbors will be picked randomly without symmetry.

    Returns:
        None: The method modifies the internal topology of the network.
    """
    if self.b_fully_connected:
        self.__fully_connected()
        return

    if self.topology is not None and len(self.topology) > 0:
        # Topology was already provided
        return

    if self.b_symmetric:
        self.__randomly_pick_neighbors_symmetric()
    else:
        self.__randomly_pick_neighbors_asymmetric()

get_coordinates(random_geo=True) staticmethod

Generates random geographical coordinates within predefined bounds for either Spain or Switzerland.

The method returns a random geographical coordinate (latitude, longitude). The bounds for random coordinates are defined for two regions: Spain and Switzerland. The region is chosen randomly, and then the latitude and longitude are selected within the corresponding bounds.

Parameters:

Name Type Description Default
random_geo bool

If set to True, the method generates random coordinates within the predefined bounds for Spain or Switzerland. If set to False, this method could be modified to return fixed coordinates.

True

Returns:

Name Type Description
tuple

A tuple containing the latitude and longitude of the generated point.

Source code in nebula/addons/topologymanager.py
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
@staticmethod
def get_coordinates(random_geo=True):
    """
    Generates random geographical coordinates within predefined bounds for either Spain or Switzerland.

    The method returns a random geographical coordinate (latitude, longitude). The bounds for random coordinates are
    defined for two regions: Spain and Switzerland. The region is chosen randomly, and then the latitude and longitude
    are selected within the corresponding bounds.

    Parameters:
        random_geo (bool): If set to True, the method generates random coordinates within the predefined bounds
                            for Spain or Switzerland. If set to False, this method could be modified to return fixed
                            coordinates.

    Returns:
        tuple: A tuple containing the latitude and longitude of the generated point.
    """
    if random_geo:
        #  España min_lat, max_lat, min_lon, max_lon                  Suiza min_lat, max_lat, min_lon, max_lon
        bounds = (36.0, 43.0, -9.0, 3.3) if random.randint(0, 1) == 0 else (45.8, 47.8, 5.9, 10.5)  # noqa: S311

        min_latitude, max_latitude, min_longitude, max_longitude = bounds
        latitude = random.uniform(min_latitude, max_latitude)  # noqa: S311
        longitude = random.uniform(min_longitude, max_longitude)  # noqa: S311

        return latitude, longitude

get_neighbors_string(node_idx)

Retrieves the neighbors of a given node as a string representation.

This method checks the topology attribute to find the neighbors of the node at the specified index (node_idx). It then returns a string that lists the coordinates of each neighbor.

Parameters:

Name Type Description Default
node_idx int

The index of the node for which neighbors are to be retrieved.

required

Returns:

Name Type Description
str

A space-separated string of neighbors' coordinates in the format "latitude:longitude".

Source code in nebula/addons/topologymanager.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
def get_neighbors_string(self, node_idx):
    """
    Retrieves the neighbors of a given node as a string representation.

    This method checks the `topology` attribute to find the neighbors of the node at the specified index (`node_idx`). It then returns a string that lists the coordinates of each neighbor.

    Parameters:
        node_idx (int): The index of the node for which neighbors are to be retrieved.

    Returns:
        str: A space-separated string of neighbors' coordinates in the format "latitude:longitude".
    """
    # logging.info(f"Topology: {self.topology}")
    # logging.info(f"Nodes: {self.nodes}")
    neighbors_data = []
    for i, node in enumerate(self.topology[node_idx]):
        if node == 1:
            neighbors_data.append(self.nodes[i])

    neighbors_data_strings = [f"{i[0]}:{i[1]}" for i in neighbors_data]
    neighbors_data_string = " ".join(neighbors_data_strings)
    logging.info(f"Neighbors of node participant_{node_idx}: {neighbors_data_string}")
    return neighbors_data_string

get_node_color(role)

Returns the color associated with a given role.

The method maps roles to specific colors for visualization or representation purposes.

Parameters:

Name Type Description Default
role Role

The role for which the color is to be determined.

required

Returns:

Name Type Description
str

The color associated with the given role. Defaults to "red" if the role is not recognized.

Source code in nebula/addons/topologymanager.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def get_node_color(self, role):
    """
    Returns the color associated with a given role.

    The method maps roles to specific colors for visualization or representation purposes.

    Args:
        role (Role): The role for which the color is to be determined.

    Returns:
        str: The color associated with the given role. Defaults to "red" if the role is not recognized.
    """
    role_colors = {
        Role.AGGREGATOR: "orange",
        Role.SERVER: "green",
        Role.TRAINER: "#6182bd",
        Role.PROXY: "purple",
    }
    return role_colors.get(role, "red")

update_nodes(config_participants)

Updates the nodes of the topology based on the provided configuration.

This method assigns a new set of nodes to the nodes attribute, typically based on the configuration of the participants.

Parameters:

Name Type Description Default
config_participants array - like

A new set of nodes, usually derived from the participants' configuration, to be assigned to the topology.

required

Returns:

Type Description

None

Source code in nebula/addons/topologymanager.py
309
310
311
312
313
314
315
316
317
318
319
320
321
def update_nodes(self, config_participants):
    """
    Updates the nodes of the topology based on the provided configuration.

    This method assigns a new set of nodes to the `nodes` attribute, typically based on the configuration of the participants.

    Parameters:
        config_participants (array-like): A new set of nodes, usually derived from the participants' configuration, to be assigned to the topology.

    Returns:
        None
    """
    self.nodes = config_participants