TahaRasouli commited on
Commit ·
bab0230
0
Parent(s):
Initial commit (clean, no binaries)
Browse files- .gitignore +6 -0
- __pycache__/network_generator.cpython-310.pyc +0 -0
- __pycache__/visualizer.cpython-310.pyc +0 -0
- app.py +388 -0
- dataset/renovation_data.npz +0 -0
- json_handler.py +101 -0
- network_generator.py +403 -0
- preprocess.py +70 -0
- sandbox.ipynb +258 -0
- solver.py +189 -0
- visualizer.py +66 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
temp_visuals/
|
| 2 |
+
__pycache__/
|
| 3 |
+
.ipynb_checkpoints/
|
| 4 |
+
*.png
|
| 5 |
+
*.pyc
|
| 6 |
+
|
__pycache__/network_generator.cpython-310.pyc
ADDED
|
Binary file (16.2 kB). View file
|
|
|
__pycache__/visualizer.cpython-310.pyc
ADDED
|
Binary file (2.8 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import os
|
| 3 |
+
import shutil
|
| 4 |
+
import random
|
| 5 |
+
import json
|
| 6 |
+
import zipfile
|
| 7 |
+
import networkx as nx
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
# Import from our custom modules
|
| 11 |
+
from network_generator import NetworkGenerator, validate_topology
|
| 12 |
+
from visualizer import plot_graph_to_image, IMG_WIDTH_PX, IMG_HEIGHT_PX, TEMP_DIR
|
| 13 |
+
from json_handler import generate_full_json_dict, load_graph_from_json, load_graph_from_data
|
| 14 |
+
|
| 15 |
+
# ==========================================
|
| 16 |
+
# DIRECTORY MANAGEMENT
|
| 17 |
+
# ==========================================
|
| 18 |
+
PERM_VIS_DIR = "saved_visuals"
|
| 19 |
+
ZIP_DIR = "saved_zips"
|
| 20 |
+
|
| 21 |
+
os.makedirs(PERM_VIS_DIR, exist_ok=True)
|
| 22 |
+
os.makedirs(ZIP_DIR, exist_ok=True)
|
| 23 |
+
|
| 24 |
+
def get_local_zips():
|
| 25 |
+
if not os.path.exists(ZIP_DIR): return []
|
| 26 |
+
return [f for f in os.listdir(ZIP_DIR) if f.endswith('.zip')]
|
| 27 |
+
|
| 28 |
+
def extract_jsons_from_zip(zip_path):
|
| 29 |
+
loaded = []
|
| 30 |
+
with zipfile.ZipFile(zip_path, 'r') as z:
|
| 31 |
+
for filename in z.namelist():
|
| 32 |
+
if filename.endswith('.json'):
|
| 33 |
+
with z.open(filename) as f:
|
| 34 |
+
data = json.load(f)
|
| 35 |
+
loaded.append(load_graph_from_data(data, filename))
|
| 36 |
+
return loaded
|
| 37 |
+
|
| 38 |
+
# ==========================================
|
| 39 |
+
# UI EVENT HANDLERS
|
| 40 |
+
# ==========================================
|
| 41 |
+
|
| 42 |
+
def handle_plot_click(evt: gr.SelectData, click_mode, state_data):
|
| 43 |
+
if not state_data or "graph" not in state_data:
|
| 44 |
+
return None, "Generate first.", state_data
|
| 45 |
+
|
| 46 |
+
click_x, click_y = evt.index
|
| 47 |
+
width = state_data["width"]
|
| 48 |
+
height = state_data["height"]
|
| 49 |
+
|
| 50 |
+
norm_x = click_x / IMG_WIDTH_PX
|
| 51 |
+
norm_y = click_y / IMG_HEIGHT_PX
|
| 52 |
+
grid_x = int(round(norm_x * (width + 1.0) - 0.5))
|
| 53 |
+
grid_y = int(round(norm_y * (height + 1.0) - 0.5))
|
| 54 |
+
|
| 55 |
+
# Correction for edge cases
|
| 56 |
+
if grid_x < 0: grid_x = 0
|
| 57 |
+
if grid_y < 0: grid_y = 0
|
| 58 |
+
if grid_x >= width: grid_x = width - 1
|
| 59 |
+
if grid_y >= height: grid_y = height - 1
|
| 60 |
+
|
| 61 |
+
gen = NetworkGenerator(width, height)
|
| 62 |
+
gen.graph = state_data["graph"]
|
| 63 |
+
|
| 64 |
+
action_msg = "Ignored"
|
| 65 |
+
success = False
|
| 66 |
+
highlight = None
|
| 67 |
+
|
| 68 |
+
target_coord = (grid_x, grid_y)
|
| 69 |
+
|
| 70 |
+
if click_mode == "Add/Remove Node":
|
| 71 |
+
state_data["edge_start"] = None
|
| 72 |
+
if gen.graph.has_node(target_coord):
|
| 73 |
+
success, action_msg = gen.manual_delete_node(*target_coord)
|
| 74 |
+
else:
|
| 75 |
+
success, action_msg = gen.manual_add_node(*target_coord)
|
| 76 |
+
if success: highlight = target_coord
|
| 77 |
+
|
| 78 |
+
elif click_mode == "Add/Remove Edge":
|
| 79 |
+
if not gen.graph.has_node(target_coord):
|
| 80 |
+
state_data["edge_start"] = None
|
| 81 |
+
success = True
|
| 82 |
+
action_msg = "Selection cleared."
|
| 83 |
+
else:
|
| 84 |
+
start_node = state_data.get("edge_start")
|
| 85 |
+
|
| 86 |
+
if start_node is None:
|
| 87 |
+
state_data["edge_start"] = target_coord
|
| 88 |
+
highlight = target_coord
|
| 89 |
+
success = True
|
| 90 |
+
node_id = gen.get_node_id_str(target_coord)
|
| 91 |
+
action_msg = f"Node {node_id} selected. Click another node to link."
|
| 92 |
+
elif start_node == target_coord:
|
| 93 |
+
state_data["edge_start"] = None
|
| 94 |
+
success = True
|
| 95 |
+
action_msg = "Selection cleared."
|
| 96 |
+
else:
|
| 97 |
+
success, action_msg = gen.manual_toggle_edge(start_node, target_coord)
|
| 98 |
+
state_data["edge_start"] = None
|
| 99 |
+
|
| 100 |
+
if success:
|
| 101 |
+
state_data["graph"] = gen.graph
|
| 102 |
+
img_path = plot_graph_to_image(gen.graph, width, height, highlight_node=highlight)
|
| 103 |
+
metrics = f"**Nodes:** {len(gen.graph.nodes())} | **Edges:** {len(gen.graph.edges())} | **Action:** {action_msg}"
|
| 104 |
+
return img_path, metrics, state_data
|
| 105 |
+
else:
|
| 106 |
+
return gr.update(), f"⚠️ Error: {action_msg}", state_data
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def get_preset_dims(preset_mode, topology):
|
| 110 |
+
if preset_mode == "Custom": return gr.update(interactive=True), gr.update(interactive=True)
|
| 111 |
+
dims = (6, 11) if topology=="linear" and preset_mode=="Medium" else (8,8)
|
| 112 |
+
if preset_mode == "Small": dims = (4, 4)
|
| 113 |
+
if preset_mode == "Large": dims = (16, 16) if topology!="linear" else (10, 26)
|
| 114 |
+
return gr.update(value=dims[0], interactive=False), gr.update(value=dims[1], interactive=False)
|
| 115 |
+
|
| 116 |
+
def update_ui_for_variant(variant, width, height, topology, void_frac):
|
| 117 |
+
is_custom = (variant == "Custom")
|
| 118 |
+
|
| 119 |
+
# Calculate Capable Edges
|
| 120 |
+
temp_gen = NetworkGenerator(width, height, "F", topology, void_frac)
|
| 121 |
+
max_edges = temp_gen.calculate_max_capacity()
|
| 122 |
+
|
| 123 |
+
if is_custom:
|
| 124 |
+
n, e = temp_gen.calculate_defaults()
|
| 125 |
+
return (gr.update(interactive=True),
|
| 126 |
+
gr.update(value=e, maximum=max_edges, interactive=True),
|
| 127 |
+
f"Active Grid Capacity: ~{max_edges} edges")
|
| 128 |
+
else:
|
| 129 |
+
area = width*height
|
| 130 |
+
val = 0.60 if area <= 20 else 0.35
|
| 131 |
+
return (gr.update(value=val, interactive=False),
|
| 132 |
+
gr.update(value=0, interactive=False),
|
| 133 |
+
f"Active Grid Capacity: ~{max_edges} edges")
|
| 134 |
+
|
| 135 |
+
def generate_and_store(topology, preset, width, height, variant, void_frac, t_edges):
|
| 136 |
+
try:
|
| 137 |
+
var_code = "F" if variant == "Fixed" else "R"
|
| 138 |
+
actual_edges = 0 if variant == "Fixed" else int(t_edges)
|
| 139 |
+
|
| 140 |
+
gen = NetworkGenerator(width, height, var_code, topology, void_frac, target_edges=actual_edges)
|
| 141 |
+
graph = gen.generate()
|
| 142 |
+
|
| 143 |
+
is_valid, val_msg = validate_topology(graph, topology)
|
| 144 |
+
val_icon = "✅" if is_valid else "⚠️"
|
| 145 |
+
|
| 146 |
+
# --- NEW PROMINENT STATUS MESSAGING ---
|
| 147 |
+
status_header = "✅ **Status:** Generation Successful."
|
| 148 |
+
status_detail = ""
|
| 149 |
+
|
| 150 |
+
if variant == "Custom" and actual_edges > 0:
|
| 151 |
+
current_edges = len(graph.edges())
|
| 152 |
+
diff = current_edges - actual_edges
|
| 153 |
+
|
| 154 |
+
if diff < 0:
|
| 155 |
+
# Undershoot (Saturation)
|
| 156 |
+
missing = abs(diff)
|
| 157 |
+
status_header = f"⚠️ **Status:** Saturation Limit Reached (Missing {missing} Edges)"
|
| 158 |
+
status_detail = (f"The generator saturated at **{current_edges} edges**. It could not place the remaining {missing} edges without crossing existing lines.\n\n"
|
| 159 |
+
f"**Suggestion:** To fit {actual_edges} edges, please **increase the Grid Width/Height** or **decrease Void Fraction** to create more physical space.")
|
| 160 |
+
elif diff > 0:
|
| 161 |
+
# Overshoot (Connectivity)
|
| 162 |
+
extra = diff
|
| 163 |
+
status_header = f"⚠️ **Status:** Connectivity Forced (Added {extra} Edges)"
|
| 164 |
+
status_detail = (f"The target was {actual_edges}, but **{current_edges} edges** were required to keep the graph connected.\n"
|
| 165 |
+
f"The system automatically added links to prevent isolated nodes.")
|
| 166 |
+
else:
|
| 167 |
+
status_header = f"✅ **Status:** Exact Target Met ({actual_edges} Edges)"
|
| 168 |
+
# --------------------------------------
|
| 169 |
+
|
| 170 |
+
img_path = plot_graph_to_image(graph, width, height)
|
| 171 |
+
|
| 172 |
+
# Combined Metrics Block
|
| 173 |
+
metrics = (f"**Nodes:** {len(graph.nodes())} | **Edges:** {len(graph.edges())}\n\n"
|
| 174 |
+
f"{val_icon} **Topology:** {val_msg}\n\n"
|
| 175 |
+
f"--- \n"
|
| 176 |
+
f"{status_header}\n{status_detail}")
|
| 177 |
+
|
| 178 |
+
state_data = { "graph": graph, "width": width, "height": height, "topology": topology, "edge_start": None }
|
| 179 |
+
return img_path, metrics, state_data, gr.update(interactive=True)
|
| 180 |
+
except Exception as e:
|
| 181 |
+
return None, f"Error: {e}", None, gr.update(interactive=False)
|
| 182 |
+
|
| 183 |
+
def run_batch_generation(count, topology, width, height, variant, min_v, max_v, min_e, max_e):
|
| 184 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 185 |
+
dir_name = f"batch_{timestamp}"
|
| 186 |
+
temp_build_dir = os.path.join(ZIP_DIR, dir_name)
|
| 187 |
+
os.makedirs(temp_build_dir, exist_ok=True)
|
| 188 |
+
var_code = "F" if variant == "Fixed" else "R"
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
for i in range(int(count)):
|
| 192 |
+
if variant == "Custom":
|
| 193 |
+
t_e = random.randint(int(min_e), int(max_e))
|
| 194 |
+
current_void = random.uniform(float(min_v), float(max_v))
|
| 195 |
+
else:
|
| 196 |
+
t_e = 0
|
| 197 |
+
current_void = min_v
|
| 198 |
+
|
| 199 |
+
gen = NetworkGenerator(width, height, var_code, topology, current_void, target_edges=t_e)
|
| 200 |
+
G = gen.generate()
|
| 201 |
+
|
| 202 |
+
json_content = generate_full_json_dict(G, loop=i+1)
|
| 203 |
+
with open(os.path.join(temp_build_dir, f"inst_{i+1}.json"), 'w') as f:
|
| 204 |
+
json.dump(json_content, f, indent=4)
|
| 205 |
+
|
| 206 |
+
zip_base_name = os.path.join(ZIP_DIR, dir_name)
|
| 207 |
+
zip_path = shutil.make_archive(zip_base_name, 'zip', temp_build_dir)
|
| 208 |
+
shutil.rmtree(temp_build_dir)
|
| 209 |
+
|
| 210 |
+
return zip_path, gr.update(choices=get_local_zips())
|
| 211 |
+
except Exception as e:
|
| 212 |
+
return None, gr.update()
|
| 213 |
+
|
| 214 |
+
def save_permanent_visual(state_data):
|
| 215 |
+
if not state_data or "graph" not in state_data: return "No graph to save."
|
| 216 |
+
img_path = plot_graph_to_image(state_data["graph"], state_data["width"], state_data["height"], save_dir=PERM_VIS_DIR)
|
| 217 |
+
return f"Saved successfully to {img_path}"
|
| 218 |
+
|
| 219 |
+
def save_single_json_action(state_data):
|
| 220 |
+
if not state_data or "graph" not in state_data: return None
|
| 221 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 222 |
+
json_content = generate_full_json_dict(state_data["graph"], loop=1)
|
| 223 |
+
fname = f"single_network_{timestamp}.json"
|
| 224 |
+
with open(fname, 'w') as f:
|
| 225 |
+
json.dump(json_content, f, indent=4)
|
| 226 |
+
return fname
|
| 227 |
+
|
| 228 |
+
def process_uploaded_files(files):
|
| 229 |
+
if not files:
|
| 230 |
+
return None, "No files uploaded.", gr.update(interactive=False), gr.update(interactive=False), [], 0
|
| 231 |
+
|
| 232 |
+
loaded_data = []
|
| 233 |
+
for f in files:
|
| 234 |
+
try:
|
| 235 |
+
if f.name.endswith('.zip'):
|
| 236 |
+
loaded_data.extend(extract_jsons_from_zip(f.name))
|
| 237 |
+
else:
|
| 238 |
+
loaded_data.append(load_graph_from_json(f.name))
|
| 239 |
+
except Exception as e:
|
| 240 |
+
print(f"Failed to load {f.name}: {e}")
|
| 241 |
+
|
| 242 |
+
if not loaded_data:
|
| 243 |
+
return None, "Failed to parse files.", gr.update(interactive=False), gr.update(interactive=False), [], 0
|
| 244 |
+
|
| 245 |
+
img_path, info_text = render_loaded_graph(0, loaded_data)
|
| 246 |
+
return img_path, info_text, gr.update(interactive=True), gr.update(interactive=True), loaded_data, 0
|
| 247 |
+
|
| 248 |
+
def process_local_zip_selection(zip_filename):
|
| 249 |
+
if not zip_filename:
|
| 250 |
+
return None, "No ZIP selected.", gr.update(interactive=False), gr.update(interactive=False), [], 0
|
| 251 |
+
|
| 252 |
+
zip_path = os.path.join(ZIP_DIR, zip_filename)
|
| 253 |
+
try:
|
| 254 |
+
loaded_data = extract_jsons_from_zip(zip_path)
|
| 255 |
+
except Exception as e:
|
| 256 |
+
return None, f"Failed to read ZIP: {e}", gr.update(interactive=False), gr.update(interactive=False), [], 0
|
| 257 |
+
|
| 258 |
+
if not loaded_data:
|
| 259 |
+
return None, "ZIP was empty or invalid.", gr.update(interactive=False), gr.update(interactive=False), [], 0
|
| 260 |
+
|
| 261 |
+
img_path, info_text = render_loaded_graph(0, loaded_data)
|
| 262 |
+
return img_path, info_text, gr.update(interactive=True), gr.update(interactive=True), loaded_data, 0
|
| 263 |
+
|
| 264 |
+
def change_loaded_graph(direction, current_idx, loaded_data):
|
| 265 |
+
if not loaded_data:
|
| 266 |
+
return None, "No data.", gr.update(), gr.update(), current_idx
|
| 267 |
+
|
| 268 |
+
new_idx = current_idx + direction
|
| 269 |
+
if new_idx < 0: new_idx = len(loaded_data) - 1
|
| 270 |
+
if new_idx >= len(loaded_data): new_idx = 0
|
| 271 |
+
|
| 272 |
+
img_path, info_text = render_loaded_graph(new_idx, loaded_data)
|
| 273 |
+
return img_path, info_text, gr.update(interactive=True), gr.update(interactive=True), new_idx
|
| 274 |
+
|
| 275 |
+
def render_loaded_graph(idx, loaded_data):
|
| 276 |
+
data = loaded_data[idx]
|
| 277 |
+
G = data["graph"]
|
| 278 |
+
w = data["width"]
|
| 279 |
+
h = data["height"]
|
| 280 |
+
name = data["name"]
|
| 281 |
+
img_path = plot_graph_to_image(G, w, h, title=f"Loaded: {name}", save_dir=TEMP_DIR)
|
| 282 |
+
info_text = f"**Viewing {idx + 1} of {len(loaded_data)}**\n\nFile: `{name}`\nNodes: {len(G.nodes())} | Edges: {len(G.edges())}"
|
| 283 |
+
return img_path, info_text
|
| 284 |
+
|
| 285 |
+
# ==========================================
|
| 286 |
+
# 5. GRADIO UI LAYOUT
|
| 287 |
+
# ==========================================
|
| 288 |
+
with gr.Blocks(title="Interactive Network Generator") as demo:
|
| 289 |
+
state = gr.State({"edge_start": None})
|
| 290 |
+
load_state = gr.State([])
|
| 291 |
+
load_idx = gr.State(0)
|
| 292 |
+
|
| 293 |
+
gr.Markdown("# Interactive Network Generator")
|
| 294 |
+
|
| 295 |
+
with gr.Tabs():
|
| 296 |
+
with gr.Tab("Generate & Edit"):
|
| 297 |
+
with gr.Row():
|
| 298 |
+
with gr.Column(scale=1):
|
| 299 |
+
gr.Markdown("### 1. Configuration")
|
| 300 |
+
topology = gr.Dropdown(["highly_connected", "bottlenecks", "linear"], value="highly_connected", label="Topology")
|
| 301 |
+
preset = gr.Radio(["Small", "Medium", "Large", "Custom"], value="Medium", label="Preset")
|
| 302 |
+
|
| 303 |
+
with gr.Row():
|
| 304 |
+
width = gr.Number(8, label="Grid Width", interactive=False, precision=0)
|
| 305 |
+
height = gr.Number(8, label="Grid Height", interactive=False, precision=0)
|
| 306 |
+
|
| 307 |
+
with gr.Group():
|
| 308 |
+
variant = gr.Dropdown(["Fixed", "Custom"], value="Fixed", label="Variant", info="Custom unlocks Overrides.")
|
| 309 |
+
void_frac = gr.Slider(0.0, 0.9, 0.35, step=0.05, label="Void Fraction (Controls Nodes)", interactive=False)
|
| 310 |
+
t_edges = gr.Slider(0, 800, 0, step=1, label="Target Edges (0 = Auto)", interactive=False)
|
| 311 |
+
capacity_info = gr.Markdown("Active Grid Capacity: N/A")
|
| 312 |
+
|
| 313 |
+
gen_btn = gr.Button("Generate Network", variant="primary")
|
| 314 |
+
with gr.Row():
|
| 315 |
+
save_json_btn = gr.Button("Download JSON", interactive=False)
|
| 316 |
+
save_vis_btn = gr.Button("💾 Save Visual Locally", interactive=False)
|
| 317 |
+
save_msg = gr.Markdown()
|
| 318 |
+
json_file = gr.File(label="Saved JSON", visible=False)
|
| 319 |
+
|
| 320 |
+
with gr.Column(scale=2):
|
| 321 |
+
metrics = gr.Markdown("Ready to generate.")
|
| 322 |
+
click_mode = gr.Radio(["Add/Remove Node", "Add/Remove Edge"], value="Add/Remove Node", label="Mouse Interaction Mode",
|
| 323 |
+
info="For Edges: Click Node 1, then Node 2. Click empty space to cancel selection.")
|
| 324 |
+
plot_img = gr.Image(label="Interactive Graph", interactive=False, height=800, width=800)
|
| 325 |
+
|
| 326 |
+
with gr.Tab("Batch Export"):
|
| 327 |
+
gr.Markdown(f"Generates multiple JSON files into a single ZIP. Automatically saves to your `{ZIP_DIR}/` directory.")
|
| 328 |
+
with gr.Row():
|
| 329 |
+
with gr.Column():
|
| 330 |
+
batch_count = gr.Slider(1, 50, 5, step=1, label="Generation Count")
|
| 331 |
+
with gr.Group():
|
| 332 |
+
gr.Markdown("### Range Controls (Custom Variant Only)")
|
| 333 |
+
with gr.Row():
|
| 334 |
+
b_min_void = gr.Slider(0.0, 0.9, 0.1, step=0.05, label="Min Void Fraction")
|
| 335 |
+
b_max_void = gr.Slider(0.0, 0.9, 0.6, step=0.05, label="Max Void Fraction")
|
| 336 |
+
with gr.Row():
|
| 337 |
+
b_min_edges = gr.Number(10, label="Min Target Edges", precision=0)
|
| 338 |
+
b_max_edges = gr.Number(100, label="Max Target Edges", precision=0)
|
| 339 |
+
batch_btn = gr.Button("Generate Batch ZIP", variant="primary")
|
| 340 |
+
file_out = gr.File(label="Download ZIP")
|
| 341 |
+
|
| 342 |
+
with gr.Tab("Load & View JSON"):
|
| 343 |
+
gr.Markdown("Upload JSON/ZIP files or choose a previously generated local ZIP from the dropdown.")
|
| 344 |
+
with gr.Row():
|
| 345 |
+
with gr.Column(scale=1):
|
| 346 |
+
upload_files = gr.File(label="Upload JSON(s) or ZIP(s)", file_count="multiple", file_types=[".json", ".zip"])
|
| 347 |
+
gr.Markdown("---")
|
| 348 |
+
with gr.Row():
|
| 349 |
+
local_zips = gr.Dropdown(choices=get_local_zips(), label="Select a local ZIP", interactive=True)
|
| 350 |
+
refresh_zip_btn = gr.Button("🔄 Refresh List")
|
| 351 |
+
gr.Markdown("---")
|
| 352 |
+
with gr.Row():
|
| 353 |
+
btn_prev = gr.Button("⬅️ Prev", interactive=False)
|
| 354 |
+
btn_next = gr.Button("Next ➡️", interactive=False)
|
| 355 |
+
load_info = gr.Markdown("No files loaded.")
|
| 356 |
+
with gr.Column(scale=2):
|
| 357 |
+
load_plot = gr.Image(label="Loaded Graph", interactive=False, height=800, width=800)
|
| 358 |
+
|
| 359 |
+
# EVENTS
|
| 360 |
+
inputs_dims = [preset, topology]
|
| 361 |
+
preset.change(get_preset_dims, inputs_dims, [width, height])
|
| 362 |
+
topology.change(get_preset_dims, inputs_dims, [width, height])
|
| 363 |
+
|
| 364 |
+
inputs_var = [variant, width, height, topology, void_frac]
|
| 365 |
+
variant.change(update_ui_for_variant, inputs_var, [void_frac, t_edges, capacity_info])
|
| 366 |
+
width.change(update_ui_for_variant, inputs_var, [void_frac, t_edges, capacity_info])
|
| 367 |
+
height.change(update_ui_for_variant, inputs_var, [void_frac, t_edges, capacity_info])
|
| 368 |
+
topology.change(update_ui_for_variant, inputs_var, [void_frac, t_edges, capacity_info])
|
| 369 |
+
void_frac.change(update_ui_for_variant, inputs_var, [void_frac, t_edges, capacity_info])
|
| 370 |
+
|
| 371 |
+
gen_args = [topology, preset, width, height, variant, void_frac, t_edges]
|
| 372 |
+
gen_btn.click(generate_and_store, gen_args, [plot_img, metrics, state, save_json_btn])
|
| 373 |
+
plot_img.select(handle_plot_click, [click_mode, state], [plot_img, metrics, state])
|
| 374 |
+
|
| 375 |
+
save_json_btn.click(save_single_json_action, [state], [json_file]).then(lambda: gr.update(visible=True), None, [json_file])
|
| 376 |
+
save_vis_btn.click(save_permanent_visual, [state], [save_msg])
|
| 377 |
+
|
| 378 |
+
batch_args = [batch_count, topology, width, height, variant, b_min_void, b_max_void, b_min_edges, b_max_edges]
|
| 379 |
+
batch_btn.click(run_batch_generation, batch_args, [file_out, local_zips])
|
| 380 |
+
|
| 381 |
+
upload_files.upload(process_uploaded_files, [upload_files], [load_plot, load_info, btn_prev, btn_next, load_state, load_idx])
|
| 382 |
+
refresh_zip_btn.click(lambda: gr.update(choices=get_local_zips()), None, [local_zips])
|
| 383 |
+
local_zips.change(process_local_zip_selection, [local_zips], [load_plot, load_info, btn_prev, btn_next, load_state, load_idx])
|
| 384 |
+
btn_prev.click(lambda idx, data: change_loaded_graph(-1, idx, data), [load_idx, load_state], [load_plot, load_info, btn_prev, btn_next, load_idx])
|
| 385 |
+
btn_next.click(lambda idx, data: change_loaded_graph(1, idx, data), [load_idx, load_state], [load_plot, load_info, btn_prev, btn_next, load_idx])
|
| 386 |
+
|
| 387 |
+
if __name__ == "__main__":
|
| 388 |
+
demo.launch()
|
dataset/renovation_data.npz
ADDED
|
Binary file (4.17 kB). View file
|
|
|
json_handler.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import random
|
| 3 |
+
import networkx as nx
|
| 4 |
+
import os
|
| 5 |
+
from visualizer import get_sorted_nodes
|
| 6 |
+
|
| 7 |
+
def prepare_edges_for_json(G):
|
| 8 |
+
nodes_list = get_sorted_nodes(G)
|
| 9 |
+
nodes_list_dict = {str(i+1): node for i, node in enumerate(nodes_list)}
|
| 10 |
+
coord_to_id = {v: k for k, v in nodes_list_dict.items()}
|
| 11 |
+
edges_formatted = []
|
| 12 |
+
for u, v in G.edges():
|
| 13 |
+
if u in coord_to_id and v in coord_to_id:
|
| 14 |
+
edges_formatted.append({"room1": coord_to_id[u], "room2": coord_to_id[v]})
|
| 15 |
+
return edges_formatted, list(nodes_list_dict.keys()), nodes_list_dict
|
| 16 |
+
|
| 17 |
+
def prepare_parameter_for_json(G, I, nodes_list_dict):
|
| 18 |
+
n_count = len(G.nodes())
|
| 19 |
+
if n_count == 0:
|
| 20 |
+
return [], [], [], [], [], [], [], [], [], []
|
| 21 |
+
|
| 22 |
+
weights = [n_count / (n_count * (1 + (((i + 1) * 2) / 30))) for i in range(n_count)]
|
| 23 |
+
m_weights = random.choices(I, weights=weights, k=5)
|
| 24 |
+
t_weights_probs = [n_count / (n_count * (1 + (((i + 1) * 2) / 5))) for i in range(10)]
|
| 25 |
+
t_weights = random.choices(range(1, 11), weights=t_weights_probs, k=5)
|
| 26 |
+
|
| 27 |
+
dismantled, conditioningDuration, assignment, help_list = [], [], [], []
|
| 28 |
+
|
| 29 |
+
for m in range(5):
|
| 30 |
+
dismantled.append({"m": str(m + 1), "i": str(m_weights[m]), "t": t_weights[m], "value": 1})
|
| 31 |
+
conditioningDuration.append({"m": str(m + 1), "value": 1})
|
| 32 |
+
x = random.randint(1, 3)
|
| 33 |
+
if m > 2:
|
| 34 |
+
if 1 not in help_list: x = 1
|
| 35 |
+
if 2 not in help_list: x = 2
|
| 36 |
+
if 3 not in help_list: x = 3
|
| 37 |
+
help_list.append(x)
|
| 38 |
+
assignment.append({"m": str(m + 1), "r": str(x), "value": 1})
|
| 39 |
+
|
| 40 |
+
t_weights_del = random.choices(range(1, 11), weights=t_weights_probs[:10], k=3)
|
| 41 |
+
delivered = [{"r": str(r+1), "i": "1", "t": t_weights_del[r], "value": 1} for r in range(3)]
|
| 42 |
+
conditioningCapacity = [{"r": str(r+1), "value": 1} for r in range(3)]
|
| 43 |
+
|
| 44 |
+
CostMT, CostMB, CostRT, CostRB, Coord = [], [], [], [], []
|
| 45 |
+
for i in range(n_count):
|
| 46 |
+
s_id = str(i + 1)
|
| 47 |
+
CostMT.append({"i": s_id, "value": random.choice([2, 5])})
|
| 48 |
+
CostMB.append({"i": s_id, "value": random.choice([5, 10, 30])})
|
| 49 |
+
CostRT.append({"i": s_id, "value": random.choice([4, 10])})
|
| 50 |
+
CostRB.append({"i": s_id, "value": 1000 if i==0 else random.choice([20, 30, 100])})
|
| 51 |
+
if s_id in nodes_list_dict:
|
| 52 |
+
Coord.append({"i": s_id, "Coordinates": nodes_list_dict[s_id]})
|
| 53 |
+
|
| 54 |
+
return dismantled, assignment, delivered, conditioningCapacity, conditioningDuration, CostMT, CostMB, CostRT, CostRB, Coord
|
| 55 |
+
|
| 56 |
+
def generate_full_json_dict(G, loop=0):
|
| 57 |
+
edges, I, nodes_list_dict = prepare_edges_for_json(G)
|
| 58 |
+
dismantled, assignment, delivered, condCap, condDur, CostMT, CostMB, CostRT, CostRB, Coord = prepare_parameter_for_json(G, I, nodes_list_dict)
|
| 59 |
+
sets = {
|
| 60 |
+
"I": I, "E": {"bidirectional": True, "seed": 1, "edges": edges},
|
| 61 |
+
"M": ["1", "2", "3", "4", "5"], "R": ["1", "2", "3"]
|
| 62 |
+
}
|
| 63 |
+
params = {
|
| 64 |
+
"defaults": { "V": 1000, "CostMB": 100, "CostMT": 20, "CostRB": 300, "CostRT": 50 },
|
| 65 |
+
"t_max": 100, "V": [{"m": "1", "i": "1", "value": 42}],
|
| 66 |
+
"dismantled": dismantled, "delivered": delivered,
|
| 67 |
+
"conditioningCapacity": condCap, "conditioningDuration": condDur,
|
| 68 |
+
"assignment": assignment, "Coord": Coord,
|
| 69 |
+
"CostMT": CostMT, "CostMB": CostMB, "CostRT": CostRT, "CostRB": CostRB, "CostZR": 9, "CostZH": 5
|
| 70 |
+
}
|
| 71 |
+
return {"description": "Generated by Gradio", "sets": sets, "params": params}
|
| 72 |
+
|
| 73 |
+
def load_graph_from_data(data, name):
|
| 74 |
+
"""Core function to parse loaded JSON data into a NetworkX graph."""
|
| 75 |
+
G = nx.Graph()
|
| 76 |
+
id_to_coord = {}
|
| 77 |
+
|
| 78 |
+
if "params" in data and "Coord" in data["params"]:
|
| 79 |
+
for item in data["params"]["Coord"]:
|
| 80 |
+
coord = tuple(item["Coordinates"])
|
| 81 |
+
id_to_coord[item["i"]] = coord
|
| 82 |
+
G.add_node(coord)
|
| 83 |
+
|
| 84 |
+
if "sets" in data and "E" in data["sets"] and "edges" in data["sets"]["E"]:
|
| 85 |
+
for edge in data["sets"]["E"]["edges"]:
|
| 86 |
+
r1 = edge["room1"]
|
| 87 |
+
r2 = edge["room2"]
|
| 88 |
+
if r1 in id_to_coord and r2 in id_to_coord:
|
| 89 |
+
G.add_edge(id_to_coord[r1], id_to_coord[r2])
|
| 90 |
+
|
| 91 |
+
width = max([n[0] for n in G.nodes()]) if len(G.nodes()) > 0 else 10
|
| 92 |
+
height = max([n[1] for n in G.nodes()]) if len(G.nodes()) > 0 else 10
|
| 93 |
+
width = int(width + max(2, width * 0.1))
|
| 94 |
+
height = int(height + max(2, height * 0.1))
|
| 95 |
+
|
| 96 |
+
return {"name": name, "graph": G, "width": width, "height": height}
|
| 97 |
+
|
| 98 |
+
def load_graph_from_json(filepath):
|
| 99 |
+
with open(filepath, 'r') as f:
|
| 100 |
+
data = json.load(f)
|
| 101 |
+
return load_graph_from_data(data, os.path.basename(filepath))
|
network_generator.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import networkx as nx
|
| 2 |
+
import random
|
| 3 |
+
from visualizer import get_sorted_nodes
|
| 4 |
+
|
| 5 |
+
def validate_topology(G, topology):
|
| 6 |
+
n = len(G.nodes())
|
| 7 |
+
e = len(G.edges())
|
| 8 |
+
if n < 3: return True, "Graph too small for strict validation."
|
| 9 |
+
|
| 10 |
+
avg_deg = (2.0 * e) / n
|
| 11 |
+
|
| 12 |
+
if topology == "highly_connected":
|
| 13 |
+
if avg_deg < 2.5:
|
| 14 |
+
return False, f"Graph is sparse (Avg Degree: {avg_deg:.1f}) for 'Highly Connected'. Add more target edges."
|
| 15 |
+
|
| 16 |
+
elif topology == "bottlenecks":
|
| 17 |
+
bridges = list(nx.bridges(G))
|
| 18 |
+
if len(bridges) == 0 and avg_deg > 3.0:
|
| 19 |
+
return False, "Graph lacks distinct bottleneck links (bridges) and is too dense. Reduce target edges."
|
| 20 |
+
|
| 21 |
+
elif topology == "linear":
|
| 22 |
+
max_deg = max([d for n, d in G.degree()]) if len(G.nodes()) > 0 else 0
|
| 23 |
+
if max_deg > 4 or avg_deg > 2.5:
|
| 24 |
+
return False, f"Graph contains hub nodes (Max Degree: {max_deg}) or is too dense for 'Linear'. Reduce edges."
|
| 25 |
+
|
| 26 |
+
return True, "Topology matches definition."
|
| 27 |
+
|
| 28 |
+
class NetworkGenerator:
|
| 29 |
+
def __init__(self, width=10, height=10, variant="F", topology="highly_connected",
|
| 30 |
+
node_drop_fraction=0.1, target_edges=0,
|
| 31 |
+
bottleneck_cluster_count=None, bottleneck_edges_per_link=1):
|
| 32 |
+
|
| 33 |
+
self.variant = variant.upper()
|
| 34 |
+
self.topology = topology.lower()
|
| 35 |
+
self.width = int(width)
|
| 36 |
+
self.height = int(height)
|
| 37 |
+
|
| 38 |
+
self.node_drop_fraction = float(node_drop_fraction)
|
| 39 |
+
self.target_edges = int(target_edges)
|
| 40 |
+
self.node_factor = 0.4
|
| 41 |
+
|
| 42 |
+
if bottleneck_cluster_count is None:
|
| 43 |
+
area = self.width * self.height
|
| 44 |
+
self.bottleneck_cluster_count = max(2, int(area / 18))
|
| 45 |
+
else:
|
| 46 |
+
self.bottleneck_cluster_count = int(bottleneck_cluster_count)
|
| 47 |
+
|
| 48 |
+
self.bottleneck_edges_per_link = int(bottleneck_edges_per_link)
|
| 49 |
+
self.graph = None
|
| 50 |
+
self.active_positions = None
|
| 51 |
+
|
| 52 |
+
# def calculate_defaults(self):
|
| 53 |
+
# total_possible = (self.width + 1) * (self.height + 1)
|
| 54 |
+
# scale = {"highly_connected": 1.2, "bottlenecks": 0.85, "linear": 0.75}.get(self.topology, 1.0)
|
| 55 |
+
|
| 56 |
+
# if self.topology == "highly_connected": vf = max(0.0, self.node_drop_fraction * 0.8)
|
| 57 |
+
# elif self.topology == "linear": vf = min(0.95, self.node_drop_fraction * 1.2)
|
| 58 |
+
# else: vf = self.node_drop_fraction
|
| 59 |
+
|
| 60 |
+
# est_nodes = int(self.node_factor * scale * total_possible * (1.0 - vf))
|
| 61 |
+
|
| 62 |
+
# if self.topology == "highly_connected": est_edges = int(3.5 * est_nodes)
|
| 63 |
+
# elif self.topology == "bottlenecks": est_edges = int(1.8 * est_nodes)
|
| 64 |
+
# else: est_edges = int(1.5 * est_nodes)
|
| 65 |
+
|
| 66 |
+
# return est_nodes, est_edges
|
| 67 |
+
|
| 68 |
+
def calculate_defaults(self):
|
| 69 |
+
total_possible = self.width * self.height
|
| 70 |
+
scale = {"highly_connected": 1.2, "bottlenecks": 0.85, "linear": 0.75}.get(self.topology, 1.0)
|
| 71 |
+
|
| 72 |
+
# NEW: Use the unified fraction method we just updated above
|
| 73 |
+
vf = self._effective_node_drop_fraction()
|
| 74 |
+
|
| 75 |
+
est_nodes = int(self.node_factor * scale * total_possible * (1.0 - vf))
|
| 76 |
+
if self.topology == "highly_connected": est_edges = int(3.5 * est_nodes)
|
| 77 |
+
elif self.topology == "bottlenecks": est_edges = int(1.8 * est_nodes)
|
| 78 |
+
else: est_edges = int(1.5 * est_nodes)
|
| 79 |
+
return est_nodes, est_edges
|
| 80 |
+
|
| 81 |
+
def calculate_max_capacity(self):
|
| 82 |
+
"""Estimates max possible edges for planar-like spatial graph."""
|
| 83 |
+
total_possible_nodes = int(self.width * self.height * (1.0 - self.node_drop_fraction))
|
| 84 |
+
if self.topology == "highly_connected":
|
| 85 |
+
return int(total_possible_nodes * 4.5)
|
| 86 |
+
return int(total_possible_nodes * 3.0)
|
| 87 |
+
|
| 88 |
+
def generate(self):
|
| 89 |
+
max_attempts = 15
|
| 90 |
+
for attempt in range(max_attempts):
|
| 91 |
+
self._build_node_mask()
|
| 92 |
+
self._initialize_graph()
|
| 93 |
+
self._add_nodes()
|
| 94 |
+
|
| 95 |
+
nodes = list(self.graph.nodes())
|
| 96 |
+
if len(nodes) < 2: continue
|
| 97 |
+
|
| 98 |
+
if self.topology == "bottlenecks":
|
| 99 |
+
self._build_bottleneck_clusters(nodes)
|
| 100 |
+
else:
|
| 101 |
+
self._connect_all_nodes_by_nearby_growth(nodes)
|
| 102 |
+
self._add_edges()
|
| 103 |
+
|
| 104 |
+
self._remove_intersections()
|
| 105 |
+
|
| 106 |
+
if self.target_edges > 0:
|
| 107 |
+
self._adjust_edges_to_target()
|
| 108 |
+
else:
|
| 109 |
+
self._enforce_edge_budget()
|
| 110 |
+
|
| 111 |
+
if not nx.is_connected(self.graph):
|
| 112 |
+
self._force_connect_components()
|
| 113 |
+
|
| 114 |
+
self._remove_intersections()
|
| 115 |
+
|
| 116 |
+
if nx.is_connected(self.graph):
|
| 117 |
+
return self.graph
|
| 118 |
+
|
| 119 |
+
raise RuntimeError("Failed to generate valid network. Loosen overrides.")
|
| 120 |
+
|
| 121 |
+
# def _effective_node_drop_fraction(self):
|
| 122 |
+
# base = self.node_drop_fraction
|
| 123 |
+
# if self.topology == "highly_connected": return max(0.0, base * 0.8)
|
| 124 |
+
# if self.topology == "linear": return min(0.95, base * 1.2)
|
| 125 |
+
# return base
|
| 126 |
+
|
| 127 |
+
def _effective_node_drop_fraction(self):
|
| 128 |
+
base = self.node_drop_fraction
|
| 129 |
+
|
| 130 |
+
# Fix: app.py passes "R" when the "Custom" variant is selected
|
| 131 |
+
if self.variant == "R":
|
| 132 |
+
return base
|
| 133 |
+
|
| 134 |
+
# Safety net for 'Fixed' ("F") presets
|
| 135 |
+
if self.topology == "highly_connected": return max(0.0, base * 0.8)
|
| 136 |
+
if self.topology == "linear": return min(0.95, base * 1.2)
|
| 137 |
+
return base
|
| 138 |
+
|
| 139 |
+
def _build_node_mask(self):
|
| 140 |
+
all_positions = [(x, y) for x in range(self.width) for y in range(self.height)]
|
| 141 |
+
drop_frac = self._effective_node_drop_fraction()
|
| 142 |
+
drop = int(drop_frac * len(all_positions))
|
| 143 |
+
deactivated = set(random.sample(all_positions, drop)) if drop > 0 else set()
|
| 144 |
+
self.active_positions = set(all_positions) - deactivated
|
| 145 |
+
|
| 146 |
+
def _initialize_graph(self):
|
| 147 |
+
self.graph = nx.Graph()
|
| 148 |
+
margin_x = max(1, self.width // 4)
|
| 149 |
+
margin_y = max(1, self.height // 4)
|
| 150 |
+
low_x, high_x = margin_x, self.width - 1 - margin_x
|
| 151 |
+
low_y, high_y = margin_y, self.height - 1 - margin_y
|
| 152 |
+
|
| 153 |
+
if low_x > high_x: low_x, high_x = 0, self.width - 1
|
| 154 |
+
if low_y > high_y: low_y, high_y = 0, self.height - 1
|
| 155 |
+
|
| 156 |
+
middle_active = [p for p in self.active_positions if low_x <= p[0] <= high_x and low_y <= p[1] <= high_y]
|
| 157 |
+
|
| 158 |
+
if middle_active: seed = random.choice(middle_active)
|
| 159 |
+
elif self.active_positions: seed = random.choice(list(self.active_positions))
|
| 160 |
+
else: return
|
| 161 |
+
self.graph.add_node(tuple(seed))
|
| 162 |
+
|
| 163 |
+
def _add_nodes(self):
|
| 164 |
+
for n in self.active_positions:
|
| 165 |
+
if not self.graph.has_node(n):
|
| 166 |
+
self.graph.add_node(n)
|
| 167 |
+
|
| 168 |
+
def _connect_all_nodes_by_nearby_growth(self, nodes):
|
| 169 |
+
connected = set()
|
| 170 |
+
remaining = set(nodes)
|
| 171 |
+
if not remaining: return
|
| 172 |
+
current = random.choice(nodes)
|
| 173 |
+
connected.add(current)
|
| 174 |
+
remaining.remove(current)
|
| 175 |
+
|
| 176 |
+
while remaining:
|
| 177 |
+
candidates = []
|
| 178 |
+
for n in remaining:
|
| 179 |
+
closest_dist = min([abs(n[0]-c[0]) + abs(n[1]-c[1]) for c in connected])
|
| 180 |
+
if closest_dist <= 4:
|
| 181 |
+
candidates.append(n)
|
| 182 |
+
|
| 183 |
+
if not candidates:
|
| 184 |
+
best_n = min(remaining, key=lambda r: min(abs(r[0]-c[0]) + abs(r[1]-c[1]) for c in connected))
|
| 185 |
+
candidates.append(best_n)
|
| 186 |
+
|
| 187 |
+
candidate = random.choice(candidates)
|
| 188 |
+
neighbors = sorted(list(connected), key=lambda c: abs(c[0]-candidate[0]) + abs(c[1]-candidate[1]))
|
| 189 |
+
for n in neighbors[:3]:
|
| 190 |
+
if not self._would_create_intersection(n, candidate):
|
| 191 |
+
self.graph.add_edge(n, candidate)
|
| 192 |
+
break
|
| 193 |
+
else:
|
| 194 |
+
self.graph.add_edge(neighbors[0], candidate)
|
| 195 |
+
|
| 196 |
+
connected.add(candidate)
|
| 197 |
+
remaining.remove(candidate)
|
| 198 |
+
|
| 199 |
+
def _compute_edge_count(self):
|
| 200 |
+
if self.target_edges > 0: return self.target_edges
|
| 201 |
+
n = len(self.graph.nodes())
|
| 202 |
+
if self.topology == "highly_connected": return int(3.5 * n)
|
| 203 |
+
if self.topology == "bottlenecks": return int(1.8 * n)
|
| 204 |
+
return int(random.uniform(1.2, 2.0) * n)
|
| 205 |
+
|
| 206 |
+
def _add_edges(self):
|
| 207 |
+
nodes = list(self.graph.nodes())
|
| 208 |
+
if self.topology == "highly_connected": self._add_cluster_dense(nodes, self._compute_edge_count())
|
| 209 |
+
elif self.topology == "linear": self._make_linear(nodes)
|
| 210 |
+
|
| 211 |
+
def _make_linear(self, nodes):
|
| 212 |
+
nodes_sorted = sorted(nodes, key=lambda x: (x[0], x[1]))
|
| 213 |
+
if not nodes_sorted: return
|
| 214 |
+
prev = nodes_sorted[0]
|
| 215 |
+
for nxt in nodes_sorted[1:]:
|
| 216 |
+
if not self._would_create_intersection(prev, nxt): self.graph.add_edge(prev, nxt)
|
| 217 |
+
prev = nxt
|
| 218 |
+
|
| 219 |
+
def _add_cluster_dense(self, nodes, max_edges):
|
| 220 |
+
edges_added = 0
|
| 221 |
+
nodes = list(nodes)
|
| 222 |
+
random.shuffle(nodes)
|
| 223 |
+
dist_limit = 10 if self.target_edges > 0 else 4
|
| 224 |
+
|
| 225 |
+
for i in range(len(nodes)):
|
| 226 |
+
for j in range(i + 1, len(nodes)):
|
| 227 |
+
if self.target_edges == 0 and edges_added >= max_edges: return
|
| 228 |
+
n1, n2 = nodes[i], nodes[j]
|
| 229 |
+
dist = max(abs(n1[0]-n2[0]), abs(n1[1]-n2[1]))
|
| 230 |
+
if dist <= dist_limit:
|
| 231 |
+
if not self._would_create_intersection(n1, n2):
|
| 232 |
+
self.graph.add_edge(n1, n2)
|
| 233 |
+
edges_added += 1
|
| 234 |
+
|
| 235 |
+
def _build_bottleneck_clusters(self, nodes):
|
| 236 |
+
self.graph.remove_edges_from(list(self.graph.edges()))
|
| 237 |
+
clusters, centers = self._spatial_cluster_nodes(nodes, k=self.bottleneck_cluster_count)
|
| 238 |
+
for cluster in clusters:
|
| 239 |
+
if len(cluster) < 2: continue
|
| 240 |
+
# FIX: Call main connectivity directly
|
| 241 |
+
self._connect_all_nodes_by_nearby_growth(cluster)
|
| 242 |
+
self._add_cluster_dense(list(cluster), max_edges=max(1, int(3.5 * len(cluster))))
|
| 243 |
+
|
| 244 |
+
order = sorted(range(len(clusters)), key=lambda i: (centers[i][0], centers[i][1]))
|
| 245 |
+
for a_idx, b_idx in zip(order[:-1], order[1:]):
|
| 246 |
+
self._add_bottleneck_links(clusters[a_idx], clusters[b_idx], self.bottleneck_edges_per_link)
|
| 247 |
+
|
| 248 |
+
if not nx.is_connected(self.graph): self._force_connect_components()
|
| 249 |
+
|
| 250 |
+
def _force_connect_components(self):
|
| 251 |
+
components = list(nx.connected_components(self.graph))
|
| 252 |
+
while len(components) > 1:
|
| 253 |
+
c1, c2 = list(components[0]), list(components[1])
|
| 254 |
+
best_pair, min_dist = None, float('inf')
|
| 255 |
+
s1 = c1 if len(c1)<30 else random.sample(c1, 30)
|
| 256 |
+
s2 = c2 if len(c2)<30 else random.sample(c2, 30)
|
| 257 |
+
for u in s1:
|
| 258 |
+
for v in s2:
|
| 259 |
+
d = (u[0]-v[0])**2 + (u[1]-v[1])**2
|
| 260 |
+
if d < min_dist and not self._would_create_intersection(u, v):
|
| 261 |
+
min_dist, best_pair = d, (u, v)
|
| 262 |
+
if best_pair: self.graph.add_edge(best_pair[0], best_pair[1])
|
| 263 |
+
else: break
|
| 264 |
+
prev_len = len(components)
|
| 265 |
+
components = list(nx.connected_components(self.graph))
|
| 266 |
+
if len(components) == prev_len: break
|
| 267 |
+
|
| 268 |
+
def _spatial_cluster_nodes(self, nodes, k):
|
| 269 |
+
nodes = list(nodes)
|
| 270 |
+
if k >= len(nodes): return [[n] for n in nodes], nodes[:]
|
| 271 |
+
centers = random.sample(nodes, k)
|
| 272 |
+
clusters = [[] for _ in range(k)]
|
| 273 |
+
for n in nodes:
|
| 274 |
+
best_i = min(range(k), key=lambda i: max(abs(n[0]-centers[i][0]), abs(n[1]-centers[i][1])))
|
| 275 |
+
clusters[best_i].append(n)
|
| 276 |
+
return clusters, centers
|
| 277 |
+
|
| 278 |
+
def _add_bottleneck_links(self, cluster_a, cluster_b, m):
|
| 279 |
+
pairs = []
|
| 280 |
+
for u in cluster_a:
|
| 281 |
+
for v in cluster_b:
|
| 282 |
+
dist = max(abs(u[0]-v[0]), abs(u[1]-v[1]))
|
| 283 |
+
pairs.append((dist, u, v))
|
| 284 |
+
pairs.sort(key=lambda t: t[0])
|
| 285 |
+
added = 0
|
| 286 |
+
for _, u, v in pairs:
|
| 287 |
+
if added >= m: break
|
| 288 |
+
if not self.graph.has_edge(u, v) and not self._would_create_intersection(u, v):
|
| 289 |
+
self.graph.add_edge(u, v)
|
| 290 |
+
added += 1
|
| 291 |
+
|
| 292 |
+
def _remove_intersections(self):
|
| 293 |
+
pass_no = 0
|
| 294 |
+
while pass_no < 5:
|
| 295 |
+
pass_no += 1
|
| 296 |
+
edges = list(self.graph.edges())
|
| 297 |
+
intersections = []
|
| 298 |
+
check_edges = random.sample(edges, 400) if len(edges) > 600 else edges
|
| 299 |
+
for i in range(len(check_edges)):
|
| 300 |
+
for j in range(i+1, len(check_edges)):
|
| 301 |
+
e1, e2 = check_edges[i], check_edges[j]
|
| 302 |
+
if self._segments_intersect(e1[0], e1[1], e2[0], e2[1]): intersections.append((e1, e2))
|
| 303 |
+
if not intersections: break
|
| 304 |
+
for e1, e2 in intersections:
|
| 305 |
+
if not self.graph.has_edge(*e1) or not self.graph.has_edge(*e2): continue
|
| 306 |
+
l1 = (e1[0][0]-e1[1][0])**2 + (e1[0][1]-e1[1][1])**2
|
| 307 |
+
l2 = (e2[0][0]-e2[1][0])**2 + (e2[0][1]-e2[1][1])**2
|
| 308 |
+
rem = e1 if l1 > l2 else e2
|
| 309 |
+
self.graph.remove_edge(*rem)
|
| 310 |
+
|
| 311 |
+
def _adjust_edges_to_target(self):
|
| 312 |
+
current_edges = list(self.graph.edges())
|
| 313 |
+
curr_count = len(current_edges)
|
| 314 |
+
if curr_count > self.target_edges:
|
| 315 |
+
to_remove = curr_count - self.target_edges
|
| 316 |
+
sorted_edges = sorted(current_edges, key=lambda e: (e[0][0]-e[1][0])**2 + (e[0][1]-e[1][1])**2, reverse=True)
|
| 317 |
+
for e in sorted_edges:
|
| 318 |
+
if len(self.graph.edges()) <= self.target_edges: break
|
| 319 |
+
self.graph.remove_edge(*e)
|
| 320 |
+
if not nx.is_connected(self.graph): self.graph.add_edge(*e)
|
| 321 |
+
elif curr_count < self.target_edges:
|
| 322 |
+
needed = self.target_edges - curr_count
|
| 323 |
+
nodes = list(self.graph.nodes())
|
| 324 |
+
attempts = 0
|
| 325 |
+
while len(self.graph.edges()) < self.target_edges and attempts < (needed * 30):
|
| 326 |
+
attempts += 1
|
| 327 |
+
u = random.choice(nodes)
|
| 328 |
+
candidates = sorted(nodes, key=lambda n: (n[0]-u[0])**2 + (n[1]-u[1])**2)
|
| 329 |
+
if len(candidates) < 2: continue
|
| 330 |
+
v = random.choice(candidates[1:min(len(candidates), 10)])
|
| 331 |
+
if not self.graph.has_edge(u, v) and not self._would_create_intersection(u, v):
|
| 332 |
+
self.graph.add_edge(u, v)
|
| 333 |
+
|
| 334 |
+
def _enforce_edge_budget(self):
|
| 335 |
+
budget = self._compute_edge_count()
|
| 336 |
+
while len(self.graph.edges()) > budget:
|
| 337 |
+
edges = list(self.graph.edges())
|
| 338 |
+
rem = random.choice(edges)
|
| 339 |
+
self.graph.remove_edge(*rem)
|
| 340 |
+
if not nx.is_connected(self.graph):
|
| 341 |
+
self.graph.add_edge(*rem)
|
| 342 |
+
break
|
| 343 |
+
|
| 344 |
+
def _segments_intersect(self, a, b, c, d):
|
| 345 |
+
if a == c or a == d or b == c or b == d: return False
|
| 346 |
+
def ccw(A,B,C): return (C[1]-A[1]) * (B[0]-A[0]) > (B[1]-A[1]) * (C[0]-A[0])
|
| 347 |
+
return ccw(a,c,d) != ccw(b,c,d) and ccw(a,b,c) != ccw(a,b,d)
|
| 348 |
+
|
| 349 |
+
def _would_create_intersection(self, u, v):
|
| 350 |
+
for a, b in self.graph.edges():
|
| 351 |
+
if u == a or u == b or v == a or v == b: continue
|
| 352 |
+
if self._segments_intersect(u, v, a, b): return True
|
| 353 |
+
return False
|
| 354 |
+
|
| 355 |
+
def _get_intersecting_edge(self, u, v):
|
| 356 |
+
for a, b in self.graph.edges():
|
| 357 |
+
if u == a or u == b or v == a or v == b: continue
|
| 358 |
+
if self._segments_intersect(u, v, a, b): return (a, b)
|
| 359 |
+
return None
|
| 360 |
+
|
| 361 |
+
def get_node_id_str(self, node):
|
| 362 |
+
sorted_nodes = get_sorted_nodes(self.graph)
|
| 363 |
+
if node in sorted_nodes:
|
| 364 |
+
return str(sorted_nodes.index(node) + 1)
|
| 365 |
+
return "?"
|
| 366 |
+
|
| 367 |
+
def manual_add_node(self, x, y):
|
| 368 |
+
x, y = int(x), int(y)
|
| 369 |
+
# FIX: Bounds check against Width-1
|
| 370 |
+
if not (0 <= x < self.width and 0 <= y < self.height): return False, "Out of bounds."
|
| 371 |
+
if self.graph.has_node((x, y)): return False, "Already exists."
|
| 372 |
+
self.graph.add_node((x, y))
|
| 373 |
+
nodes = list(self.graph.nodes())
|
| 374 |
+
if len(nodes) > 1:
|
| 375 |
+
closest = min([n for n in nodes if n != (x,y)], key=lambda n: (n[0]-x)**2 + (n[1]-y)**2)
|
| 376 |
+
if not self._would_create_intersection((x,y), closest): self.graph.add_edge((x,y), closest)
|
| 377 |
+
return True, "Node added."
|
| 378 |
+
|
| 379 |
+
def manual_delete_node(self, x, y):
|
| 380 |
+
x, y = int(x), int(y)
|
| 381 |
+
if not self.graph.has_node((x, y)): return False, "Node not found."
|
| 382 |
+
self.graph.remove_node((x, y))
|
| 383 |
+
if len(self.graph.nodes()) > 1 and not nx.is_connected(self.graph):
|
| 384 |
+
self._force_connect_components()
|
| 385 |
+
return True, "Node removed."
|
| 386 |
+
|
| 387 |
+
def manual_toggle_edge(self, u, v):
|
| 388 |
+
if self.graph.has_edge(u, v):
|
| 389 |
+
self.graph.remove_edge(u, v)
|
| 390 |
+
if not nx.is_connected(self.graph):
|
| 391 |
+
self.graph.add_edge(u, v)
|
| 392 |
+
return False, "Cannot remove edge (breaks connectivity)."
|
| 393 |
+
return True, "Edge removed."
|
| 394 |
+
else:
|
| 395 |
+
intersecting_edge = self._get_intersecting_edge(u, v)
|
| 396 |
+
if not intersecting_edge:
|
| 397 |
+
self.graph.add_edge(u, v)
|
| 398 |
+
return True, "Edge added."
|
| 399 |
+
else:
|
| 400 |
+
a, b = intersecting_edge
|
| 401 |
+
id_a = self.get_node_id_str(a)
|
| 402 |
+
id_b = self.get_node_id_str(b)
|
| 403 |
+
return False, f"Intersect with {id_a}-{id_b}."
|
preprocess.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import numpy as np
|
| 4 |
+
import networkx as nx
|
| 5 |
+
|
| 6 |
+
from network_generator import NetworkGenerator
|
| 7 |
+
from visualizer import get_sorted_nodes
|
| 8 |
+
|
| 9 |
+
def create_renovation_dataset():
|
| 10 |
+
print("Generating 10 graphs. Accepting any valid room count...")
|
| 11 |
+
|
| 12 |
+
all_distance_matrices = []
|
| 13 |
+
all_coord_matrices = []
|
| 14 |
+
|
| 15 |
+
success_count = 0
|
| 16 |
+
|
| 17 |
+
while success_count < 10:
|
| 18 |
+
sys.stdout.write('.')
|
| 19 |
+
sys.stdout.flush()
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
# We let your generator do what it does best.
|
| 23 |
+
# We keep target_edges reasonable (75) so it doesn't get stuck drawing crossing lines.
|
| 24 |
+
gen = NetworkGenerator(
|
| 25 |
+
width=8,
|
| 26 |
+
height=8,
|
| 27 |
+
variant="Custom",
|
| 28 |
+
topology="highly_connected",
|
| 29 |
+
node_drop_fraction=0.2,
|
| 30 |
+
target_edges=75
|
| 31 |
+
)
|
| 32 |
+
G = gen.generate()
|
| 33 |
+
|
| 34 |
+
sorted_nodes = get_sorted_nodes(G)
|
| 35 |
+
num_nodes = len(sorted_nodes)
|
| 36 |
+
|
| 37 |
+
# Convert to ML Matrices
|
| 38 |
+
coords = np.array(sorted_nodes, dtype=np.float32)
|
| 39 |
+
dist_matrix = np.zeros((num_nodes, num_nodes), dtype=np.float32)
|
| 40 |
+
|
| 41 |
+
for u, v in G.edges():
|
| 42 |
+
idx_u = sorted_nodes.index(u)
|
| 43 |
+
idx_v = sorted_nodes.index(v)
|
| 44 |
+
dist = np.sqrt((u[0] - v[0])**2 + (u[1] - v[1])**2)
|
| 45 |
+
dist_matrix[idx_u][idx_v] = dist
|
| 46 |
+
dist_matrix[idx_v][idx_u] = dist
|
| 47 |
+
|
| 48 |
+
all_coord_matrices.append(coords)
|
| 49 |
+
all_distance_matrices.append(dist_matrix)
|
| 50 |
+
|
| 51 |
+
success_count += 1
|
| 52 |
+
print(f"\n✅ Graph {success_count} generated with {num_nodes} rooms!")
|
| 53 |
+
|
| 54 |
+
except Exception as e:
|
| 55 |
+
# If the generator fails to find a valid layout, quietly try again
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
# Because our matrices might be slightly different sizes (e.g., 51 vs 52),
|
| 59 |
+
# we save them as 'object' arrays so NumPy doesn't complain.
|
| 60 |
+
os.makedirs("dataset", exist_ok=True)
|
| 61 |
+
np.savez_compressed(
|
| 62 |
+
"dataset/renovation_data.npz",
|
| 63 |
+
distances=np.array(all_distance_matrices, dtype=object),
|
| 64 |
+
coords=np.array(all_coord_matrices, dtype=object)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
print("\n🎉 Done! Data saved to dataset/renovation_data.npz")
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
create_renovation_dataset()
|
sandbox.ipynb
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 10,
|
| 6 |
+
"id": "b59335ea-e22a-41ac-8120-744a8ade2621",
|
| 7 |
+
"metadata": {},
|
| 8 |
+
"outputs": [
|
| 9 |
+
{
|
| 10 |
+
"name": "stdout",
|
| 11 |
+
"output_type": "stream",
|
| 12 |
+
"text": [
|
| 13 |
+
"Loading data from dataset/renovation_data.npz...\n",
|
| 14 |
+
"Loaded 10 graphs. We will solve Graph #1.\n",
|
| 15 |
+
"Graph #1 has 54 rooms.\n",
|
| 16 |
+
"Entrance Identified: Room 0 at coordinates [0. 0.]\n",
|
| 17 |
+
"Unleashing 50 ants for 100 iterations...\n",
|
| 18 |
+
"Iteration 000 | Best Route: 138 total moves\n",
|
| 19 |
+
"Iteration 010 | Best Route: 67 total moves\n",
|
| 20 |
+
"Iteration 020 | Best Route: 67 total moves\n",
|
| 21 |
+
"Iteration 030 | Best Route: 64 total moves\n",
|
| 22 |
+
"Iteration 040 | Best Route: 63 total moves\n",
|
| 23 |
+
"Iteration 050 | Best Route: 63 total moves\n",
|
| 24 |
+
"Iteration 060 | Best Route: 63 total moves\n",
|
| 25 |
+
"Iteration 070 | Best Route: 63 total moves\n",
|
| 26 |
+
"Iteration 080 | Best Route: 63 total moves\n",
|
| 27 |
+
"Iteration 090 | Best Route: 63 total moves\n",
|
| 28 |
+
"\n",
|
| 29 |
+
"✅ Simulation Complete!\n",
|
| 30 |
+
"Shortest path found covers all rooms in 63 total moves.\n",
|
| 31 |
+
"\n",
|
| 32 |
+
"🗺️ Map saved successfully! Check your folder for 'temp_visuals/optimized_directional_renovation_route.png'\n"
|
| 33 |
+
]
|
| 34 |
+
}
|
| 35 |
+
],
|
| 36 |
+
"source": [
|
| 37 |
+
"import os\n",
|
| 38 |
+
"import numpy as np\n",
|
| 39 |
+
"import matplotlib\n",
|
| 40 |
+
"matplotlib.use('Agg') \n",
|
| 41 |
+
"import matplotlib.pyplot as plt\n",
|
| 42 |
+
"from scipy.sparse.csgraph import shortest_path\n",
|
| 43 |
+
"\n",
|
| 44 |
+
"# --- NEW HELPER: Reconstructs the exact physical hallways walked ---\n",
|
| 45 |
+
"def get_physical_path(start, end, preds):\n",
|
| 46 |
+
" path = []\n",
|
| 47 |
+
" curr = end\n",
|
| 48 |
+
" while curr != start and curr >= 0:\n",
|
| 49 |
+
" path.append(curr)\n",
|
| 50 |
+
" curr = preds[start, curr]\n",
|
| 51 |
+
" path.reverse()\n",
|
| 52 |
+
" return path\n",
|
| 53 |
+
"\n",
|
| 54 |
+
"def run_ant_colony(distances, coords, n_ants=50, n_iterations=100, decay=0.1, alpha=1.0, beta=2.0):\n",
|
| 55 |
+
" n_nodes = distances.shape[0]\n",
|
| 56 |
+
" \n",
|
| 57 |
+
" # 1. The \"Smart GPS\" (NOW WITH return_predecessors=True)\n",
|
| 58 |
+
" dist_matrix_for_pathing = np.where(distances == 0, np.inf, distances)\n",
|
| 59 |
+
" np.fill_diagonal(dist_matrix_for_pathing, 0)\n",
|
| 60 |
+
" all_pairs_distances, predecessors = shortest_path(csgraph=dist_matrix_for_pathing, directed=False, return_predecessors=True)\n",
|
| 61 |
+
" \n",
|
| 62 |
+
" # 2. Initialize Pheromones\n",
|
| 63 |
+
" pheromones = np.ones((n_nodes, n_nodes)) * 0.1\n",
|
| 64 |
+
" \n",
|
| 65 |
+
" best_macro_tour = None\n",
|
| 66 |
+
" best_length = float('inf')\n",
|
| 67 |
+
" \n",
|
| 68 |
+
" # Entrance Logic\n",
|
| 69 |
+
" start_node = np.lexsort((coords[:, 0], coords[:, 1]))[0]\n",
|
| 70 |
+
" print(f\"Entrance Identified: Room {start_node} at coordinates {coords[start_node]}\")\n",
|
| 71 |
+
" print(f\"Unleashing {n_ants} ants for {n_iterations} iterations...\")\n",
|
| 72 |
+
" \n",
|
| 73 |
+
" for iteration in range(n_iterations):\n",
|
| 74 |
+
" all_tours = []\n",
|
| 75 |
+
" all_lengths = []\n",
|
| 76 |
+
" \n",
|
| 77 |
+
" for ant in range(n_ants):\n",
|
| 78 |
+
" unvisited = set(range(n_nodes))\n",
|
| 79 |
+
" current_node = start_node \n",
|
| 80 |
+
" tour = [current_node]\n",
|
| 81 |
+
" unvisited.remove(current_node)\n",
|
| 82 |
+
" tour_length = 0.0\n",
|
| 83 |
+
" \n",
|
| 84 |
+
" while unvisited:\n",
|
| 85 |
+
" candidates = list(unvisited)\n",
|
| 86 |
+
" \n",
|
| 87 |
+
" pher_values = pheromones[current_node, candidates]\n",
|
| 88 |
+
" dist_values = all_pairs_distances[current_node, candidates]\n",
|
| 89 |
+
" heuristic = 1.0 / (dist_values + 1e-10) \n",
|
| 90 |
+
" \n",
|
| 91 |
+
" probabilities = (pher_values ** alpha) * (heuristic ** beta)\n",
|
| 92 |
+
" \n",
|
| 93 |
+
" if probabilities.sum() == 0:\n",
|
| 94 |
+
" probabilities = np.ones(len(candidates)) / len(candidates)\n",
|
| 95 |
+
" else:\n",
|
| 96 |
+
" probabilities /= probabilities.sum() \n",
|
| 97 |
+
" \n",
|
| 98 |
+
" next_node = np.random.choice(candidates, p=probabilities)\n",
|
| 99 |
+
" \n",
|
| 100 |
+
" tour.append(next_node)\n",
|
| 101 |
+
" tour_length += all_pairs_distances[current_node, next_node]\n",
|
| 102 |
+
" unvisited.remove(next_node)\n",
|
| 103 |
+
" current_node = next_node\n",
|
| 104 |
+
" \n",
|
| 105 |
+
" tour_length += all_pairs_distances[tour[-1], tour[0]]\n",
|
| 106 |
+
" tour.append(tour[0])\n",
|
| 107 |
+
" \n",
|
| 108 |
+
" all_tours.append(tour)\n",
|
| 109 |
+
" all_lengths.append(tour_length)\n",
|
| 110 |
+
" \n",
|
| 111 |
+
" if tour_length < best_length:\n",
|
| 112 |
+
" best_length = tour_length\n",
|
| 113 |
+
" best_macro_tour = tour\n",
|
| 114 |
+
" \n",
|
| 115 |
+
" pheromones *= (1.0 - decay) \n",
|
| 116 |
+
" \n",
|
| 117 |
+
" for tour, length in zip(all_tours, all_lengths):\n",
|
| 118 |
+
" deposit_amount = 100.0 / length \n",
|
| 119 |
+
" for i in range(len(tour) - 1):\n",
|
| 120 |
+
" u, v = tour[i], tour[i+1]\n",
|
| 121 |
+
" pheromones[u, v] += deposit_amount\n",
|
| 122 |
+
" pheromones[v, u] += deposit_amount \n",
|
| 123 |
+
" \n",
|
| 124 |
+
" if iteration % 10 == 0:\n",
|
| 125 |
+
" print(f\"Iteration {iteration:03d} | Best Route: {int(best_length)} total moves\")\n",
|
| 126 |
+
" \n",
|
| 127 |
+
" # --- NEW: UNPACK THE MACRO TOUR INTO PHYSICAL STEPS ---\n",
|
| 128 |
+
" physical_tour = [best_macro_tour[0]]\n",
|
| 129 |
+
" for i in range(len(best_macro_tour) - 1):\n",
|
| 130 |
+
" start_n = best_macro_tour[i]\n",
|
| 131 |
+
" end_n = best_macro_tour[i+1]\n",
|
| 132 |
+
" # Inject the actual hallway nodes into the array\n",
|
| 133 |
+
" segment = get_physical_path(start_n, end_n, predecessors)\n",
|
| 134 |
+
" physical_tour.extend(segment)\n",
|
| 135 |
+
" \n",
|
| 136 |
+
" return physical_tour, best_length, all_pairs_distances\n",
|
| 137 |
+
"\n",
|
| 138 |
+
"def draw_base_graph_edges(ax, distances, coords, color='red'):\n",
|
| 139 |
+
" n_nodes = distances.shape[0]\n",
|
| 140 |
+
" for u in range(n_nodes):\n",
|
| 141 |
+
" for v in range(u + 1, n_nodes): \n",
|
| 142 |
+
" if distances[u, v] > 0 and distances[u, v] != np.inf:\n",
|
| 143 |
+
" start_c = coords[u]\n",
|
| 144 |
+
" end_c = coords[v]\n",
|
| 145 |
+
" ax.annotate(\"\", xy=end_c, xytext=start_c, \n",
|
| 146 |
+
" arrowprops=dict(arrowstyle=\"-\", color=color, linewidth=4.0, alpha=0.6, zorder=1))\n",
|
| 147 |
+
"\n",
|
| 148 |
+
"def visualize_tour(coords, physical_tour, title, distances):\n",
|
| 149 |
+
" fig, ax = plt.figure(figsize=(10, 10)), plt.gca()\n",
|
| 150 |
+
" \n",
|
| 151 |
+
" xs = coords[:, 0]\n",
|
| 152 |
+
" ys = coords[:, 1]\n",
|
| 153 |
+
" \n",
|
| 154 |
+
" draw_base_graph_edges(ax, distances, coords)\n",
|
| 155 |
+
" ax.scatter(xs, ys, c='blue', s=100, zorder=5)\n",
|
| 156 |
+
" \n",
|
| 157 |
+
" entrance_idx = physical_tour[0]\n",
|
| 158 |
+
" ax.scatter(coords[entrance_idx, 0], coords[entrance_idx, 1], c='yellow', edgecolors='black', s=400, marker='*', zorder=10, label=\"Entrance\")\n",
|
| 159 |
+
" \n",
|
| 160 |
+
" # The physical_tour now ONLY contains strictly adjacent nodes!\n",
|
| 161 |
+
" tour_coords = coords[physical_tour]\n",
|
| 162 |
+
" \n",
|
| 163 |
+
" for i in range(len(tour_coords) - 1):\n",
|
| 164 |
+
" start_c = tour_coords[i]\n",
|
| 165 |
+
" end_c = tour_coords[i+1]\n",
|
| 166 |
+
" \n",
|
| 167 |
+
" dx = end_c[0] - start_c[0]\n",
|
| 168 |
+
" dy = end_c[1] - start_c[1]\n",
|
| 169 |
+
" \n",
|
| 170 |
+
" # Draw the continuous dashed line between adjacent rooms\n",
|
| 171 |
+
" ax.plot([start_c[0], end_c[0]], [start_c[1], end_c[1]], \n",
|
| 172 |
+
" color=\"blue\", linewidth=2.0, linestyle=\"--\", zorder=6)\n",
|
| 173 |
+
" \n",
|
| 174 |
+
" # Drop the arrowhead exactly at the 50% midpoint\n",
|
| 175 |
+
" mid_x = start_c[0] + dx * 0.50\n",
|
| 176 |
+
" mid_y = start_c[1] + dy * 0.50\n",
|
| 177 |
+
" target_x = start_c[0] + dx * 0.51\n",
|
| 178 |
+
" target_y = start_c[1] + dy * 0.51\n",
|
| 179 |
+
" \n",
|
| 180 |
+
" ax.annotate(\"\", xy=(target_x, target_y), xytext=(mid_x, mid_y),\n",
|
| 181 |
+
" arrowprops=dict(arrowstyle=\"-|>,head_width=0.4,head_length=0.8\", \n",
|
| 182 |
+
" color=\"blue\", linewidth=2.0, zorder=7))\n",
|
| 183 |
+
"\n",
|
| 184 |
+
" ax.set_title(title, pad=20, fontsize=14, fontweight='bold')\n",
|
| 185 |
+
" ax.invert_yaxis() \n",
|
| 186 |
+
" ax.grid(True, linestyle=':', alpha=0.6)\n",
|
| 187 |
+
" \n",
|
| 188 |
+
" from matplotlib.lines import Line2D\n",
|
| 189 |
+
" custom_lines = [Line2D([0], [0], color=\"blue\", linewidth=2.0, linestyle=\"--\"),\n",
|
| 190 |
+
" Line2D([0], [0], marker='*', color='w', markerfacecolor='yellow', markeredgecolor='black', markersize=15),\n",
|
| 191 |
+
" Line2D([0], [0], color=\"red\", linewidth=4.0, alpha=0.6)]\n",
|
| 192 |
+
" ax.legend(custom_lines, ['Worker Route', 'Entrance', 'Available Hallways'], loc=\"best\")\n",
|
| 193 |
+
" \n",
|
| 194 |
+
" save_dir = \"temp_visuals\"\n",
|
| 195 |
+
" os.makedirs(save_dir, exist_ok=True)\n",
|
| 196 |
+
" save_filename = os.path.join(save_dir, \"optimized_directional_renovation_route.png\")\n",
|
| 197 |
+
" \n",
|
| 198 |
+
" plt.savefig(save_filename, bbox_inches='tight')\n",
|
| 199 |
+
" print(f\"\\n🗺️ Map saved successfully! Check your folder for '{save_filename}'\")\n",
|
| 200 |
+
" plt.close()\n",
|
| 201 |
+
"\n",
|
| 202 |
+
"if __name__ == \"__main__\":\n",
|
| 203 |
+
" dataset_path = os.path.join(\"dataset\", \"renovation_data.npz\")\n",
|
| 204 |
+
" print(f\"Loading data from {dataset_path}...\")\n",
|
| 205 |
+
" \n",
|
| 206 |
+
" data = np.load(dataset_path, allow_pickle=True)\n",
|
| 207 |
+
" distances_array = data['distances']\n",
|
| 208 |
+
" coords_array = data['coords']\n",
|
| 209 |
+
" \n",
|
| 210 |
+
" print(f\"Loaded {len(distances_array)} graphs. We will solve Graph #1.\")\n",
|
| 211 |
+
" \n",
|
| 212 |
+
" original_distances = np.array(distances_array[3], dtype=np.float64)\n",
|
| 213 |
+
" test_coords = np.array(coords_array[0], dtype=np.float64)\n",
|
| 214 |
+
" \n",
|
| 215 |
+
" # 1 Move = 1 Travel Meter\n",
|
| 216 |
+
" test_distances = np.where(original_distances > 0, 1.0, 0.0)\n",
|
| 217 |
+
" \n",
|
| 218 |
+
" print(f\"Graph #1 has {test_distances.shape[0]} rooms.\")\n",
|
| 219 |
+
" \n",
|
| 220 |
+
" physical_tour, best_length, all_pairs_distances = run_ant_colony(test_distances, test_coords)\n",
|
| 221 |
+
" \n",
|
| 222 |
+
" print(\"\\n✅ Simulation Complete!\")\n",
|
| 223 |
+
" print(f\"Shortest path found covers all rooms in {int(best_length)} total moves.\")\n",
|
| 224 |
+
" \n",
|
| 225 |
+
" visualize_tour(test_coords, physical_tour, f\"Ant Colony Optimized Renovation Route\\nTotal Moves: {int(best_length)}\", original_distances)"
|
| 226 |
+
]
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"cell_type": "code",
|
| 230 |
+
"execution_count": null,
|
| 231 |
+
"id": "ccdb2d47-4f26-4486-8ff3-a934b7db2e47",
|
| 232 |
+
"metadata": {},
|
| 233 |
+
"outputs": [],
|
| 234 |
+
"source": []
|
| 235 |
+
}
|
| 236 |
+
],
|
| 237 |
+
"metadata": {
|
| 238 |
+
"kernelspec": {
|
| 239 |
+
"display_name": "Python (hivt)",
|
| 240 |
+
"language": "python",
|
| 241 |
+
"name": "hivt"
|
| 242 |
+
},
|
| 243 |
+
"language_info": {
|
| 244 |
+
"codemirror_mode": {
|
| 245 |
+
"name": "ipython",
|
| 246 |
+
"version": 3
|
| 247 |
+
},
|
| 248 |
+
"file_extension": ".py",
|
| 249 |
+
"mimetype": "text/x-python",
|
| 250 |
+
"name": "python",
|
| 251 |
+
"nbconvert_exporter": "python",
|
| 252 |
+
"pygments_lexer": "ipython3",
|
| 253 |
+
"version": "3.10.20"
|
| 254 |
+
}
|
| 255 |
+
},
|
| 256 |
+
"nbformat": 4,
|
| 257 |
+
"nbformat_minor": 5
|
| 258 |
+
}
|
solver.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import matplotlib
|
| 4 |
+
matplotlib.use('Agg')
|
| 5 |
+
import matplotlib.pyplot as plt
|
| 6 |
+
from scipy.sparse.csgraph import shortest_path
|
| 7 |
+
|
| 8 |
+
# --- NEW HELPER: Reconstructs the exact physical hallways walked ---
|
| 9 |
+
def get_physical_path(start, end, preds):
|
| 10 |
+
path = []
|
| 11 |
+
curr = end
|
| 12 |
+
while curr != start and curr >= 0:
|
| 13 |
+
path.append(curr)
|
| 14 |
+
curr = preds[start, curr]
|
| 15 |
+
path.reverse()
|
| 16 |
+
return path
|
| 17 |
+
|
| 18 |
+
def run_ant_colony(distances, coords, n_ants=50, n_iterations=100, decay=0.1, alpha=1.0, beta=2.0):
|
| 19 |
+
n_nodes = distances.shape[0]
|
| 20 |
+
|
| 21 |
+
# 1. The "Smart GPS" (NOW WITH return_predecessors=True)
|
| 22 |
+
dist_matrix_for_pathing = np.where(distances == 0, np.inf, distances)
|
| 23 |
+
np.fill_diagonal(dist_matrix_for_pathing, 0)
|
| 24 |
+
all_pairs_distances, predecessors = shortest_path(csgraph=dist_matrix_for_pathing, directed=False, return_predecessors=True)
|
| 25 |
+
|
| 26 |
+
# 2. Initialize Pheromones
|
| 27 |
+
pheromones = np.ones((n_nodes, n_nodes)) * 0.1
|
| 28 |
+
|
| 29 |
+
best_macro_tour = None
|
| 30 |
+
best_length = float('inf')
|
| 31 |
+
|
| 32 |
+
# Entrance Logic
|
| 33 |
+
start_node = np.lexsort((coords[:, 0], coords[:, 1]))[0]
|
| 34 |
+
print(f"Entrance Identified: Room {start_node} at coordinates {coords[start_node]}")
|
| 35 |
+
print(f"Unleashing {n_ants} ants for {n_iterations} iterations...")
|
| 36 |
+
|
| 37 |
+
for iteration in range(n_iterations):
|
| 38 |
+
all_tours = []
|
| 39 |
+
all_lengths = []
|
| 40 |
+
|
| 41 |
+
for ant in range(n_ants):
|
| 42 |
+
unvisited = set(range(n_nodes))
|
| 43 |
+
current_node = start_node
|
| 44 |
+
tour = [current_node]
|
| 45 |
+
unvisited.remove(current_node)
|
| 46 |
+
tour_length = 0.0
|
| 47 |
+
|
| 48 |
+
while unvisited:
|
| 49 |
+
candidates = list(unvisited)
|
| 50 |
+
|
| 51 |
+
pher_values = pheromones[current_node, candidates]
|
| 52 |
+
dist_values = all_pairs_distances[current_node, candidates]
|
| 53 |
+
heuristic = 1.0 / (dist_values + 1e-10)
|
| 54 |
+
|
| 55 |
+
probabilities = (pher_values ** alpha) * (heuristic ** beta)
|
| 56 |
+
|
| 57 |
+
if probabilities.sum() == 0:
|
| 58 |
+
probabilities = np.ones(len(candidates)) / len(candidates)
|
| 59 |
+
else:
|
| 60 |
+
probabilities /= probabilities.sum()
|
| 61 |
+
|
| 62 |
+
next_node = np.random.choice(candidates, p=probabilities)
|
| 63 |
+
|
| 64 |
+
tour.append(next_node)
|
| 65 |
+
tour_length += all_pairs_distances[current_node, next_node]
|
| 66 |
+
unvisited.remove(next_node)
|
| 67 |
+
current_node = next_node
|
| 68 |
+
|
| 69 |
+
tour_length += all_pairs_distances[tour[-1], tour[0]]
|
| 70 |
+
tour.append(tour[0])
|
| 71 |
+
|
| 72 |
+
all_tours.append(tour)
|
| 73 |
+
all_lengths.append(tour_length)
|
| 74 |
+
|
| 75 |
+
if tour_length < best_length:
|
| 76 |
+
best_length = tour_length
|
| 77 |
+
best_macro_tour = tour
|
| 78 |
+
|
| 79 |
+
pheromones *= (1.0 - decay)
|
| 80 |
+
|
| 81 |
+
for tour, length in zip(all_tours, all_lengths):
|
| 82 |
+
deposit_amount = 100.0 / length
|
| 83 |
+
for i in range(len(tour) - 1):
|
| 84 |
+
u, v = tour[i], tour[i+1]
|
| 85 |
+
pheromones[u, v] += deposit_amount
|
| 86 |
+
pheromones[v, u] += deposit_amount
|
| 87 |
+
|
| 88 |
+
if iteration % 10 == 0:
|
| 89 |
+
print(f"Iteration {iteration:03d} | Best Route: {int(best_length)} total moves")
|
| 90 |
+
|
| 91 |
+
# --- NEW: UNPACK THE MACRO TOUR INTO PHYSICAL STEPS ---
|
| 92 |
+
physical_tour = [best_macro_tour[0]]
|
| 93 |
+
for i in range(len(best_macro_tour) - 1):
|
| 94 |
+
start_n = best_macro_tour[i]
|
| 95 |
+
end_n = best_macro_tour[i+1]
|
| 96 |
+
# Inject the actual hallway nodes into the array
|
| 97 |
+
segment = get_physical_path(start_n, end_n, predecessors)
|
| 98 |
+
physical_tour.extend(segment)
|
| 99 |
+
|
| 100 |
+
return physical_tour, best_length, all_pairs_distances
|
| 101 |
+
|
| 102 |
+
def draw_base_graph_edges(ax, distances, coords, color='red'):
|
| 103 |
+
n_nodes = distances.shape[0]
|
| 104 |
+
for u in range(n_nodes):
|
| 105 |
+
for v in range(u + 1, n_nodes):
|
| 106 |
+
if distances[u, v] > 0 and distances[u, v] != np.inf:
|
| 107 |
+
start_c = coords[u]
|
| 108 |
+
end_c = coords[v]
|
| 109 |
+
ax.annotate("", xy=end_c, xytext=start_c,
|
| 110 |
+
arrowprops=dict(arrowstyle="-", color=color, linewidth=4.0, alpha=0.6, zorder=1))
|
| 111 |
+
|
| 112 |
+
def visualize_tour(coords, physical_tour, title, distances):
|
| 113 |
+
fig, ax = plt.figure(figsize=(10, 10)), plt.gca()
|
| 114 |
+
|
| 115 |
+
xs = coords[:, 0]
|
| 116 |
+
ys = coords[:, 1]
|
| 117 |
+
|
| 118 |
+
draw_base_graph_edges(ax, distances, coords)
|
| 119 |
+
ax.scatter(xs, ys, c='blue', s=100, zorder=5)
|
| 120 |
+
|
| 121 |
+
entrance_idx = physical_tour[0]
|
| 122 |
+
ax.scatter(coords[entrance_idx, 0], coords[entrance_idx, 1], c='yellow', edgecolors='black', s=400, marker='*', zorder=10, label="Entrance")
|
| 123 |
+
|
| 124 |
+
# The physical_tour now ONLY contains strictly adjacent nodes!
|
| 125 |
+
tour_coords = coords[physical_tour]
|
| 126 |
+
|
| 127 |
+
for i in range(len(tour_coords) - 1):
|
| 128 |
+
start_c = tour_coords[i]
|
| 129 |
+
end_c = tour_coords[i+1]
|
| 130 |
+
|
| 131 |
+
dx = end_c[0] - start_c[0]
|
| 132 |
+
dy = end_c[1] - start_c[1]
|
| 133 |
+
|
| 134 |
+
# Draw the continuous dashed line between adjacent rooms
|
| 135 |
+
ax.plot([start_c[0], end_c[0]], [start_c[1], end_c[1]],
|
| 136 |
+
color="blue", linewidth=2.0, linestyle="--", zorder=6)
|
| 137 |
+
|
| 138 |
+
# Drop the arrowhead exactly at the 50% midpoint
|
| 139 |
+
mid_x = start_c[0] + dx * 0.50
|
| 140 |
+
mid_y = start_c[1] + dy * 0.50
|
| 141 |
+
target_x = start_c[0] + dx * 0.51
|
| 142 |
+
target_y = start_c[1] + dy * 0.51
|
| 143 |
+
|
| 144 |
+
ax.annotate("", xy=(target_x, target_y), xytext=(mid_x, mid_y),
|
| 145 |
+
arrowprops=dict(arrowstyle="-|>,head_width=0.4,head_length=0.8",
|
| 146 |
+
color="blue", linewidth=2.0, zorder=7))
|
| 147 |
+
|
| 148 |
+
ax.set_title(title, pad=20, fontsize=14, fontweight='bold')
|
| 149 |
+
ax.invert_yaxis()
|
| 150 |
+
ax.grid(True, linestyle=':', alpha=0.6)
|
| 151 |
+
|
| 152 |
+
from matplotlib.lines import Line2D
|
| 153 |
+
custom_lines = [Line2D([0], [0], color="blue", linewidth=2.0, linestyle="--"),
|
| 154 |
+
Line2D([0], [0], marker='*', color='w', markerfacecolor='yellow', markeredgecolor='black', markersize=15),
|
| 155 |
+
Line2D([0], [0], color="red", linewidth=4.0, alpha=0.6)]
|
| 156 |
+
ax.legend(custom_lines, ['Worker Route', 'Entrance', 'Available Hallways'], loc="best")
|
| 157 |
+
|
| 158 |
+
save_dir = "temp_visuals"
|
| 159 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 160 |
+
save_filename = os.path.join(save_dir, "optimized_directional_renovation_route.png")
|
| 161 |
+
|
| 162 |
+
plt.savefig(save_filename, bbox_inches='tight')
|
| 163 |
+
print(f"\n🗺️ Map saved successfully! Check your folder for '{save_filename}'")
|
| 164 |
+
plt.close()
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
dataset_path = os.path.join("dataset", "renovation_data.npz")
|
| 168 |
+
print(f"Loading data from {dataset_path}...")
|
| 169 |
+
|
| 170 |
+
data = np.load(dataset_path, allow_pickle=True)
|
| 171 |
+
distances_array = data['distances']
|
| 172 |
+
coords_array = data['coords']
|
| 173 |
+
|
| 174 |
+
print(f"Loaded {len(distances_array)} graphs. We will solve Graph #1.")
|
| 175 |
+
|
| 176 |
+
original_distances = np.array(distances_array[0], dtype=np.float64)
|
| 177 |
+
test_coords = np.array(coords_array[0], dtype=np.float64)
|
| 178 |
+
|
| 179 |
+
# 1 Move = 1 Travel Meter
|
| 180 |
+
test_distances = np.where(original_distances > 0, 1.0, 0.0)
|
| 181 |
+
|
| 182 |
+
print(f"Graph #1 has {test_distances.shape[0]} rooms.")
|
| 183 |
+
|
| 184 |
+
physical_tour, best_length, all_pairs_distances = run_ant_colony(test_distances, test_coords)
|
| 185 |
+
|
| 186 |
+
print("\n✅ Simulation Complete!")
|
| 187 |
+
print(f"Shortest path found covers all rooms in {int(best_length)} total moves.")
|
| 188 |
+
|
| 189 |
+
visualize_tour(test_coords, physical_tour, f"Ant Colony Optimized Renovation Route\nTotal Moves: {int(best_length)}", original_distances)
|
visualizer.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import networkx as nx
|
| 2 |
+
import matplotlib.pyplot as plt
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
|
| 7 |
+
IMG_WIDTH_PX = 800
|
| 8 |
+
IMG_HEIGHT_PX = 800
|
| 9 |
+
TEMP_DIR = "temp_visuals"
|
| 10 |
+
|
| 11 |
+
# --- NEW CLEANUP LOGIC ---
|
| 12 |
+
# Wipe the folder clean when the app starts, then recreate it
|
| 13 |
+
if os.path.exists(TEMP_DIR):
|
| 14 |
+
shutil.rmtree(TEMP_DIR)
|
| 15 |
+
os.makedirs(TEMP_DIR, exist_ok=True)
|
| 16 |
+
# -------------------------
|
| 17 |
+
|
| 18 |
+
def get_sorted_nodes(G):
|
| 19 |
+
"""Returns nodes sorted by X, then Y to ensure consistent IDs."""
|
| 20 |
+
return sorted(list(G.nodes()), key=lambda l: (l[0], l[1]))
|
| 21 |
+
|
| 22 |
+
def plot_graph_to_image(graph, width, height, title="Network", highlight_node=None, save_dir=TEMP_DIR):
|
| 23 |
+
"""Generates a matplotlib plot and saves it as an image file."""
|
| 24 |
+
dpi = 100
|
| 25 |
+
fig = plt.figure(figsize=(IMG_WIDTH_PX/dpi, IMG_HEIGHT_PX/dpi), dpi=dpi)
|
| 26 |
+
ax = fig.add_axes([0, 0, 1, 1])
|
| 27 |
+
|
| 28 |
+
pos = {node: (node[0], node[1]) for node in graph.nodes()}
|
| 29 |
+
|
| 30 |
+
# Dynamic sizing to prevent jamming
|
| 31 |
+
max_dim = max(width, height)
|
| 32 |
+
if max_dim <= 6:
|
| 33 |
+
n_sz, f_sz, h_sz = 900, 12, 1100
|
| 34 |
+
elif max_dim <= 10:
|
| 35 |
+
n_sz, f_sz, h_sz = 500, 9, 650
|
| 36 |
+
elif max_dim <= 16:
|
| 37 |
+
n_sz, f_sz, h_sz = 200, 7, 280
|
| 38 |
+
elif max_dim <= 24:
|
| 39 |
+
n_sz, f_sz, h_sz = 100, 5, 140
|
| 40 |
+
else:
|
| 41 |
+
n_sz, f_sz, h_sz = 50, 4, 80
|
| 42 |
+
|
| 43 |
+
nx.draw_networkx_edges(graph, pos, ax=ax, width=2, alpha=0.6, edge_color="#333")
|
| 44 |
+
|
| 45 |
+
normal_nodes = [n for n in graph.nodes() if n != highlight_node]
|
| 46 |
+
nx.draw_networkx_nodes(graph, pos, ax=ax, nodelist=normal_nodes, node_size=n_sz, node_color="#4F46E5", edgecolors="white", linewidths=1.5)
|
| 47 |
+
|
| 48 |
+
if highlight_node and graph.has_node(highlight_node):
|
| 49 |
+
nx.draw_networkx_nodes(graph, pos, ax=ax, nodelist=[highlight_node], node_size=h_sz, node_color="#EF4444", edgecolors="white", linewidths=2.0)
|
| 50 |
+
|
| 51 |
+
sorted_nodes = get_sorted_nodes(graph)
|
| 52 |
+
labels = {node: str(i+1) for i, node in enumerate(sorted_nodes)}
|
| 53 |
+
nx.draw_networkx_labels(graph, pos, labels, ax=ax, font_size=f_sz, font_color="white", font_weight="bold")
|
| 54 |
+
|
| 55 |
+
ax.set_xlim(-0.5, width + 0.5)
|
| 56 |
+
ax.set_ylim(height + 0.5, -0.5)
|
| 57 |
+
ax.grid(True, linestyle=':', alpha=0.3)
|
| 58 |
+
ax.set_axis_on()
|
| 59 |
+
|
| 60 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 61 |
+
prefix = "temp_plot" if save_dir == TEMP_DIR else "saved_plot"
|
| 62 |
+
fname = os.path.join(save_dir, f"{prefix}_{timestamp}.png")
|
| 63 |
+
|
| 64 |
+
plt.savefig(fname)
|
| 65 |
+
plt.close(fig)
|
| 66 |
+
return fname
|