topolib 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- topolib/__init__.py +4 -0
- topolib/analysis/__init__.py +4 -0
- topolib/analysis/metrics.py +80 -0
- topolib/analysis/traffic_matrix.py +344 -0
- topolib/assets/AMRES.json +1265 -0
- topolib/assets/Abilene.json +285 -0
- topolib/assets/Bell_canada.json +925 -0
- topolib/assets/Brazil.json +699 -0
- topolib/assets/CESNET.json +657 -0
- topolib/assets/CORONET.json +1957 -0
- topolib/assets/China.json +1135 -0
- topolib/assets/DT-14.json +470 -0
- topolib/assets/DT-17.json +525 -0
- topolib/assets/DT-50.json +1515 -0
- topolib/assets/ES-30.json +967 -0
- topolib/assets/EURO-16.json +455 -0
- topolib/assets/FR-43.json +1277 -0
- topolib/assets/FUNET.json +317 -0
- topolib/assets/GCN-BG.json +855 -0
- topolib/assets/GRNET.json +1717 -0
- topolib/assets/HyperOne.json +255 -0
- topolib/assets/IT-21.json +649 -0
- topolib/assets/India.json +517 -0
- topolib/assets/JPN-12.json +331 -0
- topolib/assets/KOREN.json +287 -0
- topolib/assets/NORDUNet.json +783 -0
- topolib/assets/NSFNet.json +399 -0
- topolib/assets/PANEURO.json +757 -0
- topolib/assets/PAVLOV.json +465 -0
- topolib/assets/PLN-12.json +343 -0
- topolib/assets/SANReN.json +161 -0
- topolib/assets/SERBIA-MONTENEGRO.json +139 -0
- topolib/assets/Telefonica-21.json +637 -0
- topolib/assets/Turk_Telekom.json +551 -0
- topolib/assets/UKNet.json +685 -0
- topolib/assets/Vega_Telecom.json +819 -0
- topolib/elements/__init__.py +5 -0
- topolib/elements/link.py +121 -0
- topolib/elements/node.py +230 -0
- topolib/topology/__init__.py +4 -0
- topolib/topology/path.py +84 -0
- topolib/topology/topology.py +469 -0
- topolib/visualization/__init__.py +1 -0
- topolib/visualization/_qt_screenshot.py +103 -0
- topolib/visualization/_qt_window.py +78 -0
- topolib/visualization/_templates.py +101 -0
- topolib/visualization/mapview.py +316 -0
- topolib-0.8.0.dist-info/METADATA +148 -0
- topolib-0.8.0.dist-info/RECORD +51 -0
- topolib-0.8.0.dist-info/WHEEL +4 -0
- topolib-0.8.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Topology class for optical network topologies.
|
|
3
|
+
|
|
4
|
+
This module defines the Topology class, representing a network topology with nodes and links,
|
|
5
|
+
and providing an adjacency matrix using numpy.
|
|
6
|
+
|
|
7
|
+
This file uses NetworkX (BSD 3-Clause License):
|
|
8
|
+
https://github.com/networkx/networkx/blob/main/LICENSE.txt
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import List, Optional, Any
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Standard library imports
|
|
16
|
+
import json
|
|
17
|
+
import csv
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# Third-party imports
|
|
21
|
+
import numpy as np
|
|
22
|
+
import networkx as nx
|
|
23
|
+
import jsonschema
|
|
24
|
+
|
|
25
|
+
# Local imports
|
|
26
|
+
from topolib.elements.node import Node
|
|
27
|
+
from topolib.elements.link import Link
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Topology:
|
|
31
|
+
"""
|
|
32
|
+
Represents a network topology with nodes and links.
|
|
33
|
+
|
|
34
|
+
:param nodes: Initial list of nodes (optional).
|
|
35
|
+
:type nodes: list[topolib.elements.node.Node] or None
|
|
36
|
+
:param links: Initial list of links (optional).
|
|
37
|
+
:type links: list[topolib.elements.link.Link] or None
|
|
38
|
+
|
|
39
|
+
:ivar nodes: List of nodes in the topology.
|
|
40
|
+
:vartype nodes: list[Node]
|
|
41
|
+
:ivar links: List of links in the topology.
|
|
42
|
+
:vartype links: list[Link]
|
|
43
|
+
|
|
44
|
+
**Examples**
|
|
45
|
+
>>> from topolib.elements.node import Node
|
|
46
|
+
>>> from topolib.elements.link import Link
|
|
47
|
+
>>> from topolib.topology import Topology
|
|
48
|
+
>>> n1 = Node(1, "A", 0.0, 0.0)
|
|
49
|
+
>>> n2 = Node(2, "B", 1.0, 1.0)
|
|
50
|
+
>>> l1 = Link(1, n1, n2, 10.0)
|
|
51
|
+
>>> topo = Topology(nodes=[n1, n2], links=[l1])
|
|
52
|
+
>>> topo.adjacency_matrix()
|
|
53
|
+
array([[0, 1],
|
|
54
|
+
[1, 0]])
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
nodes: Optional[List[Node]] = None,
|
|
60
|
+
links: Optional[List[Link]] = None,
|
|
61
|
+
name: Optional[str] = None,
|
|
62
|
+
):
|
|
63
|
+
"""
|
|
64
|
+
Initialize a Topology object.
|
|
65
|
+
|
|
66
|
+
:param nodes: Initial list of nodes (optional).
|
|
67
|
+
:type nodes: list[topolib.elements.node.Node] or None
|
|
68
|
+
:param links: Initial list of links (optional).
|
|
69
|
+
:type links: list[topolib.elements.link.Link] or None
|
|
70
|
+
:param name: Name of the topology (optional).
|
|
71
|
+
:type name: str or None
|
|
72
|
+
"""
|
|
73
|
+
self.nodes = nodes if nodes is not None else []
|
|
74
|
+
self.links = links if links is not None else []
|
|
75
|
+
self.name = name
|
|
76
|
+
# Internal NetworkX graph for algorithms and visualization
|
|
77
|
+
self._graph: nx.DiGraph[Any] = nx.DiGraph()
|
|
78
|
+
for node in self.nodes:
|
|
79
|
+
self._graph.add_node(node.id, node=node)
|
|
80
|
+
for link in self.links:
|
|
81
|
+
self._graph.add_edge(link.source.id, link.target.id, link=link)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_json(cls, json_path: str) -> "Topology":
|
|
85
|
+
"""
|
|
86
|
+
Create a Topology object from a JSON file.
|
|
87
|
+
|
|
88
|
+
:param json_path: Path to the JSON file containing the topology.
|
|
89
|
+
:type json_path: str
|
|
90
|
+
:return: Topology instance loaded from the file.
|
|
91
|
+
:rtype: Topology
|
|
92
|
+
"""
|
|
93
|
+
with open(json_path, "r", encoding="utf-8") as f:
|
|
94
|
+
data = json.load(f)
|
|
95
|
+
|
|
96
|
+
# Validation schema for the assets JSON format
|
|
97
|
+
topology_schema: dict[str, Any] = {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {
|
|
100
|
+
"name": {"type": "string"},
|
|
101
|
+
"nodes": {
|
|
102
|
+
"type": "array",
|
|
103
|
+
"items": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"id": {"type": "integer"},
|
|
107
|
+
"name": {"type": "string"},
|
|
108
|
+
"weight": {"type": "number"},
|
|
109
|
+
"latitude": {"type": "number"},
|
|
110
|
+
"longitude": {"type": "number"},
|
|
111
|
+
"pop": {"type": "integer"},
|
|
112
|
+
"DC": {"type": "integer"},
|
|
113
|
+
"IXP": {"type": "integer"},
|
|
114
|
+
},
|
|
115
|
+
"required": ["id", "name", "latitude", "longitude"],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
"links": {
|
|
119
|
+
"type": "array",
|
|
120
|
+
"items": {
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"id": {"type": "integer"},
|
|
124
|
+
"src": {"type": "integer"},
|
|
125
|
+
"dst": {"type": "integer"},
|
|
126
|
+
"length": {"type": "number"},
|
|
127
|
+
},
|
|
128
|
+
"required": ["id", "src", "dst", "length"],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
"required": ["nodes", "links"],
|
|
133
|
+
}
|
|
134
|
+
try:
|
|
135
|
+
jsonschema.validate(instance=data, schema=topology_schema)
|
|
136
|
+
except jsonschema.ValidationError as e:
|
|
137
|
+
raise ValueError(f"Invalid topology JSON format: {e.message}")
|
|
138
|
+
nodes = [
|
|
139
|
+
Node(
|
|
140
|
+
n["id"],
|
|
141
|
+
n["name"],
|
|
142
|
+
n["latitude"],
|
|
143
|
+
n["longitude"],
|
|
144
|
+
n.get("weight", 0),
|
|
145
|
+
n.get("pop", 0),
|
|
146
|
+
n.get("dc", n.get("DC", 0)),
|
|
147
|
+
n.get("ixp", n.get("IXP", 0)),
|
|
148
|
+
)
|
|
149
|
+
for n in data["nodes"]
|
|
150
|
+
]
|
|
151
|
+
# Crear un dict para mapear id a Node
|
|
152
|
+
node_dict = {n.id: n for n in nodes}
|
|
153
|
+
links = [
|
|
154
|
+
Link(l["id"], node_dict[l["src"]],
|
|
155
|
+
node_dict[l["dst"]], l["length"])
|
|
156
|
+
for l in data["links"]
|
|
157
|
+
]
|
|
158
|
+
name = data.get("name", None)
|
|
159
|
+
return cls(nodes=nodes, links=links, name=name)
|
|
160
|
+
|
|
161
|
+
def add_node(self, node: Node) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Add a node to the topology.
|
|
164
|
+
|
|
165
|
+
:param node: Node to add.
|
|
166
|
+
:type node: Node
|
|
167
|
+
"""
|
|
168
|
+
self.nodes.append(node)
|
|
169
|
+
self._graph.add_node(node.id, node=node)
|
|
170
|
+
|
|
171
|
+
def add_link(self, link: Link) -> None:
|
|
172
|
+
"""
|
|
173
|
+
Add a link to the topology.
|
|
174
|
+
|
|
175
|
+
:param link: Link to add.
|
|
176
|
+
:type link: Link
|
|
177
|
+
"""
|
|
178
|
+
self.links.append(link)
|
|
179
|
+
self._graph.add_edge(link.source.id, link.target.id, link=link)
|
|
180
|
+
|
|
181
|
+
def remove_node(self, node_id: int) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Remove a node and all its links by node id.
|
|
184
|
+
|
|
185
|
+
:param node_id: ID of the node to remove.
|
|
186
|
+
:type node_id: int
|
|
187
|
+
"""
|
|
188
|
+
self.nodes = [n for n in self.nodes if n.id != node_id]
|
|
189
|
+
self.links = [
|
|
190
|
+
l for l in self.links if l.source.id != node_id and l.target.id != node_id
|
|
191
|
+
]
|
|
192
|
+
self._graph.remove_node(node_id)
|
|
193
|
+
|
|
194
|
+
def remove_link(self, link_id: int) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Remove a link by its id.
|
|
197
|
+
|
|
198
|
+
:param link_id: ID of the link to remove.
|
|
199
|
+
:type link_id: int
|
|
200
|
+
"""
|
|
201
|
+
# Find the link and remove from graph
|
|
202
|
+
link = next((l for l in self.links if l.id == link_id), None)
|
|
203
|
+
if link:
|
|
204
|
+
self._graph.remove_edge(link.source.id, link.target.id)
|
|
205
|
+
self.links = [l for l in self.links if l.id != link_id]
|
|
206
|
+
|
|
207
|
+
def adjacency_matrix(self) -> NDArray[np.int_]:
|
|
208
|
+
"""
|
|
209
|
+
Return the adjacency matrix of the topology as a numpy array.
|
|
210
|
+
|
|
211
|
+
:return: Adjacency matrix (1 if connected, 0 otherwise).
|
|
212
|
+
:rtype: numpy.ndarray
|
|
213
|
+
|
|
214
|
+
**Example**
|
|
215
|
+
>>> topo.adjacency_matrix()
|
|
216
|
+
array([[0, 1],
|
|
217
|
+
[1, 0]])
|
|
218
|
+
"""
|
|
219
|
+
# Usa NetworkX para obtener la matriz de adyacencia
|
|
220
|
+
if not self.nodes:
|
|
221
|
+
return np.zeros((0, 0), dtype=int)
|
|
222
|
+
node_ids = [n.id for n in self.nodes]
|
|
223
|
+
mat = nx.to_numpy_array(self._graph, nodelist=node_ids, dtype=int)
|
|
224
|
+
return mat
|
|
225
|
+
|
|
226
|
+
def export_to_json(self, file_path: str) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Export the current topology to the JSON format used in the assets folder.
|
|
229
|
+
|
|
230
|
+
:param file_path: Path where the JSON file will be saved.
|
|
231
|
+
:type file_path: str
|
|
232
|
+
|
|
233
|
+
Example usage::
|
|
234
|
+
|
|
235
|
+
topo.export_to_json("/path/output.json")
|
|
236
|
+
|
|
237
|
+
Example output format::
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
"name": "Abilene",
|
|
241
|
+
"nodes": [
|
|
242
|
+
{
|
|
243
|
+
"id": 0,
|
|
244
|
+
"name": "Seattle",
|
|
245
|
+
...
|
|
246
|
+
}
|
|
247
|
+
],
|
|
248
|
+
"links": [
|
|
249
|
+
{
|
|
250
|
+
"id": 0,
|
|
251
|
+
"src": 0,
|
|
252
|
+
"dst": 1,
|
|
253
|
+
"length": 1482.26
|
|
254
|
+
}
|
|
255
|
+
]
|
|
256
|
+
}
|
|
257
|
+
"""
|
|
258
|
+
nodes_list: list[dict[str, Any]] = []
|
|
259
|
+
for n in self.nodes:
|
|
260
|
+
node_dict: dict[str, Any] = {
|
|
261
|
+
"id": n.id,
|
|
262
|
+
"name": getattr(n, "name", None),
|
|
263
|
+
"weight": getattr(n, "weight", 0),
|
|
264
|
+
"latitude": getattr(n, "latitude", None),
|
|
265
|
+
"longitude": getattr(n, "longitude", None),
|
|
266
|
+
"pop": getattr(n, "pop", 0),
|
|
267
|
+
"DC": getattr(n, "dc", getattr(n, "DC", 0)),
|
|
268
|
+
"IXP": getattr(n, "ixp", getattr(n, "IXP", 0)),
|
|
269
|
+
}
|
|
270
|
+
nodes_list.append(node_dict)
|
|
271
|
+
links_list: list[dict[str, Any]] = []
|
|
272
|
+
for l in self.links:
|
|
273
|
+
link_dict: dict[str, Any] = {
|
|
274
|
+
"id": l.id,
|
|
275
|
+
"src": l.source.id,
|
|
276
|
+
"dst": l.target.id,
|
|
277
|
+
"length": getattr(l, "length", None),
|
|
278
|
+
}
|
|
279
|
+
links_list.append(link_dict)
|
|
280
|
+
data: dict[str, Any] = {
|
|
281
|
+
"name": self.name if self.name else "Topology",
|
|
282
|
+
"nodes": nodes_list,
|
|
283
|
+
"links": links_list,
|
|
284
|
+
}
|
|
285
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
286
|
+
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
287
|
+
|
|
288
|
+
def export_to_csv(self, filename_prefix: str) -> None:
|
|
289
|
+
"""
|
|
290
|
+
Export the topology to two CSV files: one for nodes and one for links.
|
|
291
|
+
The files will be named as <filename_prefix>_nodes.csv and <filename_prefix>_links.csv.
|
|
292
|
+
|
|
293
|
+
:param filename_prefix: Prefix for the output files (e.g., 'topology1').
|
|
294
|
+
:type filename_prefix: str
|
|
295
|
+
|
|
296
|
+
Example:
|
|
297
|
+
>>> topo.export_to_csv("mytopo")
|
|
298
|
+
# Generates 'mytopo_nodes.csv' and 'mytopo_links.csv'
|
|
299
|
+
"""
|
|
300
|
+
# Export nodes
|
|
301
|
+
nodes_path = f"{filename_prefix}_nodes.csv"
|
|
302
|
+
with open(nodes_path, "w", newline="", encoding="utf-8") as f:
|
|
303
|
+
writer = csv.writer(f)
|
|
304
|
+
# Header
|
|
305
|
+
writer.writerow(
|
|
306
|
+
["id", "name", "weight", "latitude",
|
|
307
|
+
"longitude", "pop", "DC", "IXP"]
|
|
308
|
+
)
|
|
309
|
+
for n in self.nodes:
|
|
310
|
+
writer.writerow(
|
|
311
|
+
[
|
|
312
|
+
n.id,
|
|
313
|
+
getattr(n, "name", None),
|
|
314
|
+
getattr(n, "weight", 0),
|
|
315
|
+
getattr(n, "latitude", None),
|
|
316
|
+
getattr(n, "longitude", None),
|
|
317
|
+
getattr(n, "pop", 0),
|
|
318
|
+
getattr(n, "dc", getattr(n, "DC", 0)),
|
|
319
|
+
getattr(n, "ixp", getattr(n, "IXP", 0)),
|
|
320
|
+
]
|
|
321
|
+
)
|
|
322
|
+
# Export links
|
|
323
|
+
links_path = f"{filename_prefix}_links.csv"
|
|
324
|
+
with open(links_path, "w", newline="", encoding="utf-8") as f:
|
|
325
|
+
writer = csv.writer(f)
|
|
326
|
+
writer.writerow(["id", "src", "dst", "length"])
|
|
327
|
+
for l in self.links:
|
|
328
|
+
writer.writerow(
|
|
329
|
+
[
|
|
330
|
+
l.id,
|
|
331
|
+
l.source.id,
|
|
332
|
+
l.target.id,
|
|
333
|
+
getattr(l, "length", None),
|
|
334
|
+
]
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def export_to_flexnetsim_json(self, file_path: str, slots: int) -> None:
|
|
338
|
+
"""
|
|
339
|
+
Export the current topology to a JSON file compatible with Flex Net Sim.
|
|
340
|
+
|
|
341
|
+
:param file_path: Path where the JSON file will be saved.
|
|
342
|
+
:type file_path: str
|
|
343
|
+
:param slots: Number of slots for each link.
|
|
344
|
+
:type slots: int
|
|
345
|
+
|
|
346
|
+
The generated format includes the following fields:
|
|
347
|
+
- alias: short name of the topology (uses self.name if available)
|
|
348
|
+
- name: full name of the topology (uses self.name if available)
|
|
349
|
+
- nodes: list of nodes with 'id' field
|
|
350
|
+
- links: list of links with id, src, dst, length, slots
|
|
351
|
+
"""
|
|
352
|
+
alias = self.name if self.name else "Topology"
|
|
353
|
+
name = self.name if self.name else "Topology"
|
|
354
|
+
nodes_list = [{"id": n.id} for n in self.nodes]
|
|
355
|
+
links_list = []
|
|
356
|
+
for l in self.links:
|
|
357
|
+
link_dict = {
|
|
358
|
+
"id": l.id,
|
|
359
|
+
"src": l.source.id,
|
|
360
|
+
"dst": l.target.id,
|
|
361
|
+
"length": getattr(l, "length", None),
|
|
362
|
+
"slots": slots,
|
|
363
|
+
}
|
|
364
|
+
links_list.append(link_dict)
|
|
365
|
+
data = {
|
|
366
|
+
"alias": alias,
|
|
367
|
+
"name": name,
|
|
368
|
+
"nodes": nodes_list,
|
|
369
|
+
"links": links_list,
|
|
370
|
+
}
|
|
371
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
372
|
+
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
373
|
+
|
|
374
|
+
def export_to_flexnetsim_ksp_json(self, file_path: str, k: int = 3) -> None:
|
|
375
|
+
"""
|
|
376
|
+
Export the k-shortest paths between all node pairs to a JSON file compatible with Flex Net Sim.
|
|
377
|
+
|
|
378
|
+
:param file_path: Path where the JSON file will be saved.
|
|
379
|
+
:type file_path: str
|
|
380
|
+
:param k: Number of shortest paths to compute for each node pair (default: 3).
|
|
381
|
+
:type k: int
|
|
382
|
+
|
|
383
|
+
Example output format::
|
|
384
|
+
|
|
385
|
+
{
|
|
386
|
+
"name": self.name,
|
|
387
|
+
"alias": self.name,
|
|
388
|
+
"routes": [
|
|
389
|
+
{"src": <id>, "dst": <id>, "paths": [[id, ...], ...]},
|
|
390
|
+
...
|
|
391
|
+
]
|
|
392
|
+
}
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
# Build a weighted graph using link length as edge weight
|
|
396
|
+
G = nx.DiGraph()
|
|
397
|
+
for l in self.links:
|
|
398
|
+
G.add_edge(l.source.id, l.target.id,
|
|
399
|
+
weight=getattr(l, "length", 1))
|
|
400
|
+
routes = []
|
|
401
|
+
node_ids = [n.id for n in self.nodes]
|
|
402
|
+
for src in node_ids:
|
|
403
|
+
for dst in node_ids:
|
|
404
|
+
if src == dst:
|
|
405
|
+
continue
|
|
406
|
+
try:
|
|
407
|
+
# Compute k shortest paths using link length as weight
|
|
408
|
+
paths_gen = nx.shortest_simple_paths(
|
|
409
|
+
G, src, dst, weight="weight")
|
|
410
|
+
paths = []
|
|
411
|
+
for i, path in enumerate(paths_gen):
|
|
412
|
+
if i >= k:
|
|
413
|
+
break
|
|
414
|
+
paths.append(path)
|
|
415
|
+
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
|
416
|
+
paths = []
|
|
417
|
+
routes.append({"src": src, "dst": dst, "paths": paths})
|
|
418
|
+
data = {
|
|
419
|
+
"name": self.name if self.name else "Topology",
|
|
420
|
+
"alias": self.name if self.name else "Topology",
|
|
421
|
+
"routes": routes,
|
|
422
|
+
}
|
|
423
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
424
|
+
json.dump(data, f, indent=4, ensure_ascii=False)
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def list_available_topologies(cls) -> list[dict]:
|
|
428
|
+
"""
|
|
429
|
+
List available topologies in the assets folder.
|
|
430
|
+
|
|
431
|
+
Returns a list of dictionaries with keys:
|
|
432
|
+
- 'name': topology name (filename without extension)
|
|
433
|
+
- 'nodes': number of nodes
|
|
434
|
+
- 'links': number of links
|
|
435
|
+
|
|
436
|
+
:return: List of available topologies with metadata.
|
|
437
|
+
:rtype: list[dict]
|
|
438
|
+
"""
|
|
439
|
+
asset_dir = Path(__file__).parent.parent / "assets"
|
|
440
|
+
result = []
|
|
441
|
+
for json_path in asset_dir.glob("*.json"):
|
|
442
|
+
try:
|
|
443
|
+
topo = cls.from_json(str(json_path))
|
|
444
|
+
result.append({
|
|
445
|
+
"name": json_path.stem,
|
|
446
|
+
"nodes": len(topo.nodes),
|
|
447
|
+
"links": len(topo.links),
|
|
448
|
+
})
|
|
449
|
+
except Exception:
|
|
450
|
+
continue
|
|
451
|
+
return result
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def load_default_topology(name: str) -> "Topology":
|
|
455
|
+
"""
|
|
456
|
+
Load a default topology from the assets folder by name (filename without extension).
|
|
457
|
+
|
|
458
|
+
:param name: Name of the topology asset (without .json extension)
|
|
459
|
+
:type name: str
|
|
460
|
+
:return: Topology instance loaded from the asset file
|
|
461
|
+
:rtype: Topology
|
|
462
|
+
:raises FileNotFoundError: If the asset file does not exist
|
|
463
|
+
"""
|
|
464
|
+
asset_dir = Path(__file__).parent.parent / "assets"
|
|
465
|
+
asset_path = asset_dir / f"{name}.json"
|
|
466
|
+
if not asset_path.exists():
|
|
467
|
+
raise FileNotFoundError(
|
|
468
|
+
f"Topology asset '{name}.json' not found in assets directory.")
|
|
469
|
+
return Topology.from_json(str(asset_path))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .mapview import MapView
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qt screenshot script for capturing maps as PNG images.
|
|
3
|
+
|
|
4
|
+
This module provides a standalone script that can be executed in a subprocess
|
|
5
|
+
to render HTML content and capture it as a PNG screenshot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Suppress Qt warnings
|
|
13
|
+
os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.webenginecontext.debug=false;qt.qpa.windows=false'
|
|
14
|
+
os.environ['QTWEBENGINE_CHROMIUM_FLAGS'] = '--disable-logging --log-level=3'
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from PyQt6.QtWidgets import QApplication
|
|
18
|
+
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
19
|
+
from PyQt6.QtCore import QUrl, QTimer, QEventLoop, Qt
|
|
20
|
+
from PyQt6.QtWebEngineCore import QWebEngineSettings
|
|
21
|
+
from PyQt6.QtGui import QPixmap
|
|
22
|
+
except ImportError:
|
|
23
|
+
print("ERROR: PyQt6 not installed. Install with: pip install PyQt6 PyQt6-WebEngine")
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def capture_screenshot(html_path: str, output_path: str, width: int = 1920, height: int = 1080, wait_time: float = 1.0) -> None:
|
|
28
|
+
"""Capture a screenshot of an HTML file.
|
|
29
|
+
|
|
30
|
+
:param html_path: Absolute path to the HTML file to render.
|
|
31
|
+
:type html_path: str
|
|
32
|
+
:param output_path: Path where the PNG screenshot will be saved.
|
|
33
|
+
:type output_path: str
|
|
34
|
+
:param width: Width of the screenshot in pixels.
|
|
35
|
+
:type width: int
|
|
36
|
+
:param height: Height of the screenshot in pixels.
|
|
37
|
+
:type height: int
|
|
38
|
+
:param wait_time: Time in seconds to wait for rendering before capture.
|
|
39
|
+
:type wait_time: float
|
|
40
|
+
"""
|
|
41
|
+
app = QApplication(sys.argv)
|
|
42
|
+
|
|
43
|
+
# Create web view offscreen (no window displayed)
|
|
44
|
+
web_view = QWebEngineView()
|
|
45
|
+
web_view.setGeometry(0, 0, width, height)
|
|
46
|
+
# Render offscreen without showing window
|
|
47
|
+
web_view.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
|
48
|
+
web_view.show() # Trigger rendering without displaying
|
|
49
|
+
|
|
50
|
+
# Configure settings
|
|
51
|
+
settings = web_view.settings()
|
|
52
|
+
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
|
|
53
|
+
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
|
|
54
|
+
|
|
55
|
+
def on_load_finished(ok):
|
|
56
|
+
if ok:
|
|
57
|
+
# Wait for Leaflet/JavaScript to fully render the map
|
|
58
|
+
QTimer.singleShot(int(wait_time * 1000), capture)
|
|
59
|
+
|
|
60
|
+
def capture():
|
|
61
|
+
# Grab the rendered content
|
|
62
|
+
pixmap = web_view.grab()
|
|
63
|
+
pixmap.save(output_path, 'PNG')
|
|
64
|
+
app.quit()
|
|
65
|
+
|
|
66
|
+
web_view.loadFinished.connect(on_load_finished)
|
|
67
|
+
|
|
68
|
+
# Load the HTML file
|
|
69
|
+
web_view.load(QUrl.fromLocalFile(html_path))
|
|
70
|
+
|
|
71
|
+
# Suppress output during execution
|
|
72
|
+
null_fd = os.open(os.devnull, os.O_RDWR)
|
|
73
|
+
save_stderr = os.dup(2)
|
|
74
|
+
os.dup2(null_fd, 2)
|
|
75
|
+
|
|
76
|
+
app.exec()
|
|
77
|
+
|
|
78
|
+
# Restore stderr
|
|
79
|
+
os.dup2(save_stderr, 2)
|
|
80
|
+
os.close(null_fd)
|
|
81
|
+
os.close(save_stderr)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
if len(sys.argv) < 3:
|
|
86
|
+
print("Usage: python _qt_screenshot.py <html_path> <output_path> [width] [height] [wait_time]")
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
html_path = sys.argv[1]
|
|
90
|
+
output_path = sys.argv[2]
|
|
91
|
+
width = int(sys.argv[3]) if len(sys.argv) > 3 else 1920
|
|
92
|
+
height = int(sys.argv[4]) if len(sys.argv) > 4 else 1080
|
|
93
|
+
wait_time = float(sys.argv[5]) if len(sys.argv) > 5 else 1.0
|
|
94
|
+
|
|
95
|
+
if not Path(html_path).exists():
|
|
96
|
+
print(f"ERROR: HTML file not found: {html_path}")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
capture_screenshot(html_path, output_path, width, height, wait_time)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
print(f"ERROR: Failed to capture screenshot: {e}")
|
|
103
|
+
sys.exit(1)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qt window script for displaying maps in isolated subprocess.
|
|
3
|
+
|
|
4
|
+
This module provides a standalone script that can be executed in a subprocess
|
|
5
|
+
to display HTML content in a PyQt6 window without interfering with the main process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# Suppress Qt warnings
|
|
12
|
+
os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.webenginecontext.debug=false;qt.qpa.windows=false'
|
|
13
|
+
os.environ['QTWEBENGINE_CHROMIUM_FLAGS'] = '--disable-logging --log-level=3'
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from PyQt6.QtWidgets import QApplication, QMainWindow
|
|
17
|
+
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
18
|
+
from PyQt6.QtCore import QUrl
|
|
19
|
+
from PyQt6.QtWebEngineCore import QWebEngineSettings
|
|
20
|
+
except ImportError:
|
|
21
|
+
print("ERROR: PyQt6 not installed. Install with: pip install PyQt6 PyQt6-WebEngine")
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SuppressOutput:
|
|
26
|
+
"""Context manager to suppress stdout/stderr during Qt event loop."""
|
|
27
|
+
|
|
28
|
+
def __enter__(self):
|
|
29
|
+
self.null_fd = os.open(os.devnull, os.O_RDWR)
|
|
30
|
+
self.save_stdout = os.dup(1)
|
|
31
|
+
self.save_stderr = os.dup(2)
|
|
32
|
+
os.dup2(self.null_fd, 1)
|
|
33
|
+
os.dup2(self.null_fd, 2)
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
def __exit__(self, *args):
|
|
37
|
+
os.dup2(self.save_stdout, 1)
|
|
38
|
+
os.dup2(self.save_stderr, 2)
|
|
39
|
+
os.close(self.null_fd)
|
|
40
|
+
os.close(self.save_stdout)
|
|
41
|
+
os.close(self.save_stderr)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def show_html_in_window(html_path: str, window_title: str = "Map Viewer") -> None:
|
|
45
|
+
"""Display an HTML file in a PyQt6 window.
|
|
46
|
+
|
|
47
|
+
:param html_path: Absolute path to the HTML file to display.
|
|
48
|
+
:type html_path: str
|
|
49
|
+
:param window_title: Title for the window.
|
|
50
|
+
:type window_title: str
|
|
51
|
+
"""
|
|
52
|
+
app = QApplication(sys.argv)
|
|
53
|
+
window = QMainWindow()
|
|
54
|
+
window.setWindowTitle(window_title)
|
|
55
|
+
window.resize(1200, 800)
|
|
56
|
+
|
|
57
|
+
web_view = QWebEngineView()
|
|
58
|
+
settings = web_view.settings()
|
|
59
|
+
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
|
|
60
|
+
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
|
|
61
|
+
|
|
62
|
+
web_view.load(QUrl.fromLocalFile(html_path))
|
|
63
|
+
window.setCentralWidget(web_view)
|
|
64
|
+
window.show()
|
|
65
|
+
|
|
66
|
+
with SuppressOutput():
|
|
67
|
+
app.exec()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
if len(sys.argv) < 2:
|
|
72
|
+
print("Usage: python _qt_window.py <html_path> [window_title]")
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
html_path = sys.argv[1]
|
|
76
|
+
window_title = sys.argv[2] if len(sys.argv) > 2 else "Map Viewer"
|
|
77
|
+
|
|
78
|
+
show_html_in_window(html_path, window_title)
|