TahaRasouli commited on
Commit
1c6109f
·
verified ·
1 Parent(s): 8e8c62e

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +107 -0
  2. graphGen3.py +709 -0
  3. graphGen4.py +681 -0
  4. graphGen5.py +776 -0
app.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import gradio as gr
3
+ import matplotlib.pyplot as plt
4
+ import networkx as nx
5
+
6
+ # ---- Import generators (NO circular imports) ----
7
+ from graphGen3 import NetworkGenerator as NetworkGenerator3
8
+ from graphGen4 import NetworkGenerator as NetworkGenerator4
9
+ from graphGen5 import NetworkGenerator as NetworkGenerator5
10
+
11
+
12
+ # ---- Registry of available generators ----
13
+ GENERATOR_MAP = {
14
+ "graphGen3": NetworkGenerator3,
15
+ "graphGen4": NetworkGenerator4,
16
+ "graphGen5": NetworkGenerator5,
17
+
18
+ }
19
+
20
+
21
+ def generate_network(generator_name, size, variant, topology):
22
+ """
23
+ Gradio callback: generate a network using the selected generator.
24
+ """
25
+ GeneratorClass = GENERATOR_MAP[generator_name]
26
+
27
+ generator = GeneratorClass(
28
+ size=size,
29
+ variant=variant,
30
+ topology=topology
31
+ )
32
+
33
+ start = time.time()
34
+ graph = generator.generate()
35
+ elapsed = time.time() - start
36
+
37
+ stats = (
38
+ f"Generator: {generator_name}\n"
39
+ f"Operation Time: {elapsed:.4f} seconds\n"
40
+ f"Nodes: {len(graph.nodes())}\n"
41
+ f"Edges: {len(graph.edges())}"
42
+ )
43
+
44
+ # ---- Plot ----
45
+ fig, ax = plt.subplots(figsize=(8, 8))
46
+ pos = {node: (node[1], -node[0]) for node in graph.nodes()}
47
+ nx.draw(
48
+ graph,
49
+ pos,
50
+ ax=ax,
51
+ with_labels=True,
52
+ node_size=300,
53
+ font_size=8
54
+ )
55
+ ax.set_title(f"{generator_name} | {size}, {variant}, {topology}")
56
+ ax.grid(True)
57
+
58
+ return fig, stats
59
+
60
+
61
+ # ---- Gradio UI ----
62
+ with gr.Blocks(title="Network Generator") as demo:
63
+ gr.Markdown("# Network Generator")
64
+
65
+ with gr.Row():
66
+ generator_choice = gr.Dropdown(
67
+ choices=list(GENERATOR_MAP.keys()),
68
+ value="graphGen3",
69
+ label="Generator Logic"
70
+ )
71
+
72
+ with gr.Row():
73
+ size = gr.Dropdown(
74
+ choices=["S", "M", "L"],
75
+ value="S",
76
+ label="Size"
77
+ )
78
+ variant = gr.Dropdown(
79
+ choices=["F", "R"],
80
+ value="F",
81
+ label="Variant"
82
+ )
83
+ topology = gr.Dropdown(
84
+ choices=["highly_connected", "bottlenecks", "linear"],
85
+ value="highly_connected",
86
+ label="Topology"
87
+ )
88
+
89
+ generate_btn = gr.Button("Generate Network")
90
+
91
+ with gr.Row():
92
+ plot_out = gr.Plot(label="Generated Graph")
93
+ stats_out = gr.Textbox(
94
+ label="Statistics",
95
+ lines=6,
96
+ interactive=False
97
+ )
98
+
99
+ generate_btn.click(
100
+ fn=generate_network,
101
+ inputs=[generator_choice, size, variant, topology],
102
+ outputs=[plot_out, stats_out]
103
+ )
104
+
105
+
106
+ if __name__ == "__main__":
107
+ demo.launch()
graphGen3.py ADDED
@@ -0,0 +1,709 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import networkx as nx
3
+ import matplotlib.pyplot as plt
4
+ import random
5
+ import time
6
+
7
+ class NetworkGenerator:
8
+ def __init__(self, size='S', variant='F', topology='highly_connected'):
9
+ self.size = size.upper()
10
+ self.variant = variant.upper()
11
+ self.topology = topology.lower()
12
+
13
+ if self.topology not in ['highly_connected', 'bottlenecks', 'linear']:
14
+ raise ValueError("topology must be: 'highly_connected', 'bottlenecks', or 'linear'")
15
+
16
+ # Configuration based on size (small, middle, large)
17
+ self.size_config = {
18
+ 'S': {'grid': 4, 'node_factor': 0.4, 'diag_weights': [1, 4]},
19
+ 'M': {'grid': 8, 'node_factor': 0.4, 'diag_weights': [1, 4]},
20
+ 'L': {'grid': 16, 'node_factor': 0.4, 'diag_weights': [1, 8]},
21
+ }
22
+
23
+ if self.size not in self.size_config:
24
+ raise ValueError("Invalid size. Choose 'S', 'M', or 'L'.")
25
+ if self.variant not in ['F', 'R']:
26
+ raise ValueError("Invalid variant. Choose 'F' (fixed) or 'R' (random).")
27
+
28
+ # Scenario setup
29
+ self.grid_size = self.size_config[self.size]['grid']
30
+ self.node_factor = self.size_config[self.size]['node_factor']
31
+ self.weight_dist = self.size_config[self.size]['diag_weights']
32
+
33
+ # Graph and node storage
34
+ self.graph = None
35
+ self.nodes_list = None
36
+
37
+
38
+ def generate(self):
39
+ """Generate a connected network representing rooms in a building."""
40
+
41
+ max_attempts = 5 # retry limit
42
+
43
+ for attempt in range(max_attempts):
44
+ self._initialize_graph()
45
+ self._add_nodes()
46
+
47
+ nodes = list(self.graph.nodes())
48
+ if not nodes:
49
+ continue
50
+
51
+ # --- STEP 1: CONNECTIVITY (NEARBY ROOMS ONLY) ---
52
+ connected = set()
53
+ remaining = set(nodes)
54
+
55
+ # Start with a random initial room
56
+ current = random.choice(nodes)
57
+ connected.add(current)
58
+ remaining.remove(current)
59
+
60
+ while remaining:
61
+
62
+ # Candidate rooms: within distance <= 2 of ANY connected room
63
+ candidates = [
64
+ n for n in remaining
65
+ if any(abs(n[0] - c[0]) <= 2 and abs(n[1] - c[1]) <= 2 for c in connected)
66
+ ]
67
+
68
+ if candidates:
69
+ candidate = random.choice(candidates)
70
+ else:
71
+ # fallback: pick any unconnected room
72
+ candidate = random.choice(list(remaining))
73
+
74
+ # Find connected neighbors near the candidate
75
+ neighbors = [
76
+ c for c in connected
77
+ if abs(c[0] - candidate[0]) <= 2 and abs(c[1] - candidate[1]) <= 2
78
+ ]
79
+
80
+ if neighbors:
81
+ n = random.choice(neighbors)
82
+ else:
83
+ # fallback: ANY connected node
84
+ n = random.choice(list(connected))
85
+
86
+ # --- Intersection checks ---
87
+ valid = True
88
+
89
+ # Straight edge
90
+ if n[0] == candidate[0] or n[1] == candidate[1]:
91
+ if self._straight_edge_intersects(n, candidate):
92
+ valid = False
93
+
94
+ # Diagonal edge
95
+ elif abs(n[0] - candidate[0]) == abs(n[1] - candidate[1]):
96
+ if self._diagonal_intersects(n, candidate):
97
+ valid = False
98
+
99
+ else:
100
+ # Not straight or diagonal → forced but accepted
101
+ valid = False
102
+
103
+ # Add the edge anyway (forced connectivity)
104
+ self.graph.add_edge(n, candidate)
105
+
106
+ # Mark candidate as connected
107
+ connected.add(candidate)
108
+ remaining.remove(candidate)
109
+
110
+ # --- STEP 2: ADD TOPOLOGY-SPECIFIC EXTRA EDGES ---
111
+ self._add_edges()
112
+
113
+ # --- STEP 3: REMOVE INTERSECTIONS & RECONNECT ---
114
+ self._remove_intersections()
115
+
116
+ # --- STEP 4: FINAL CONNECTIVITY CHECK ---
117
+ if nx.is_connected(self.graph):
118
+ return self.graph
119
+
120
+ raise RuntimeError("Failed to generate a connected network after several attempts")
121
+
122
+
123
+ def _initialize_graph(self):
124
+ self.graph = nx.Graph()
125
+ # Start in the middle region instead of (0,0)
126
+ margin = max(1, self.grid_size // 4)
127
+ low, high = margin, self.grid_size - margin
128
+ x = random.randint(low, high)
129
+ y = random.randint(low, high)
130
+ coords = np.array([x, y])
131
+ flags = np.zeros(4, dtype=int)
132
+ self.nodes_list = [[coords, flags]]
133
+ self.graph.add_node(tuple(coords))
134
+
135
+ def _compute_nodes(self):
136
+ total_possible = (self.grid_size + 1) ** 2
137
+ if self.variant == 'F':
138
+ return int(self.node_factor * total_possible)
139
+ else:
140
+ return int(random.uniform(0.4, 0.7) * total_possible)
141
+
142
+ def _add_nodes(self):
143
+ """Place nodes mostly in the middle region (cluster logic)."""
144
+ total_nodes = self._compute_nodes()
145
+
146
+ # Middle region boundaries
147
+ margin = max(1, self.grid_size // 4)
148
+ low, high = margin, self.grid_size - margin
149
+
150
+ attempts = 0
151
+ while len(self.graph.nodes()) < total_nodes and attempts < 5000:
152
+ attempts += 1
153
+ x = random.randint(low, high)
154
+ y = random.randint(low, high)
155
+ if (x, y) not in self.graph:
156
+ self.graph.add_node((x, y))
157
+
158
+ def _add_random_neighbors(self):
159
+ if not self.nodes_list:
160
+ return
161
+
162
+ predecessor_entry = self.nodes_list[0]
163
+ coords, _ = predecessor_entry
164
+ rand_neighbors = random.randint(1, 4)
165
+
166
+ for _ in range(rand_neighbors):
167
+ direction = random.choice(['V', 'H'])
168
+ distance = random.choices([1, 2], weights=self.weight_dist, k=1)[0]
169
+ new_coords = self._get_new_node(coords, direction, distance)
170
+
171
+ if new_coords is not None and tuple(new_coords) not in self.graph:
172
+ self.graph.add_node(tuple(new_coords))
173
+ flags = np.zeros(4, dtype=int)
174
+ self.nodes_list.append([new_coords, flags])
175
+ self._update_neighbor_flags(coords, new_coords)
176
+
177
+ self.nodes_list.pop(0)
178
+
179
+ def _get_new_node(self, coords, direction, dist):
180
+ x, y = coords
181
+ if direction == 'V':
182
+ if random.choice([True, False]) and x + dist <= self.grid_size:
183
+ return np.array([x + dist, y])
184
+ elif x - dist >= 0:
185
+ return np.array([x - dist, y])
186
+ elif direction == 'H':
187
+ if random.choice([True, False]) and y + dist <= self.grid_size:
188
+ return np.array([x, y + dist])
189
+ elif y - dist >= 0:
190
+ return np.array([x, y - dist])
191
+ return None
192
+
193
+ def _update_neighbor_flags(self, predecessor_coords, new_coords):
194
+ px, py = predecessor_coords
195
+ nx_, ny = new_coords
196
+
197
+ # Find indices
198
+ predecessor_idx = next((i for i, n in enumerate(self.nodes_list) if np.array_equal(n[0], predecessor_coords)), None)
199
+ new_node_idx = next((i for i, n in enumerate(self.nodes_list) if np.array_equal(n[0], new_coords)), None)
200
+
201
+ if predecessor_idx is None or new_node_idx is None:
202
+ return
203
+
204
+ # Directional flags: [up, down, left, right]
205
+ if nx_ < px: # new above
206
+ self.nodes_list[predecessor_idx][1][0] = 1
207
+ self.nodes_list[new_node_idx][1][1] = 1
208
+ elif nx_ > px: # new below
209
+ self.nodes_list[predecessor_idx][1][1] = 1
210
+ self.nodes_list[new_node_idx][1][0] = 1
211
+ elif ny < py: # new left
212
+ self.nodes_list[predecessor_idx][1][2] = 1
213
+ self.nodes_list[new_node_idx][1][3] = 1
214
+ elif ny > py: # new right
215
+ self.nodes_list[predecessor_idx][1][3] = 1
216
+ self.nodes_list[new_node_idx][1][2] = 1
217
+
218
+ def _compute_edge_count(self):
219
+ total_nodes = len(self.graph.nodes())
220
+ if self.variant == 'F':
221
+ return int(1.5 * total_nodes)
222
+ else:
223
+ return int(random.uniform(1.5, 2.5) * total_nodes)
224
+
225
+ def _add_edges(self):
226
+ nodes = list(self.graph.nodes())
227
+ total_edges = self._compute_edge_count()
228
+
229
+ if self.topology == "highly_connected":
230
+ self._add_cluster_dense(nodes, total_edges)
231
+
232
+ elif self.topology == "bottlenecks":
233
+ self._add_cluster_sparse(nodes, total_edges)
234
+ self._add_cluster_bottleneck(nodes)
235
+
236
+ elif self.topology == "linear":
237
+ self._make_linear(nodes)
238
+
239
+
240
+ def _add_straight_edges_if_no_intersection(self, nodes, max_edges):
241
+ count = 0
242
+ for i in range(len(nodes)):
243
+ for j in range(i + 1, len(nodes)):
244
+ if count >= max_edges:
245
+ return
246
+ x1, y1 = nodes[i]
247
+ x2, y2 = nodes[j]
248
+ if (x1 == x2 or y1 == y2) and not self.graph.has_edge(nodes[i], nodes[j]):
249
+ self.graph.add_edge(nodes[i], nodes[j])
250
+ count += 1
251
+
252
+ def _straight_edge_intersects(self, n1, n2):
253
+ """Check if a straight (H/V) edge between n1–n2 intersects existing edges."""
254
+ x1, y1 = n1
255
+ x2, y2 = n2
256
+
257
+ # Only straight edges
258
+ if not (x1 == x2 or y1 == y2):
259
+ return True
260
+
261
+ # Ensure consistent ordering
262
+ if (x1, y1) > (x2, y2):
263
+ n1, n2 = n2, n1
264
+ x1, y1 = n1
265
+ x2, y2 = n2
266
+
267
+ for a, b in self.graph.edges():
268
+ if {a, b} == {n1, n2}:
269
+ continue
270
+
271
+ ax, ay = a
272
+ bx, by = b
273
+
274
+ # Horizontal edge
275
+ if y1 == y2:
276
+ if ay == by == y1:
277
+ # overlap?
278
+ if max(ax, bx) >= min(x1, x2) and min(ax, bx) <= max(x1, x2):
279
+ return True
280
+
281
+ # Vertical edge
282
+ if x1 == x2:
283
+ if ax == bx == x1:
284
+ if max(ay, by) >= min(y1, y2) and min(ay, by) <= max(y1, y2):
285
+ return True
286
+
287
+ return False
288
+
289
+ def _diagonal_intersects(self, n1, n2):
290
+ x1, y1 = n1
291
+ x2, y2 = n2
292
+
293
+ for a, b in self.graph.edges():
294
+ ax, ay = a
295
+ bx, by = b
296
+
297
+ # Only check against diagonal edges
298
+ if abs(ax - bx) == abs(ay - by):
299
+ # Check if bounding boxes overlap
300
+ if not (max(x1, x2) < min(ax, bx) or min(x1, x2) > max(ax, bx)):
301
+ if not (max(y1, y2) < min(ay, by) or min(y1, y2) > max(ay, by)):
302
+ return True
303
+
304
+ return False
305
+
306
+
307
+ def _generate_diagonal_edges(self, nodes, max_edges):
308
+ count = 0
309
+ for i in range(len(nodes)):
310
+ for j in range(i + 1, len(nodes)):
311
+ if count >= max_edges:
312
+ return
313
+ x1, y1 = nodes[i]
314
+ x2, y2 = nodes[j]
315
+ if abs(x1 - x2) == abs(y1 - y2) and not self.graph.has_edge(nodes[i], nodes[j]):
316
+ self.graph.add_edge(nodes[i], nodes[j])
317
+ count += 1
318
+
319
+ def _make_linear(self, nodes):
320
+ # Sort nodes by x then by y so the backbone moves roughly top→down or left→right
321
+ nodes_sorted = sorted(nodes, key=lambda x: (x[0], x[1]))
322
+
323
+ # Build the main backbone (no diagonal, only straight)
324
+ prev = nodes_sorted[0]
325
+ for nxt in nodes_sorted[1:]:
326
+ x1, y1 = prev
327
+ x2, y2 = nxt
328
+
329
+ # ONLY connect if same row or same column
330
+ if x1 == x2 or y1 == y2:
331
+ self.graph.add_edge(prev, nxt)
332
+ prev = nxt
333
+ else:
334
+ # If diagonal, find a 1-step straight intermediate
335
+ # Move horizontally first
336
+ if x1 != x2:
337
+ step = (x1 + (1 if x2 > x1 else -1), y1)
338
+ if step in nodes:
339
+ self.graph.add_edge(prev, step)
340
+ self.graph.add_edge(step, nxt)
341
+ prev = nxt
342
+ continue
343
+
344
+ # Move vertically
345
+ if y1 != y2:
346
+ step = (x1, y1 + (1 if y2 > y1 else -1))
347
+ if step in nodes:
348
+ self.graph.add_edge(prev, step)
349
+ self.graph.add_edge(step, nxt)
350
+ prev = nxt
351
+ continue
352
+
353
+ # Add occasional side branches (0.15 = 15% chance)
354
+ for node in nodes_sorted:
355
+ if random.random() < 0.15:
356
+ x, y = node
357
+ # choose one of the 4 permissible directions
358
+ candidates = [(x+1,y),(x-1,y),(x,y+1),(x,y-1)]
359
+ random.shuffle(candidates)
360
+
361
+ for c in candidates:
362
+ if c in nodes and not self.graph.has_edge(node, c):
363
+ # Ensure node doesn't exceed degree 3
364
+ if self.graph.degree(node) < 3 and self.graph.degree(c) < 3:
365
+ self.graph.add_edge(node, c)
366
+ break
367
+
368
+
369
+
370
+ def _add_sparse_edges(self, nodes):
371
+ # create a moderate number of edges but not dense
372
+ for i in range(len(nodes)):
373
+ for j in range(i+1, len(nodes)):
374
+ if random.random() < 0.15: # sparse edges
375
+ self.graph.add_edge(nodes[i], nodes[j])
376
+
377
+
378
+ def _create_bottleneck(self, nodes):
379
+ # Split graph into left/right sets (or top/bottom)
380
+ left = [n for n in nodes if n[0] <= self.grid_size // 2]
381
+ right = [n for n in nodes if n not in left]
382
+
383
+ # pick random chokepoint nodes
384
+ l = random.choice(left)
385
+ r = random.choice(right)
386
+
387
+ # force 1 bottleneck edge
388
+ self.graph.add_edge(l, r)
389
+
390
+ def _add_dense_edges(self, nodes):
391
+ # add all straight edges
392
+ for i in range(len(nodes)):
393
+ for j in range(i+1, len(nodes)):
394
+ x1, y1 = nodes[i]
395
+ x2, y2 = nodes[j]
396
+
397
+ # Straight connections
398
+ if x1 == x2 or y1 == y2:
399
+ self.graph.add_edge(nodes[i], nodes[j])
400
+
401
+ # Diagonal connections
402
+ if abs(x1 - x2) == abs(y1 - y2):
403
+ self.graph.add_edge(nodes[i], nodes[j])
404
+
405
+ def _add_cluster_dense(self, nodes, max_edges):
406
+ edges_added = 0
407
+ random.shuffle(nodes)
408
+
409
+ for i in range(len(nodes)):
410
+ for j in range(i+1, len(nodes)):
411
+ if edges_added >= max_edges:
412
+ return
413
+ n1, n2 = nodes[i], nodes[j]
414
+
415
+ # Straight edge
416
+ if (n1[0] == n2[0] or n1[1] == n2[1]):
417
+ if not self._straight_edge_intersects(n1, n2):
418
+ self.graph.add_edge(n1, n2)
419
+ edges_added += 1
420
+ continue
421
+
422
+ # Diagonal
423
+ if abs(n1[0] - n2[0]) == abs(n1[1] - n2[1]):
424
+ if not self._diagonal_intersects(n1, n2):
425
+ self.graph.add_edge(n1, n2)
426
+ edges_added += 1
427
+
428
+
429
+ def _add_cluster_sparse(self, nodes, max_edges):
430
+ edges_added = 0
431
+ random.shuffle(nodes)
432
+
433
+ for i in range(len(nodes)):
434
+ for j in range(i+1, len(nodes)):
435
+ if edges_added >= max_edges:
436
+ return
437
+
438
+ if random.random() < 0.15: # sparse like your C
439
+ n1, n2 = nodes[i], nodes[j]
440
+
441
+ # straight only for sparsity
442
+ if (n1[0] == n2[0] or n1[1] == n2[1]) and \
443
+ not self._straight_edge_intersects(n1, n2):
444
+ self.graph.add_edge(n1, n2)
445
+ edges_added += 1
446
+
447
+
448
+ def _add_cluster_bottleneck(self, nodes):
449
+ mid = self.grid_size // 2
450
+
451
+ left = [n for n in nodes if n[0] <= mid]
452
+ right = [n for n in nodes if n not in left]
453
+
454
+ if not left or not right:
455
+ return
456
+
457
+ a = random.choice(left)
458
+ b = random.choice(right)
459
+
460
+ if not self._straight_edge_intersects(a, b):
461
+ self.graph.add_edge(a, b)
462
+
463
+
464
+ # --------------------
465
+ # Intersection utilities
466
+ # --------------------
467
+ def _orientation(self, p, q, r):
468
+ """Return orientation for ordered triplet (p, q, r).
469
+ 0 = collinear, 1 = clockwise, 2 = counterclockwise."""
470
+ (px, py), (qx, qy), (rx, ry) = p, q, r
471
+ val = (qy - py) * (rx - qx) - (qx - px) * (ry - qy)
472
+ if val == 0:
473
+ return 0
474
+ return 1 if val > 0 else 2
475
+
476
+ def _on_segment(self, p, q, r):
477
+ """Check if point q lies on segment pr."""
478
+ (px, py), (qx, qy), (rx, ry) = p, q, r
479
+ return (min(px, rx) <= qx <= max(px, rx) and
480
+ min(py, ry) <= qy <= max(py, ry))
481
+
482
+ def _segments_intersect(self, a, b, c, d):
483
+ """Return True if segments ab and cd intersect (excluding shared endpoints)."""
484
+ # Shared endpoints do NOT count as intersections
485
+ if a in (c, d) or b in (c, d):
486
+ return False
487
+
488
+ o1 = self._orientation(a, b, c)
489
+ o2 = self._orientation(a, b, d)
490
+ o3 = self._orientation(c, d, a)
491
+ o4 = self._orientation(c, d, b)
492
+
493
+ # General case
494
+ if o1 != o2 and o3 != o4:
495
+ return True
496
+
497
+ # Special cases (collinear)
498
+ if o1 == 0 and self._on_segment(a, c, b):
499
+ return True
500
+ if o2 == 0 and self._on_segment(a, d, b):
501
+ return True
502
+ if o3 == 0 and self._on_segment(c, a, d):
503
+ return True
504
+ if o4 == 0 and self._on_segment(c, b, d):
505
+ return True
506
+
507
+ return False
508
+
509
+ def _would_create_intersection(self, u, v):
510
+ """Check whether adding edge (u,v) would intersect any existing edge."""
511
+ for x, y in self.graph.edges():
512
+ # ignore if touching endpoints
513
+ if u in (x, y) or v in (x, y):
514
+ continue
515
+ if self._segments_intersect(u, v, x, y):
516
+ return True
517
+ return False
518
+
519
+ def _remove_intersections(self):
520
+ """
521
+ Remove intersecting edges and attempt to reconnect components using
522
+ nearest-neighbor edges (prefer Chebyshev distance <= 2 as requested).
523
+ """
524
+ max_passes = 10
525
+ pass_no = 0
526
+ total_removed = 0
527
+
528
+ while pass_no < max_passes:
529
+ pass_no += 1
530
+ edges = list(self.graph.edges())
531
+ intersections = []
532
+
533
+ # Find all intersecting edge pairs
534
+ for i in range(len(edges)):
535
+ a, b = edges[i]
536
+ for j in range(i + 1, len(edges)):
537
+ c, d = edges[j]
538
+ if self._segments_intersect(a, b, c, d):
539
+ intersections.append((a, b, c, d))
540
+
541
+ if not intersections:
542
+ break # no intersections left
543
+
544
+ # Remove longer edge of each intersecting pair (if still present)
545
+ removed_this_pass = 0
546
+ for a, b, c, d in intersections:
547
+ if not self.graph.has_edge(a, b) or not self.graph.has_edge(c, d):
548
+ continue # already removed in this pass
549
+
550
+ len1 = (a[0]-b[0])**2 + (a[1]-b[1])**2
551
+ len2 = (c[0]-d[0])**2 + (c[1]-d[1])**2
552
+
553
+ if len1 >= len2:
554
+ try:
555
+ self.graph.remove_edge(a, b)
556
+ removed_this_pass += 1
557
+ except Exception:
558
+ pass
559
+ else:
560
+ try:
561
+ self.graph.remove_edge(c, d)
562
+ removed_this_pass += 1
563
+ except Exception:
564
+ pass
565
+
566
+ total_removed += removed_this_pass
567
+
568
+ # After removals, try to reconnect components
569
+ self._attempt_reconnect_components(prefer_max_distance=2)
570
+
571
+ # Final try to reconnect if still disconnected
572
+ if not nx.is_connected(self.graph):
573
+ self._attempt_reconnect_components(prefer_max_distance=self.grid_size)
574
+
575
+ # One last pass to remove any intersections created during reconnection attempts
576
+ # but limit passes to avoid endless loops
577
+ final_edges = list(self.graph.edges())
578
+ for i in range(len(final_edges)):
579
+ a, b = final_edges[i]
580
+ for j in range(i+1, len(final_edges)):
581
+ c, d = final_edges[j]
582
+ if self._segments_intersect(a, b, c, d):
583
+ # break ties by removing longer edge
584
+ len1 = (a[0]-b[0])**2 + (a[1]-b[1])**2
585
+ len2 = (c[0]-d[0])**2 + (c[1]-d[1])**2
586
+ if len1 >= len2 and self.graph.has_edge(a,b):
587
+ self.graph.remove_edge(a, b)
588
+ total_removed += 1
589
+ elif self.graph.has_edge(c,d):
590
+ self.graph.remove_edge(c, d)
591
+ total_removed += 1
592
+
593
+ # Debug / informative print
594
+ # (You can replace prints with logging if preferred)
595
+ print(f"[cleanup] Removed {total_removed} intersecting edges after {pass_no} passes.")
596
+
597
+ def _attempt_reconnect_components(self, prefer_max_distance=2):
598
+ """
599
+ Try to connect disconnected components by adding edges between the closest
600
+ node pairs across components. Preference: Chebyshev distance <= prefer_max_distance,
601
+ gradually relaxing up to grid_size if required. Avoid creating intersections when possible.
602
+ """
603
+ comps = list(nx.connected_components(self.graph))
604
+ if len(comps) <= 1:
605
+ return
606
+
607
+ # Function to compute Chebyshev distance
608
+ def cheb(a, b):
609
+ return max(abs(a[0]-b[0]), abs(a[1]-b[1]))
610
+
611
+ # Build list of nodes per component
612
+ comp_nodes = [list(c) for c in comps]
613
+
614
+ # We'll try to connect components pairwise until a single component remains.
615
+ # Attempt multiple relaxation levels.
616
+ max_relax = self.grid_size
617
+ relax = prefer_max_distance
618
+
619
+ while relax <= max_relax and len(comp_nodes) > 1:
620
+ made_connection = False
621
+
622
+ # Try connecting each pair of components
623
+ i = 0
624
+ while i < len(comp_nodes) - 1:
625
+ j = i + 1
626
+ connected_this_round = False
627
+ while j < len(comp_nodes):
628
+ best_pair = None
629
+ best_dist = None
630
+
631
+ # find best node pair between comp i and comp j within relax
632
+ for u in comp_nodes[i]:
633
+ for v in comp_nodes[j]:
634
+ if u == v:
635
+ continue
636
+ d = cheb(u, v)
637
+ if d <= relax and (best_dist is None or d < best_dist):
638
+ best_pair = (u, v)
639
+ best_dist = d
640
+
641
+ if best_pair is not None:
642
+ u, v = best_pair
643
+ # avoid adding duplicate edge
644
+ if not self.graph.has_edge(u, v):
645
+ # prefer adding if it won't create intersection
646
+ if not self._would_create_intersection(u, v):
647
+ self.graph.add_edge(u, v)
648
+ made_connection = True
649
+ connected_this_round = True
650
+ # merge components lists
651
+ comp_nodes[i].extend(comp_nodes[j])
652
+ comp_nodes.pop(j)
653
+ break
654
+ else:
655
+ # If we cannot avoid intersection, try to find alternative pairs
656
+ # Try other candidate pairs within same two comps
657
+ alt_added = False
658
+ for uu in comp_nodes[i]:
659
+ for vv in comp_nodes[j]:
660
+ if uu == vv:
661
+ continue
662
+ d2 = cheb(uu, vv)
663
+ if d2 <= relax and not self.graph.has_edge(uu, vv):
664
+ if not self._would_create_intersection(uu, vv):
665
+ self.graph.add_edge(uu, vv)
666
+ alt_added = True
667
+ break
668
+ if alt_added:
669
+ break
670
+ if alt_added:
671
+ made_connection = True
672
+ connected_this_round = True
673
+ comp_nodes[i].extend(comp_nodes[j])
674
+ comp_nodes.pop(j)
675
+ break
676
+ else:
677
+ # as final resort, add the best_pair even if it creates intersection
678
+ # This ensures connectivity; intersections will be cleaned in a later pass.
679
+ self.graph.add_edge(u, v)
680
+ made_connection = True
681
+ connected_this_round = True
682
+ comp_nodes[i].extend(comp_nodes[j])
683
+ comp_nodes.pop(j)
684
+ break
685
+ else:
686
+ # no candidate between these two comps within relax
687
+ j += 1
688
+
689
+ if not connected_this_round:
690
+ i += 1 # move to next comp pair to try
691
+ # if connected_this_round we keep i same to attempt merging more into same comp
692
+
693
+ if not made_connection:
694
+ relax += 1 # relax distance constraint and try again
695
+ else:
696
+ # recompute components after merges
697
+ comps = list(nx.connected_components(self.graph))
698
+ comp_nodes = [list(c) for c in comps]
699
+
700
+ # End while: either connected or we've exhausted relax limit
701
+
702
+
703
+ def plot(self):
704
+ plt.figure(figsize=(8, 8))
705
+ pos = {node: (node[1], -node[0]) for node in self.graph.nodes()}
706
+ nx.draw(self.graph, pos, with_labels=True, node_size=300, font_size=8)
707
+ plt.title(f"Generated Network ({self.size}, {self.variant})")
708
+ plt.grid(True)
709
+ plt.show()
graphGen4.py ADDED
@@ -0,0 +1,681 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import networkx as nx
3
+ import matplotlib.pyplot as plt
4
+ import random
5
+ import time
6
+
7
+
8
+ class NetworkGenerator:
9
+ def __init__(self, size="S", variant="F", topology="highly_connected",
10
+ node_drop_fraction=0.2,
11
+ bottleneck_cluster_count=None,
12
+ bottleneck_edges_per_link=1):
13
+ """
14
+ node_drop_fraction:
15
+ Fraction of all (grid+1)^2 possible positions that are deactivated (not allowed as nodes).
16
+ Example: 0.2 -> remove 1/5 of all grid positions.
17
+
18
+ bottleneck_cluster_count:
19
+ If None, chosen automatically based on size.
20
+ Larger => more small dense clusters.
21
+
22
+ bottleneck_edges_per_link:
23
+ Number of edges connecting consecutive clusters (these are the bottlenecks).
24
+ Keep this small (1 or 2) to preserve bottleneck behavior.
25
+ """
26
+ self.size = size.upper()
27
+ self.variant = variant.upper()
28
+ self.topology = topology.lower()
29
+
30
+ if self.topology not in ["highly_connected", "bottlenecks", "linear"]:
31
+ raise ValueError("topology must be: 'highly_connected', 'bottlenecks', or 'linear'")
32
+
33
+ self.size_config = {
34
+ "S": {"grid": 4, "node_factor": 0.4, "diag_weights": [1, 4]},
35
+ "M": {"grid": 8, "node_factor": 0.4, "diag_weights": [1, 4]},
36
+ "L": {"grid": 16, "node_factor": 0.4, "diag_weights": [1, 8]},
37
+ }
38
+
39
+ if self.size not in self.size_config:
40
+ raise ValueError("Invalid size. Choose 'S', 'M', or 'L'.")
41
+ if self.variant not in ["F", "R"]:
42
+ raise ValueError("Invalid variant. Choose 'F' (fixed) or 'R' (random).")
43
+
44
+ self.grid_size = self.size_config[self.size]["grid"]
45
+ self.node_factor = self.size_config[self.size]["node_factor"]
46
+ self.weight_dist = self.size_config[self.size]["diag_weights"]
47
+
48
+ self.node_drop_fraction = float(node_drop_fraction)
49
+ if not (0.0 <= self.node_drop_fraction < 1.0):
50
+ raise ValueError("node_drop_fraction must be in [0.0, 1.0).")
51
+
52
+ if bottleneck_cluster_count is None:
53
+ self.bottleneck_cluster_count = {"S": 3, "M": 5, "L": 8}[self.size]
54
+ else:
55
+ self.bottleneck_cluster_count = int(bottleneck_cluster_count)
56
+ if self.bottleneck_cluster_count < 2:
57
+ raise ValueError("bottleneck_cluster_count must be >= 2.")
58
+
59
+ self.bottleneck_edges_per_link = int(bottleneck_edges_per_link)
60
+ if self.bottleneck_edges_per_link < 1:
61
+ raise ValueError("bottleneck_edges_per_link must be >= 1.")
62
+
63
+ self.graph = None
64
+ self.nodes_list = None
65
+ self.active_positions = None # allowed grid positions
66
+
67
+
68
+ # --------------------
69
+ # Public API
70
+ # --------------------
71
+ def generate(self):
72
+ """Generate a connected network representing rooms in a building."""
73
+ max_attempts = 8
74
+
75
+ for _ in range(max_attempts):
76
+ self._build_node_mask()
77
+ self._initialize_graph()
78
+ self._add_nodes()
79
+
80
+ nodes = list(self.graph.nodes())
81
+ if len(nodes) < 2:
82
+ continue
83
+
84
+ # Topology-specific edge construction
85
+ if self.topology == "bottlenecks":
86
+ # Replace the usual step-1 connectivity with a cluster+bottleneck design.
87
+ self._build_bottleneck_clusters(nodes)
88
+ else:
89
+ # --- STEP 1: CONNECTIVITY (NEARBY ROOMS ONLY) ---
90
+ self._connect_all_nodes_by_nearby_growth(nodes)
91
+
92
+ # --- STEP 2: ADD TOPOLOGY-SPECIFIC EXTRA EDGES ---
93
+ self._add_edges()
94
+
95
+ # --- STEP 3: REMOVE INTERSECTIONS & RECONNECT ---
96
+ self._remove_intersections()
97
+
98
+ # --- STEP 4: FINAL CONNECTIVITY CHECK ---
99
+ if nx.is_connected(self.graph):
100
+ return self.graph
101
+
102
+ raise RuntimeError("Failed to generate a connected network after several attempts")
103
+
104
+
105
+ def plot(self):
106
+ plt.figure(figsize=(8, 8))
107
+ pos = {node: (node[1], -node[0]) for node in self.graph.nodes()}
108
+ nx.draw(self.graph, pos, with_labels=True, node_size=300, font_size=8)
109
+ plt.title(f"Generated Network ({self.size}, {self.variant}, {self.topology})")
110
+ plt.grid(True)
111
+ plt.show()
112
+
113
+
114
+ # --------------------
115
+ # Modification 1: deactivate 1/5 of all possible nodes
116
+ # --------------------
117
+ def _build_node_mask(self):
118
+ """Deactivate node_drop_fraction of all (grid+1)^2 positions."""
119
+ all_positions = [
120
+ (x, y)
121
+ for x in range(self.grid_size + 1)
122
+ for y in range(self.grid_size + 1)
123
+ ]
124
+ drop = int(self.node_drop_fraction * len(all_positions))
125
+ deactivated = set(random.sample(all_positions, drop)) if drop > 0 else set()
126
+ self.active_positions = set(all_positions) - deactivated
127
+
128
+
129
+ # --------------------
130
+ # Node initialization and placement
131
+ # --------------------
132
+ def _initialize_graph(self):
133
+ self.graph = nx.Graph()
134
+
135
+ # Prefer to seed from the middle region, but only from active positions.
136
+ margin = max(1, self.grid_size // 4)
137
+ low, high = margin, self.grid_size - margin
138
+
139
+ middle_active = [(x, y) for (x, y) in self.active_positions if low <= x <= high and low <= y <= high]
140
+ if middle_active:
141
+ seed = random.choice(middle_active)
142
+ else:
143
+ seed = random.choice(list(self.active_positions))
144
+
145
+ coords = np.array([seed[0], seed[1]])
146
+ flags = np.zeros(4, dtype=int)
147
+ self.nodes_list = [[coords, flags]]
148
+ self.graph.add_node(tuple(coords))
149
+
150
+
151
+ def _compute_nodes(self):
152
+ total_possible = (self.grid_size + 1) ** 2
153
+
154
+ # Important: total_possible is still the full grid size;
155
+ # the mask reduces available positions and _add_nodes enforces that.
156
+ if self.variant == "F":
157
+ return int(self.node_factor * total_possible)
158
+ return int(random.uniform(0.4, 0.7) * total_possible)
159
+
160
+
161
+ def _add_nodes(self):
162
+ """Place nodes mostly in the middle region (cluster logic), respecting active_positions."""
163
+ total_nodes = self._compute_nodes()
164
+
165
+ margin = max(1, self.grid_size // 4)
166
+ low, high = margin, self.grid_size - margin
167
+
168
+ attempts = 0
169
+ while len(self.graph.nodes()) < total_nodes and attempts < 8000:
170
+ attempts += 1
171
+ x = random.randint(low, high)
172
+ y = random.randint(low, high)
173
+
174
+ if (x, y) not in self.active_positions:
175
+ continue
176
+ if (x, y) not in self.graph:
177
+ self.graph.add_node((x, y))
178
+
179
+
180
+ # --------------------
181
+ # Connectivity for non-bottleneck modes
182
+ # --------------------
183
+ def _connect_all_nodes_by_nearby_growth(self, nodes):
184
+ """Original connectivity step (nearby growth), unchanged except refactoring."""
185
+ connected = set()
186
+ remaining = set(nodes)
187
+
188
+ current = random.choice(nodes)
189
+ connected.add(current)
190
+ remaining.remove(current)
191
+
192
+ while remaining:
193
+ candidates = [
194
+ n for n in remaining
195
+ if any(abs(n[0] - c[0]) <= 2 and abs(n[1] - c[1]) <= 2 for c in connected)
196
+ ]
197
+
198
+ candidate = random.choice(candidates) if candidates else random.choice(list(remaining))
199
+
200
+ neighbors = [
201
+ c for c in connected
202
+ if abs(c[0] - candidate[0]) <= 2 and abs(c[1] - candidate[1]) <= 2
203
+ ]
204
+ n = random.choice(neighbors) if neighbors else random.choice(list(connected))
205
+
206
+ # Keep your existing intersection checks (but connectivity is forced anyway)
207
+ if n[0] == candidate[0] or n[1] == candidate[1]:
208
+ _ = self._straight_edge_intersects(n, candidate)
209
+ elif abs(n[0] - candidate[0]) == abs(n[1] - candidate[1]):
210
+ _ = self._diagonal_intersects(n, candidate)
211
+
212
+ self.graph.add_edge(n, candidate)
213
+
214
+ connected.add(candidate)
215
+ remaining.remove(candidate)
216
+
217
+
218
+ # --------------------
219
+ # Modification 2: Bottleneck = multiple small dense clusters connected by bottleneck edges
220
+ # --------------------
221
+ def _build_bottleneck_clusters(self, nodes):
222
+ """
223
+ Build a number of small, internally dense "grids" (clusters),
224
+ then connect clusters with a small number of inter-cluster edges
225
+ which become the bottlenecks.
226
+ """
227
+ # Clear any edges that might exist (seed node has no edges, but be explicit)
228
+ self.graph.remove_edges_from(list(self.graph.edges()))
229
+
230
+ clusters, centers = self._spatial_cluster_nodes(nodes, k=self.bottleneck_cluster_count)
231
+
232
+ # Make each cluster internally dense.
233
+ # We do "dense-without-intersections-when-possible" using your dense edge routine on subsets.
234
+ for cluster in clusters:
235
+ if len(cluster) < 2:
236
+ continue
237
+
238
+ # Ensure cluster is connected first using nearby growth inside the cluster
239
+ self._connect_cluster_by_nearby_growth(cluster)
240
+
241
+ # Then densify within the cluster
242
+ max_edges = max(1, int(3.0 * len(cluster))) # dense-ish without becoming fully complete
243
+ self._add_cluster_dense(list(cluster), max_edges=max_edges)
244
+
245
+ # Connect clusters in a chain (or near-chain) by centers.
246
+ order = sorted(range(len(clusters)), key=lambda i: (centers[i][0], centers[i][1]))
247
+ for a_idx, b_idx in zip(order[:-1], order[1:]):
248
+ self._add_bottleneck_links(clusters[a_idx], clusters[b_idx], self.bottleneck_edges_per_link)
249
+
250
+ # If something ended up disconnected (e.g., tiny clusters), reconnect lightly
251
+ if not nx.is_connected(self.graph):
252
+ self._attempt_reconnect_components(prefer_max_distance=2)
253
+
254
+
255
+ def _spatial_cluster_nodes(self, nodes, k):
256
+ """
257
+ Simple spatial clustering:
258
+ - pick k random centers
259
+ - assign each node to closest center by Chebyshev distance
260
+ - return clusters + centers
261
+ """
262
+ def cheb(a, b):
263
+ return max(abs(a[0] - b[0]), abs(a[1] - b[1]))
264
+
265
+ nodes = list(nodes)
266
+ if k >= len(nodes):
267
+ # each node its own cluster (degenerate)
268
+ return [[n] for n in nodes], nodes[:]
269
+
270
+ centers = random.sample(nodes, k)
271
+ clusters = [[] for _ in range(k)]
272
+
273
+ for n in nodes:
274
+ best_i = min(range(k), key=lambda i: cheb(n, centers[i]))
275
+ clusters[best_i].append(n)
276
+
277
+ # Recompute centers as medoid-ish: pick node closest to mean
278
+ new_centers = []
279
+ for c in clusters:
280
+ if not c:
281
+ new_centers.append(random.choice(nodes))
282
+ continue
283
+ mx = sum(p[0] for p in c) / len(c)
284
+ my = sum(p[1] for p in c) / len(c)
285
+ new_centers.append(min(c, key=lambda p: (p[0] - mx) ** 2 + (p[1] - my) ** 2))
286
+
287
+ # Remove empty clusters by merging them into nearest non-empty cluster
288
+ non_empty = [(c, ctr) for c, ctr in zip(clusters, new_centers) if len(c) > 0]
289
+ clusters = [c for c, _ in non_empty]
290
+ centers = [ctr for _, ctr in non_empty]
291
+
292
+ return clusters, centers
293
+
294
+
295
+ def _connect_cluster_by_nearby_growth(self, cluster_nodes):
296
+ """Connectivity step restricted to a cluster."""
297
+ cluster_nodes = list(cluster_nodes)
298
+ connected = set()
299
+ remaining = set(cluster_nodes)
300
+
301
+ current = random.choice(cluster_nodes)
302
+ connected.add(current)
303
+ remaining.remove(current)
304
+
305
+ while remaining:
306
+ candidates = [
307
+ n for n in remaining
308
+ if any(abs(n[0] - c[0]) <= 2 and abs(n[1] - c[1]) <= 2 for c in connected)
309
+ ]
310
+ candidate = random.choice(candidates) if candidates else random.choice(list(remaining))
311
+
312
+ neighbors = [
313
+ c for c in connected
314
+ if abs(c[0] - candidate[0]) <= 2 and abs(c[1] - candidate[1]) <= 2
315
+ ]
316
+ n = random.choice(neighbors) if neighbors else random.choice(list(connected))
317
+
318
+ self.graph.add_edge(n, candidate)
319
+ connected.add(candidate)
320
+ remaining.remove(candidate)
321
+
322
+
323
+ def _add_bottleneck_links(self, cluster_a, cluster_b, m):
324
+ """
325
+ Add m inter-cluster edges as bottlenecks. Keep m small.
326
+ Prefer edges that do not create intersections, but will force-connect if needed.
327
+ """
328
+ cluster_a = list(cluster_a)
329
+ cluster_b = list(cluster_b)
330
+
331
+ def cheb(a, b):
332
+ return max(abs(a[0] - b[0]), abs(a[1] - b[1]))
333
+
334
+ # Candidate pairs sorted by distance
335
+ pairs = []
336
+ for u in cluster_a:
337
+ for v in cluster_b:
338
+ pairs.append((cheb(u, v), u, v))
339
+ pairs.sort(key=lambda t: t[0])
340
+
341
+ added = 0
342
+ used = set()
343
+ for _, u, v in pairs:
344
+ if added >= m:
345
+ break
346
+ if (u, v) in used or (v, u) in used:
347
+ continue
348
+ if self.graph.has_edge(u, v):
349
+ continue
350
+
351
+ # Prefer non-intersecting links
352
+ if not self._would_create_intersection(u, v):
353
+ self.graph.add_edge(u, v)
354
+ used.add((u, v))
355
+ added += 1
356
+
357
+ # If we couldn't add enough without intersections, force the closest remaining
358
+ if added < m:
359
+ for _, u, v in pairs:
360
+ if added >= m:
361
+ break
362
+ if self.graph.has_edge(u, v):
363
+ continue
364
+ self.graph.add_edge(u, v)
365
+ added += 1
366
+
367
+
368
+ # --------------------
369
+ # Topology-specific extra edges (non-bottleneck modes)
370
+ # --------------------
371
+ def _compute_edge_count(self):
372
+ total_nodes = len(self.graph.nodes())
373
+ if self.variant == "F":
374
+ return int(1.5 * total_nodes)
375
+ return int(random.uniform(1.5, 2.5) * total_nodes)
376
+
377
+
378
+ def _add_edges(self):
379
+ nodes = list(self.graph.nodes())
380
+ total_edges = self._compute_edge_count()
381
+
382
+ if self.topology == "highly_connected":
383
+ self._add_cluster_dense(nodes, total_edges)
384
+
385
+ elif self.topology == "linear":
386
+ self._make_linear(nodes)
387
+
388
+ # Note: bottlenecks are built in _build_bottleneck_clusters(), not here.
389
+
390
+
391
+ # --------------------
392
+ # Dense / sparse edge routines (existing)
393
+ # --------------------
394
+ def _add_cluster_dense(self, nodes, max_edges):
395
+ edges_added = 0
396
+ nodes = list(nodes)
397
+ random.shuffle(nodes)
398
+
399
+ for i in range(len(nodes)):
400
+ for j in range(i + 1, len(nodes)):
401
+ if edges_added >= max_edges:
402
+ return
403
+ n1, n2 = nodes[i], nodes[j]
404
+
405
+ # Straight edge
406
+ if (n1[0] == n2[0] or n1[1] == n2[1]):
407
+ if not self._straight_edge_intersects(n1, n2):
408
+ self.graph.add_edge(n1, n2)
409
+ edges_added += 1
410
+ continue
411
+
412
+ # Diagonal edge
413
+ if abs(n1[0] - n2[0]) == abs(n1[1] - n2[1]):
414
+ if not self._diagonal_intersects(n1, n2):
415
+ self.graph.add_edge(n1, n2)
416
+ edges_added += 1
417
+
418
+
419
+ def _make_linear(self, nodes):
420
+ nodes_sorted = sorted(nodes, key=lambda x: (x[0], x[1]))
421
+ if not nodes_sorted:
422
+ return
423
+
424
+ prev = nodes_sorted[0]
425
+ for nxt in nodes_sorted[1:]:
426
+ x1, y1 = prev
427
+ x2, y2 = nxt
428
+
429
+ if x1 == x2 or y1 == y2:
430
+ self.graph.add_edge(prev, nxt)
431
+ prev = nxt
432
+ else:
433
+ if x1 != x2:
434
+ step = (x1 + (1 if x2 > x1 else -1), y1)
435
+ if step in nodes:
436
+ self.graph.add_edge(prev, step)
437
+ self.graph.add_edge(step, nxt)
438
+ prev = nxt
439
+ continue
440
+
441
+ if y1 != y2:
442
+ step = (x1, y1 + (1 if y2 > y1 else -1))
443
+ if step in nodes:
444
+ self.graph.add_edge(prev, step)
445
+ self.graph.add_edge(step, nxt)
446
+ prev = nxt
447
+ continue
448
+
449
+ for node in nodes_sorted:
450
+ if random.random() < 0.15:
451
+ x, y = node
452
+ candidates = [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]
453
+ random.shuffle(candidates)
454
+
455
+ for c in candidates:
456
+ if c in nodes and not self.graph.has_edge(node, c):
457
+ if self.graph.degree(node) < 3 and self.graph.degree(c) < 3:
458
+ self.graph.add_edge(node, c)
459
+ break
460
+
461
+
462
+ # --------------------
463
+ # Intersection checks (existing + used by reconnect)
464
+ # --------------------
465
+ def _straight_edge_intersects(self, n1, n2):
466
+ x1, y1 = n1
467
+ x2, y2 = n2
468
+
469
+ if not (x1 == x2 or y1 == y2):
470
+ return True
471
+
472
+ if (x1, y1) > (x2, y2):
473
+ n1, n2 = n2, n1
474
+ x1, y1 = n1
475
+ x2, y2 = n2
476
+
477
+ for a, b in self.graph.edges():
478
+ if {a, b} == {n1, n2}:
479
+ continue
480
+
481
+ ax, ay = a
482
+ bx, by = b
483
+
484
+ if y1 == y2: # horizontal
485
+ if ay == by == y1:
486
+ if max(ax, bx) >= min(x1, x2) and min(ax, bx) <= max(x1, x2):
487
+ return True
488
+
489
+ if x1 == x2: # vertical
490
+ if ax == bx == x1:
491
+ if max(ay, by) >= min(y1, y2) and min(ay, by) <= max(y1, y2):
492
+ return True
493
+
494
+ return False
495
+
496
+
497
+ def _diagonal_intersects(self, n1, n2):
498
+ x1, y1 = n1
499
+ x2, y2 = n2
500
+
501
+ for a, b in self.graph.edges():
502
+ ax, ay = a
503
+ bx, by = b
504
+
505
+ if abs(ax - bx) == abs(ay - by):
506
+ if not (max(x1, x2) < min(ax, bx) or min(x1, x2) > max(ax, bx)):
507
+ if not (max(y1, y2) < min(ay, by) or min(y1, y2) > max(ay, by)):
508
+ return True
509
+
510
+ return False
511
+
512
+
513
+ def _orientation(self, p, q, r):
514
+ (px, py), (qx, qy), (rx, ry) = p, q, r
515
+ val = (qy - py) * (rx - qx) - (qx - px) * (ry - qy)
516
+ if val == 0:
517
+ return 0
518
+ return 1 if val > 0 else 2
519
+
520
+
521
+ def _on_segment(self, p, q, r):
522
+ (px, py), (qx, qy), (rx, ry) = p, q, r
523
+ return (min(px, rx) <= qx <= max(px, rx) and
524
+ min(py, ry) <= qy <= max(py, ry))
525
+
526
+
527
+ def _segments_intersect(self, a, b, c, d):
528
+ if a in (c, d) or b in (c, d):
529
+ return False
530
+
531
+ o1 = self._orientation(a, b, c)
532
+ o2 = self._orientation(a, b, d)
533
+ o3 = self._orientation(c, d, a)
534
+ o4 = self._orientation(c, d, b)
535
+
536
+ if o1 != o2 and o3 != o4:
537
+ return True
538
+
539
+ if o1 == 0 and self._on_segment(a, c, b):
540
+ return True
541
+ if o2 == 0 and self._on_segment(a, d, b):
542
+ return True
543
+ if o3 == 0 and self._on_segment(c, a, d):
544
+ return True
545
+ if o4 == 0 and self._on_segment(c, b, d):
546
+ return True
547
+
548
+ return False
549
+
550
+
551
+ def _would_create_intersection(self, u, v):
552
+ for x, y in self.graph.edges():
553
+ if u in (x, y) or v in (x, y):
554
+ continue
555
+ if self._segments_intersect(u, v, x, y):
556
+ return True
557
+ return False
558
+
559
+
560
+ def _remove_intersections(self):
561
+ max_passes = 10
562
+ pass_no = 0
563
+ total_removed = 0
564
+
565
+ while pass_no < max_passes:
566
+ pass_no += 1
567
+ edges = list(self.graph.edges())
568
+ intersections = []
569
+
570
+ for i in range(len(edges)):
571
+ a, b = edges[i]
572
+ for j in range(i + 1, len(edges)):
573
+ c, d = edges[j]
574
+ if self._segments_intersect(a, b, c, d):
575
+ intersections.append((a, b, c, d))
576
+
577
+ if not intersections:
578
+ break
579
+
580
+ removed_this_pass = 0
581
+ for a, b, c, d in intersections:
582
+ if not self.graph.has_edge(a, b) or not self.graph.has_edge(c, d):
583
+ continue
584
+
585
+ len1 = (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
586
+ len2 = (c[0] - d[0]) ** 2 + (c[1] - d[1]) ** 2
587
+
588
+ if len1 >= len2:
589
+ try:
590
+ self.graph.remove_edge(a, b)
591
+ removed_this_pass += 1
592
+ except Exception:
593
+ pass
594
+ else:
595
+ try:
596
+ self.graph.remove_edge(c, d)
597
+ removed_this_pass += 1
598
+ except Exception:
599
+ pass
600
+
601
+ total_removed += removed_this_pass
602
+ self._attempt_reconnect_components(prefer_max_distance=2)
603
+
604
+ if not nx.is_connected(self.graph):
605
+ self._attempt_reconnect_components(prefer_max_distance=self.grid_size)
606
+
607
+ final_edges = list(self.graph.edges())
608
+ for i in range(len(final_edges)):
609
+ a, b = final_edges[i]
610
+ for j in range(i + 1, len(final_edges)):
611
+ c, d = final_edges[j]
612
+ if self._segments_intersect(a, b, c, d):
613
+ len1 = (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
614
+ len2 = (c[0] - d[0]) ** 2 + (c[1] - d[1]) ** 2
615
+ if len1 >= len2 and self.graph.has_edge(a, b):
616
+ self.graph.remove_edge(a, b)
617
+ total_removed += 1
618
+ elif self.graph.has_edge(c, d):
619
+ self.graph.remove_edge(c, d)
620
+ total_removed += 1
621
+
622
+ print(f"[cleanup] Removed {total_removed} intersecting edges after {pass_no} passes.")
623
+
624
+
625
+ def _attempt_reconnect_components(self, prefer_max_distance=2):
626
+ comps = list(nx.connected_components(self.graph))
627
+ if len(comps) <= 1:
628
+ return
629
+
630
+ def cheb(a, b):
631
+ return max(abs(a[0] - b[0]), abs(a[1] - b[1]))
632
+
633
+ comp_nodes = [list(c) for c in comps]
634
+ max_relax = self.grid_size
635
+ relax = prefer_max_distance
636
+
637
+ while relax <= max_relax and len(comp_nodes) > 1:
638
+ made_connection = False
639
+
640
+ i = 0
641
+ while i < len(comp_nodes) - 1:
642
+ j = i + 1
643
+ connected_this_round = False
644
+ while j < len(comp_nodes):
645
+ best_pair = None
646
+ best_dist = None
647
+
648
+ for u in comp_nodes[i]:
649
+ for v in comp_nodes[j]:
650
+ if u == v:
651
+ continue
652
+ d = cheb(u, v)
653
+ if d <= relax and (best_dist is None or d < best_dist):
654
+ best_pair = (u, v)
655
+ best_dist = d
656
+
657
+ if best_pair is not None:
658
+ u, v = best_pair
659
+ if not self.graph.has_edge(u, v):
660
+ if not self._would_create_intersection(u, v):
661
+ self.graph.add_edge(u, v)
662
+ else:
663
+ # force if no clean option
664
+ self.graph.add_edge(u, v)
665
+
666
+ made_connection = True
667
+ connected_this_round = True
668
+ comp_nodes[i].extend(comp_nodes[j])
669
+ comp_nodes.pop(j)
670
+ break
671
+ else:
672
+ j += 1
673
+
674
+ if not connected_this_round:
675
+ i += 1
676
+
677
+ if not made_connection:
678
+ relax += 1
679
+ else:
680
+ comps = list(nx.connected_components(self.graph))
681
+ comp_nodes = [list(c) for c in comps]
graphGen5.py ADDED
@@ -0,0 +1,776 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import networkx as nx
3
+ import matplotlib.pyplot as plt
4
+ import random
5
+ import time
6
+
7
+
8
+ class NetworkGenerator:
9
+ def __init__(self, size="S", variant="F", topology="highly_connected",
10
+ node_drop_fraction=0.1,
11
+ bottleneck_cluster_count=None,
12
+ bottleneck_edges_per_link=1):
13
+ """
14
+ node_drop_fraction:
15
+ Fraction of all (grid+1)^2 possible positions that are deactivated (not allowed as nodes).
16
+ Example: 0.2 -> remove 1/5 of all grid positions.
17
+
18
+ bottleneck_cluster_count:
19
+ If None, chosen automatically based on size.
20
+ Larger => more small dense clusters.
21
+
22
+ bottleneck_edges_per_link:
23
+ Number of edges connecting consecutive clusters (these are the bottlenecks).
24
+ Keep this small (1 or 2) to preserve bottleneck behavior.
25
+ """
26
+ self.size = size.upper()
27
+ self.variant = variant.upper()
28
+ self.topology = topology.lower()
29
+
30
+ if self.topology not in ["highly_connected", "bottlenecks", "linear"]:
31
+ raise ValueError("topology must be: 'highly_connected', 'bottlenecks', or 'linear'")
32
+
33
+ self.size_config = {
34
+ "S": {"grid": 4, "node_factor": 0.4, "diag_weights": [1, 4]},
35
+ "M": {"grid": 8, "node_factor": 0.4, "diag_weights": [1, 4]},
36
+ "L": {"grid": 16, "node_factor": 0.4, "diag_weights": [1, 8]},
37
+ }
38
+
39
+ if self.size not in self.size_config:
40
+ raise ValueError("Invalid size. Choose 'S', 'M', or 'L'.")
41
+ if self.variant not in ["F", "R"]:
42
+ raise ValueError("Invalid variant. Choose 'F' (fixed) or 'R' (random).")
43
+
44
+ self.grid_size = self.size_config[self.size]["grid"]
45
+ self.node_factor = self.size_config[self.size]["node_factor"]
46
+ self.weight_dist = self.size_config[self.size]["diag_weights"]
47
+
48
+ self.node_drop_fraction = float(node_drop_fraction)
49
+ if not (0.0 <= self.node_drop_fraction < 1.0):
50
+ raise ValueError("node_drop_fraction must be in [0.0, 1.0).")
51
+
52
+ if bottleneck_cluster_count is None:
53
+ self.bottleneck_cluster_count = {"S": 3, "M": 5, "L": 8}[self.size]
54
+ else:
55
+ self.bottleneck_cluster_count = int(bottleneck_cluster_count)
56
+ if self.bottleneck_cluster_count < 2:
57
+ raise ValueError("bottleneck_cluster_count must be >= 2.")
58
+
59
+ self.bottleneck_edges_per_link = int(bottleneck_edges_per_link)
60
+ if self.bottleneck_edges_per_link < 1:
61
+ raise ValueError("bottleneck_edges_per_link must be >= 1.")
62
+
63
+ self.graph = None
64
+ self.nodes_list = None
65
+ self.active_positions = None # allowed grid positions
66
+
67
+
68
+ # --------------------
69
+ # Public API
70
+ # --------------------
71
+ def generate(self):
72
+ """Generate a connected network representing rooms in a building."""
73
+ max_attempts = 8
74
+
75
+ for _ in range(max_attempts):
76
+ self._build_node_mask()
77
+ self._initialize_graph()
78
+ self._add_nodes()
79
+
80
+ nodes = list(self.graph.nodes())
81
+ if len(nodes) < 2:
82
+ continue
83
+
84
+ # Topology-specific edge construction
85
+ if self.topology == "bottlenecks":
86
+ # Replace the usual step-1 connectivity with a cluster+bottleneck design.
87
+ self._build_bottleneck_clusters(nodes)
88
+ else:
89
+ # --- STEP 1: CONNECTIVITY (NEARBY ROOMS ONLY) ---
90
+ self._connect_all_nodes_by_nearby_growth(nodes)
91
+
92
+ # --- STEP 2: ADD TOPOLOGY-SPECIFIC EXTRA EDGES ---
93
+ self._add_edges()
94
+
95
+ # --- STEP 3: REMOVE INTERSECTIONS & RECONNECT ---
96
+ self._remove_intersections()
97
+ self._enforce_edge_budget()
98
+
99
+
100
+ # --- STEP 4: FINAL CONNECTIVITY CHECK ---
101
+ if nx.is_connected(self.graph):
102
+ return self.graph
103
+
104
+ raise RuntimeError("Failed to generate a connected network after several attempts")
105
+
106
+
107
+ def plot(self):
108
+ plt.figure(figsize=(8, 8))
109
+ pos = {node: (node[1], -node[0]) for node in self.graph.nodes()}
110
+ nx.draw(self.graph, pos, with_labels=True, node_size=300, font_size=8)
111
+ plt.title(f"Generated Network ({self.size}, {self.variant}, {self.topology})")
112
+ plt.grid(True)
113
+ plt.show()
114
+
115
+
116
+ # --------------------
117
+ # Modification 1: deactivate 1/5 of all possible nodes
118
+ # --------------------
119
+ def _build_node_mask(self):
120
+ """Deactivate node_drop_fraction of all (grid+1)^2 positions."""
121
+ all_positions = [
122
+ (x, y)
123
+ for x in range(self.grid_size + 1)
124
+ for y in range(self.grid_size + 1)
125
+ ]
126
+ drop_frac = self._effective_node_drop_fraction()
127
+ drop = int(drop_frac * len(all_positions))
128
+
129
+ deactivated = set(random.sample(all_positions, drop)) if drop > 0 else set()
130
+ self.active_positions = set(all_positions) - deactivated
131
+
132
+
133
+ # --------------------
134
+ # Node initialization and placement
135
+ # --------------------
136
+ def _initialize_graph(self):
137
+ self.graph = nx.Graph()
138
+
139
+ # Prefer to seed from the middle region, but only from active positions.
140
+ margin = max(1, self.grid_size // 4)
141
+ low, high = margin, self.grid_size - margin
142
+
143
+ middle_active = [(x, y) for (x, y) in self.active_positions if low <= x <= high and low <= y <= high]
144
+ if middle_active:
145
+ seed = random.choice(middle_active)
146
+ else:
147
+ seed = random.choice(list(self.active_positions))
148
+
149
+ coords = np.array([seed[0], seed[1]])
150
+ flags = np.zeros(4, dtype=int)
151
+ self.nodes_list = [[coords, flags]]
152
+ self.graph.add_node(tuple(coords))
153
+
154
+
155
+ def _compute_nodes(self):
156
+ total_possible = (self.grid_size + 1) ** 2
157
+
158
+ if self.variant == "F":
159
+ base = self.node_factor
160
+ else:
161
+ base = random.uniform(0.4, 0.7)
162
+
163
+ # Topology-specific scaling
164
+ if self.topology == "highly_connected":
165
+ scale = 1.2
166
+ elif self.topology == "bottlenecks":
167
+ scale = 0.85
168
+ elif self.topology == "linear":
169
+ scale = 0.75
170
+ else:
171
+ scale = 1.0
172
+
173
+ return int(base * scale * total_possible)
174
+
175
+
176
+
177
+ def _add_nodes(self):
178
+ """Place nodes mostly in the middle region (cluster logic), respecting active_positions."""
179
+ total_nodes = self._compute_nodes()
180
+
181
+ margin = max(1, self.grid_size // 4)
182
+ low, high = margin, self.grid_size - margin
183
+
184
+ attempts = 0
185
+ while len(self.graph.nodes()) < total_nodes and attempts < 8000:
186
+ attempts += 1
187
+ x = random.randint(low, high)
188
+ y = random.randint(low, high)
189
+
190
+ if (x, y) not in self.active_positions:
191
+ continue
192
+ if (x, y) not in self.graph:
193
+ self.graph.add_node((x, y))
194
+
195
+ def _compute_edge_budget(self):
196
+ """
197
+ Hard cap on number of edges to make topology differences explicit.
198
+ """
199
+ n = len(self.graph.nodes())
200
+
201
+ if self.topology == "highly_connected":
202
+ # Dense graph
203
+ if self.variant == "F":
204
+ return int(2.8 * n)
205
+ return int(random.uniform(2.5, 3.2) * n)
206
+
207
+ if self.topology == "bottlenecks":
208
+ # Sparse, cluster-based
209
+ if self.variant == "F":
210
+ return int(1.1 * n)
211
+ return int(random.uniform(0.9, 1.3) * n)
212
+
213
+ if self.topology == "linear":
214
+ # Near-tree
215
+ return max(n - 1, int(0.9 * n))
216
+
217
+
218
+ def _enforce_edge_budget(self):
219
+ """
220
+ Remove excess edges while preserving connectivity and avoiding intersections.
221
+ Prefer removing long edges first.
222
+ """
223
+ budget = self._compute_edge_budget()
224
+ edges = list(self.graph.edges())
225
+
226
+ if len(edges) <= budget:
227
+ return
228
+
229
+ # Sort edges by length (longest first)
230
+ def edge_len(e):
231
+ (x1, y1), (x2, y2) = e
232
+ return (x1 - x2) ** 2 + (y1 - y2) ** 2
233
+
234
+ edges_sorted = sorted(edges, key=edge_len, reverse=True)
235
+
236
+ for u, v in edges_sorted:
237
+ if len(self.graph.edges()) <= budget:
238
+ break
239
+
240
+ # Do not disconnect the graph
241
+ self.graph.remove_edge(u, v)
242
+ if not nx.is_connected(self.graph):
243
+ self.graph.add_edge(u, v)
244
+
245
+
246
+
247
+ # --------------------
248
+ # Connectivity for non-bottleneck modes
249
+ # --------------------
250
+ def _connect_all_nodes_by_nearby_growth(self, nodes):
251
+ """Original connectivity step (nearby growth), unchanged except refactoring."""
252
+ connected = set()
253
+ remaining = set(nodes)
254
+
255
+ current = random.choice(nodes)
256
+ connected.add(current)
257
+ remaining.remove(current)
258
+
259
+ while remaining:
260
+ candidates = [
261
+ n for n in remaining
262
+ if any(abs(n[0] - c[0]) <= 2 and abs(n[1] - c[1]) <= 2 for c in connected)
263
+ ]
264
+
265
+ candidate = random.choice(candidates) if candidates else random.choice(list(remaining))
266
+
267
+ neighbors = [
268
+ c for c in connected
269
+ if abs(c[0] - candidate[0]) <= 2 and abs(c[1] - candidate[1]) <= 2
270
+ ]
271
+ n = random.choice(neighbors) if neighbors else random.choice(list(connected))
272
+
273
+ # Keep your existing intersection checks (but connectivity is forced anyway)
274
+ if n[0] == candidate[0] or n[1] == candidate[1]:
275
+ _ = self._straight_edge_intersects(n, candidate)
276
+ elif abs(n[0] - candidate[0]) == abs(n[1] - candidate[1]):
277
+ _ = self._diagonal_intersects(n, candidate)
278
+
279
+ self.graph.add_edge(n, candidate)
280
+
281
+ connected.add(candidate)
282
+ remaining.remove(candidate)
283
+
284
+
285
+ # --------------------
286
+ # Modification 2: Bottleneck = multiple small dense clusters connected by bottleneck edges
287
+ # --------------------
288
+ def _build_bottleneck_clusters(self, nodes):
289
+ """
290
+ Build a number of small, internally dense "grids" (clusters),
291
+ then connect clusters with a small number of inter-cluster edges
292
+ which become the bottlenecks.
293
+ """
294
+ # Clear any edges that might exist (seed node has no edges, but be explicit)
295
+ self.graph.remove_edges_from(list(self.graph.edges()))
296
+
297
+ clusters, centers = self._spatial_cluster_nodes(nodes, k=self.bottleneck_cluster_count)
298
+
299
+ # Make each cluster internally dense.
300
+ # We do "dense-without-intersections-when-possible" using your dense edge routine on subsets.
301
+ for cluster in clusters:
302
+ if len(cluster) < 2:
303
+ continue
304
+
305
+ # Ensure cluster is connected first using nearby growth inside the cluster
306
+ self._connect_cluster_by_nearby_growth(cluster)
307
+
308
+ # Then densify within the cluster
309
+ max_edges = max(1, int(3.0 * len(cluster))) # dense-ish without becoming fully complete
310
+ self._add_cluster_dense(list(cluster), max_edges=max_edges)
311
+
312
+ # Connect clusters in a chain (or near-chain) by centers.
313
+ order = sorted(range(len(clusters)), key=lambda i: (centers[i][0], centers[i][1]))
314
+ for a_idx, b_idx in zip(order[:-1], order[1:]):
315
+ self._add_bottleneck_links(clusters[a_idx], clusters[b_idx], self.bottleneck_edges_per_link)
316
+
317
+ # If something ended up disconnected (e.g., tiny clusters), reconnect lightly
318
+ if not nx.is_connected(self.graph):
319
+ self._attempt_reconnect_components(prefer_max_distance=2)
320
+
321
+
322
+ def _spatial_cluster_nodes(self, nodes, k):
323
+ """
324
+ Simple spatial clustering:
325
+ - pick k random centers
326
+ - assign each node to closest center by Chebyshev distance
327
+ - return clusters + centers
328
+ """
329
+ def cheb(a, b):
330
+ return max(abs(a[0] - b[0]), abs(a[1] - b[1]))
331
+
332
+ nodes = list(nodes)
333
+ if k >= len(nodes):
334
+ # each node its own cluster (degenerate)
335
+ return [[n] for n in nodes], nodes[:]
336
+
337
+ centers = random.sample(nodes, k)
338
+ clusters = [[] for _ in range(k)]
339
+
340
+ for n in nodes:
341
+ best_i = min(range(k), key=lambda i: cheb(n, centers[i]))
342
+ clusters[best_i].append(n)
343
+
344
+ # Recompute centers as medoid-ish: pick node closest to mean
345
+ new_centers = []
346
+ for c in clusters:
347
+ if not c:
348
+ new_centers.append(random.choice(nodes))
349
+ continue
350
+ mx = sum(p[0] for p in c) / len(c)
351
+ my = sum(p[1] for p in c) / len(c)
352
+ new_centers.append(min(c, key=lambda p: (p[0] - mx) ** 2 + (p[1] - my) ** 2))
353
+
354
+ # Remove empty clusters by merging them into nearest non-empty cluster
355
+ non_empty = [(c, ctr) for c, ctr in zip(clusters, new_centers) if len(c) > 0]
356
+ clusters = [c for c, _ in non_empty]
357
+ centers = [ctr for _, ctr in non_empty]
358
+
359
+ return clusters, centers
360
+
361
+
362
+ def _connect_cluster_by_nearby_growth(self, cluster_nodes):
363
+ """Connectivity step restricted to a cluster."""
364
+ cluster_nodes = list(cluster_nodes)
365
+ connected = set()
366
+ remaining = set(cluster_nodes)
367
+
368
+ current = random.choice(cluster_nodes)
369
+ connected.add(current)
370
+ remaining.remove(current)
371
+
372
+ while remaining:
373
+ candidates = [
374
+ n for n in remaining
375
+ if any(abs(n[0] - c[0]) <= 2 and abs(n[1] - c[1]) <= 2 for c in connected)
376
+ ]
377
+ candidate = random.choice(candidates) if candidates else random.choice(list(remaining))
378
+
379
+ neighbors = [
380
+ c for c in connected
381
+ if abs(c[0] - candidate[0]) <= 2 and abs(c[1] - candidate[1]) <= 2
382
+ ]
383
+ n = random.choice(neighbors) if neighbors else random.choice(list(connected))
384
+
385
+ self.graph.add_edge(n, candidate)
386
+ connected.add(candidate)
387
+ remaining.remove(candidate)
388
+
389
+
390
+ def _add_bottleneck_links(self, cluster_a, cluster_b, m):
391
+ """
392
+ Add m inter-cluster edges as bottlenecks. Keep m small.
393
+ Prefer edges that do not create intersections, but will force-connect if needed.
394
+ """
395
+ cluster_a = list(cluster_a)
396
+ cluster_b = list(cluster_b)
397
+
398
+ def cheb(a, b):
399
+ return max(abs(a[0] - b[0]), abs(a[1] - b[1]))
400
+
401
+ # Candidate pairs sorted by distance
402
+ pairs = []
403
+ for u in cluster_a:
404
+ for v in cluster_b:
405
+ pairs.append((cheb(u, v), u, v))
406
+ pairs.sort(key=lambda t: t[0])
407
+
408
+ added = 0
409
+ used = set()
410
+ for _, u, v in pairs:
411
+ if added >= m:
412
+ break
413
+ if (u, v) in used or (v, u) in used:
414
+ continue
415
+ if self.graph.has_edge(u, v):
416
+ continue
417
+
418
+ # Prefer non-intersecting links
419
+ if not self._would_create_intersection(u, v):
420
+ self.graph.add_edge(u, v)
421
+ used.add((u, v))
422
+ added += 1
423
+
424
+ # If we couldn't add enough without intersections, force the closest remaining
425
+ if added < m:
426
+ for _, u, v in pairs:
427
+ if added >= m:
428
+ break
429
+ if self.graph.has_edge(u, v):
430
+ continue
431
+ self.graph.add_edge(u, v)
432
+ added += 1
433
+
434
+
435
+ # --------------------
436
+ # Topology-specific extra edges (non-bottleneck modes)
437
+ # --------------------
438
+
439
+ def _effective_node_drop_fraction(self):
440
+ """
441
+ Topology-aware node dropout.
442
+ """
443
+ base = self.node_drop_fraction
444
+
445
+ if self.topology == "highly_connected":
446
+ return max(0.0, base * 0.5) # fewer dropped nodes
447
+
448
+ if self.topology == "bottlenecks":
449
+ return min(0.9, base * 1.5) # more dropped nodes
450
+
451
+ if self.topology == "linear":
452
+ return min(0.95, base * 2.0) # very sparse
453
+
454
+ return base
455
+
456
+
457
+ def _compute_edge_count(self):
458
+ """Compute the number of edges for the graph based on the topology mode."""
459
+ total_nodes = len(self.graph.nodes())
460
+
461
+ if self.topology == "highly_connected":
462
+ # Increase edge count for fully connected mode (e.g., by multiplying by a factor)
463
+ return int(4.0 * total_nodes) # Example: higher factor for full connection
464
+ elif self.topology == "bottlenecks":
465
+ # Use the default edge count calculation for bottleneck mode
466
+ return int(2.0 * total_nodes)
467
+ else:
468
+ # Default edge count for other topologies
469
+ return int(random.uniform(1.5, 2.5) * total_nodes)
470
+
471
+
472
+
473
+ def _add_edges(self):
474
+ nodes = list(self.graph.nodes())
475
+ total_edges = self._compute_edge_count()
476
+
477
+ if self.topology == "highly_connected":
478
+ self._add_cluster_dense(nodes, total_edges)
479
+
480
+ elif self.topology == "linear":
481
+ self._make_linear(nodes)
482
+
483
+ # Note: bottlenecks are built in _build_bottleneck_clusters(), not here.
484
+
485
+
486
+ # --------------------
487
+ # Dense / sparse edge routines (existing)
488
+ # --------------------
489
+ def _add_cluster_dense(self, nodes, max_edges):
490
+ edges_added = 0
491
+ nodes = list(nodes)
492
+ random.shuffle(nodes)
493
+
494
+ for i in range(len(nodes)):
495
+ for j in range(i + 1, len(nodes)):
496
+ if edges_added >= max_edges:
497
+ return
498
+ n1, n2 = nodes[i], nodes[j]
499
+
500
+ # Straight edge
501
+ if (n1[0] == n2[0] or n1[1] == n2[1]):
502
+ if not self._straight_edge_intersects(n1, n2):
503
+ self.graph.add_edge(n1, n2)
504
+ edges_added += 1
505
+ continue
506
+
507
+ # Diagonal edge
508
+ if abs(n1[0] - n2[0]) == abs(n1[1] - n2[1]):
509
+ if not self._diagonal_intersects(n1, n2):
510
+ self.graph.add_edge(n1, n2)
511
+ edges_added += 1
512
+
513
+
514
+ def _make_linear(self, nodes):
515
+ nodes_sorted = sorted(nodes, key=lambda x: (x[0], x[1]))
516
+ if not nodes_sorted:
517
+ return
518
+
519
+ prev = nodes_sorted[0]
520
+ for nxt in nodes_sorted[1:]:
521
+ x1, y1 = prev
522
+ x2, y2 = nxt
523
+
524
+ if x1 == x2 or y1 == y2:
525
+ self.graph.add_edge(prev, nxt)
526
+ prev = nxt
527
+ else:
528
+ if x1 != x2:
529
+ step = (x1 + (1 if x2 > x1 else -1), y1)
530
+ if step in nodes:
531
+ self.graph.add_edge(prev, step)
532
+ self.graph.add_edge(step, nxt)
533
+ prev = nxt
534
+ continue
535
+
536
+ if y1 != y2:
537
+ step = (x1, y1 + (1 if y2 > y1 else -1))
538
+ if step in nodes:
539
+ self.graph.add_edge(prev, step)
540
+ self.graph.add_edge(step, nxt)
541
+ prev = nxt
542
+ continue
543
+
544
+ for node in nodes_sorted:
545
+ if random.random() < 0.15:
546
+ x, y = node
547
+ candidates = [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]
548
+ random.shuffle(candidates)
549
+
550
+ for c in candidates:
551
+ if c in nodes and not self.graph.has_edge(node, c):
552
+ if self.graph.degree(node) < 3 and self.graph.degree(c) < 3:
553
+ self.graph.add_edge(node, c)
554
+ break
555
+
556
+
557
+ # --------------------
558
+ # Intersection checks (existing + used by reconnect)
559
+ # --------------------
560
+ def _straight_edge_intersects(self, n1, n2):
561
+ x1, y1 = n1
562
+ x2, y2 = n2
563
+
564
+ if not (x1 == x2 or y1 == y2):
565
+ return True
566
+
567
+ if (x1, y1) > (x2, y2):
568
+ n1, n2 = n2, n1
569
+ x1, y1 = n1
570
+ x2, y2 = n2
571
+
572
+ for a, b in self.graph.edges():
573
+ if {a, b} == {n1, n2}:
574
+ continue
575
+
576
+ ax, ay = a
577
+ bx, by = b
578
+
579
+ if y1 == y2: # horizontal
580
+ if ay == by == y1:
581
+ if max(ax, bx) >= min(x1, x2) and min(ax, bx) <= max(x1, x2):
582
+ return True
583
+
584
+ if x1 == x2: # vertical
585
+ if ax == bx == x1:
586
+ if max(ay, by) >= min(y1, y2) and min(ay, by) <= max(y1, y2):
587
+ return True
588
+
589
+ return False
590
+
591
+
592
+ def _diagonal_intersects(self, n1, n2):
593
+ x1, y1 = n1
594
+ x2, y2 = n2
595
+
596
+ for a, b in self.graph.edges():
597
+ ax, ay = a
598
+ bx, by = b
599
+
600
+ if abs(ax - bx) == abs(ay - by):
601
+ if not (max(x1, x2) < min(ax, bx) or min(x1, x2) > max(ax, bx)):
602
+ if not (max(y1, y2) < min(ay, by) or min(y1, y2) > max(ay, by)):
603
+ return True
604
+
605
+ return False
606
+
607
+
608
+ def _orientation(self, p, q, r):
609
+ (px, py), (qx, qy), (rx, ry) = p, q, r
610
+ val = (qy - py) * (rx - qx) - (qx - px) * (ry - qy)
611
+ if val == 0:
612
+ return 0
613
+ return 1 if val > 0 else 2
614
+
615
+
616
+ def _on_segment(self, p, q, r):
617
+ (px, py), (qx, qy), (rx, ry) = p, q, r
618
+ return (min(px, rx) <= qx <= max(px, rx) and
619
+ min(py, ry) <= qy <= max(py, ry))
620
+
621
+
622
+ def _segments_intersect(self, a, b, c, d):
623
+ if a in (c, d) or b in (c, d):
624
+ return False
625
+
626
+ o1 = self._orientation(a, b, c)
627
+ o2 = self._orientation(a, b, d)
628
+ o3 = self._orientation(c, d, a)
629
+ o4 = self._orientation(c, d, b)
630
+
631
+ if o1 != o2 and o3 != o4:
632
+ return True
633
+
634
+ if o1 == 0 and self._on_segment(a, c, b):
635
+ return True
636
+ if o2 == 0 and self._on_segment(a, d, b):
637
+ return True
638
+ if o3 == 0 and self._on_segment(c, a, d):
639
+ return True
640
+ if o4 == 0 and self._on_segment(c, b, d):
641
+ return True
642
+
643
+ return False
644
+
645
+
646
+ def _would_create_intersection(self, u, v):
647
+ for x, y in self.graph.edges():
648
+ if u in (x, y) or v in (x, y):
649
+ continue
650
+ if self._segments_intersect(u, v, x, y):
651
+ return True
652
+ return False
653
+
654
+
655
+ def _remove_intersections(self):
656
+ max_passes = 10
657
+ pass_no = 0
658
+ total_removed = 0
659
+
660
+ while pass_no < max_passes:
661
+ pass_no += 1
662
+ edges = list(self.graph.edges())
663
+ intersections = []
664
+
665
+ for i in range(len(edges)):
666
+ a, b = edges[i]
667
+ for j in range(i + 1, len(edges)):
668
+ c, d = edges[j]
669
+ if self._segments_intersect(a, b, c, d):
670
+ intersections.append((a, b, c, d))
671
+
672
+ if not intersections:
673
+ break
674
+
675
+ removed_this_pass = 0
676
+ for a, b, c, d in intersections:
677
+ if not self.graph.has_edge(a, b) or not self.graph.has_edge(c, d):
678
+ continue
679
+
680
+ len1 = (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
681
+ len2 = (c[0] - d[0]) ** 2 + (c[1] - d[1]) ** 2
682
+
683
+ if len1 >= len2:
684
+ try:
685
+ self.graph.remove_edge(a, b)
686
+ removed_this_pass += 1
687
+ except Exception:
688
+ pass
689
+ else:
690
+ try:
691
+ self.graph.remove_edge(c, d)
692
+ removed_this_pass += 1
693
+ except Exception:
694
+ pass
695
+
696
+ total_removed += removed_this_pass
697
+ self._attempt_reconnect_components(prefer_max_distance=2)
698
+
699
+ if not nx.is_connected(self.graph):
700
+ self._attempt_reconnect_components(prefer_max_distance=self.grid_size)
701
+
702
+ final_edges = list(self.graph.edges())
703
+ for i in range(len(final_edges)):
704
+ a, b = final_edges[i]
705
+ for j in range(i + 1, len(final_edges)):
706
+ c, d = final_edges[j]
707
+ if self._segments_intersect(a, b, c, d):
708
+ len1 = (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
709
+ len2 = (c[0] - d[0]) ** 2 + (c[1] - d[1]) ** 2
710
+ if len1 >= len2 and self.graph.has_edge(a, b):
711
+ self.graph.remove_edge(a, b)
712
+ total_removed += 1
713
+ elif self.graph.has_edge(c, d):
714
+ self.graph.remove_edge(c, d)
715
+ total_removed += 1
716
+
717
+ print(f"[cleanup] Removed {total_removed} intersecting edges after {pass_no} passes.")
718
+
719
+
720
+ def _attempt_reconnect_components(self, prefer_max_distance=2):
721
+ comps = list(nx.connected_components(self.graph))
722
+ if len(comps) <= 1:
723
+ return
724
+
725
+ def cheb(a, b):
726
+ return max(abs(a[0] - b[0]), abs(a[1] - b[1]))
727
+
728
+ comp_nodes = [list(c) for c in comps]
729
+ max_relax = self.grid_size
730
+ relax = prefer_max_distance
731
+
732
+ while relax <= max_relax and len(comp_nodes) > 1:
733
+ made_connection = False
734
+ i = 0
735
+
736
+ while i < len(comp_nodes) - 1:
737
+ j = i + 1
738
+ connected_this_round = False
739
+
740
+ while j < len(comp_nodes):
741
+ best_pair = None
742
+ best_dist = None
743
+
744
+ for u in comp_nodes[i]:
745
+ for v in comp_nodes[j]:
746
+ if u == v:
747
+ continue
748
+ d = cheb(u, v)
749
+ if d <= relax and (best_dist is None or d < best_dist):
750
+ best_pair = (u, v)
751
+ best_dist = d
752
+
753
+ if best_pair is not None:
754
+ u, v = best_pair
755
+ if not self.graph.has_edge(u, v):
756
+ if not self._would_create_intersection(u, v):
757
+ self.graph.add_edge(u, v)
758
+ else:
759
+ self.graph.add_edge(u, v)
760
+
761
+ made_connection = True
762
+ connected_this_round = True
763
+ comp_nodes[i].extend(comp_nodes[j])
764
+ comp_nodes.pop(j)
765
+ break
766
+ else:
767
+ j += 1
768
+
769
+ if not connected_this_round:
770
+ i += 1
771
+
772
+ if not made_connection:
773
+ relax += 1
774
+ else:
775
+ comps = list(nx.connected_components(self.graph))
776
+ comp_nodes = [list(c) for c in comps]