LeonceNsh commited on
Commit
f18bf94
·
verified ·
1 Parent(s): ff9c8ac

Create vrp_core.py

Browse files
Files changed (1) hide show
  1. vrp_core.py +287 -0
vrp_core.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import os, io, json, zipfile, math, time, logging, pathlib, shutil, re, random, hashlib
3
+ from dataclasses import dataclass
4
+ from typing import Dict, List, Tuple, Optional
5
+ import numpy as np
6
+ import pandas as pd
7
+ from scipy.spatial.distance import cdist
8
+ import subprocess
9
+ import tempfile
10
+ import requests
11
+
12
+ LOGGER = logging.getLogger("cuopt_cvrptw.core")
13
+
14
+ DATA_CACHE_DIR = os.environ.get("CUOPT_DATA_DIR", "/tmp/data/cuopt_cvrptw")
15
+ os.makedirs(DATA_CACHE_DIR, exist_ok=True)
16
+
17
+ HOMBERGER_URLS = [
18
+ # Multiple mirrors to improve resilience; the app tries each in order.
19
+ # If all fail, the user can upload a local .TXT instance.
20
+ "https://www.sintef.no/projectweb/top/vrptw/homberger-benchmark/",
21
+ "https://lopez-ibanez.eu/benchmarking/vrptw-homberger",
22
+ ]
23
+
24
+ DEFAULT_INSTANCE = "C1_10_1.TXT" # 1000 customers
25
+
26
+ @dataclass
27
+ class GPUInfo:
28
+ available: bool
29
+ name: Optional[str] = None
30
+ driver: Optional[str] = None
31
+ cuda_version: Optional[str] = None
32
+ memory_total_mb: Optional[int] = None
33
+ cudf_version: Optional[str] = None
34
+ cuopt_version: Optional[str] = None
35
+ details_raw: Optional[str] = None
36
+ error: Optional[str] = None
37
+
38
+ def _try_imports():
39
+ mods = {}
40
+ try:
41
+ import cudf # type: ignore
42
+ mods["cudf"] = cudf
43
+ except Exception as e:
44
+ mods["cudf_error"] = str(e)
45
+ try:
46
+ # cuOpt package name may vary by CUDA; use the generic import path.
47
+ import cuopt # type: ignore
48
+ mods["cuopt"] = cuopt
49
+ except Exception as e:
50
+ mods["cuopt_error"] = str(e)
51
+ return mods
52
+
53
+ def check_gpu() -> GPUInfo:
54
+ """Collects environment and GPU details using nvidia-smi and optional cudf/cuopt versions."""
55
+ available = False
56
+ name = driver = cuda = None
57
+ mem_total = None
58
+ raw = ""
59
+ try:
60
+ out = subprocess.check_output(["nvidia-smi"], stderr=subprocess.STDOUT, text=True, timeout=5)
61
+ raw = out
62
+ available = True
63
+ # Parse simple fields
64
+ m_name = re.search(r"GPU 0:\s*([^,|]+)", out)
65
+ if m_name: name = m_name.group(1).strip()
66
+ m_driver = re.search(r"Driver Version:\s*([0-9.]+)", out)
67
+ if m_driver: driver = m_driver.group(1)
68
+ m_cuda = re.search(r"CUDA Version:\s*([0-9.]+)", out)
69
+ if m_cuda: cuda = m_cuda.group(1)
70
+ # Memory total (MiB)
71
+ m_mem = re.findall(r"\|\s+(\d+)\s*MiB /\s*(\d+)\s*MiB\s*\|", out)
72
+ if m_mem and m_mem[0]:
73
+ mem_total = int(m_mem[0][1])
74
+ except Exception as e:
75
+ return GPUInfo(False, error=f"nvidia-smi not available or failed: {e}")
76
+
77
+ mods = _try_imports()
78
+ cudf_v = getattr(mods.get("cudf"), "__version__", None) if "cudf" in mods else None
79
+ cuopt_v = getattr(mods.get("cuopt"), "__version__", None) if "cuopt" in mods else None
80
+
81
+ return GPUInfo(True, name, driver, cuda, mem_total, cudf_v, cuopt_v, details_raw=raw)
82
+
83
+ def download_dataset(dest_dir: str = DATA_CACHE_DIR) -> Tuple[bool, str]:
84
+ """Attempt to download and cache the Homberger dataset. Returns (ok, message)."""
85
+ try:
86
+ os.makedirs(dest_dir, exist_ok=True)
87
+ # If already present (some .TXT files), skip
88
+ if list_instances(dest_dir):
89
+ return True, f"Dataset already present at {dest_dir}."
90
+ # Attempt crude scrape of links and download .zip/.7z if available
91
+ session = requests.Session()
92
+ for base in HOMBERGER_URLS:
93
+ try:
94
+ r = session.get(base, timeout=15)
95
+ r.raise_for_status()
96
+ # look for zip link names
97
+ zip_links = re.findall(r'href="([^"]+homberger[^"]+\.(zip|7z))"', r.text, re.IGNORECASE)
98
+ if not zip_links:
99
+ continue
100
+ # take first link
101
+ rel, _ = zip_links[0]
102
+ url = rel if rel.startswith("http") else requests.compat.urljoin(base, rel)
103
+ z = session.get(url, timeout=60)
104
+ z.raise_for_status()
105
+ archive_path = os.path.join(dest_dir, os.path.basename(url))
106
+ with open(archive_path, "wb") as f:
107
+ f.write(z.content)
108
+ # Try unzip if zip
109
+ if archive_path.lower().endswith(".zip"):
110
+ with zipfile.ZipFile(archive_path, 'r') as zf:
111
+ zf.extractall(dest_dir)
112
+ else:
113
+ # 7z not handled; ask user to upload or manually extract
114
+ return False, f"Downloaded {archive_path} but cannot extract .7z automatically. Upload a .TXT or extract manually."
115
+ if list_instances(dest_dir):
116
+ return True, f"Downloaded and extracted dataset to {dest_dir}."
117
+ except Exception as e:
118
+ LOGGER.warning("Dataset fetch attempt failed from %s: %s", base, e)
119
+ return False, "Failed to auto-download dataset from known mirrors. Please upload a .TXT file or place files under /tmp/data/cuopt_cvrptw."
120
+ except Exception as e:
121
+ return False, f"Download error: {e}"
122
+
123
+ def list_instances(dest_dir: str = DATA_CACHE_DIR) -> List[str]:
124
+ out = []
125
+ for root, _, files in os.walk(dest_dir):
126
+ for fn in files:
127
+ if fn.upper().endswith(".TXT"):
128
+ out.append(os.path.join(root, fn))
129
+ return sorted(out)
130
+
131
+ def _read_txt(path: str) -> pd.DataFrame:
132
+ """
133
+ Parses Homberger/Gehring CVRPTW .TXT format.
134
+ Columns (typical): cust_no, x, y, demand, ready_time, due_date, service_time
135
+ Depot is usually row with cust_no == 0 or first line.
136
+ """
137
+ with open(path, "r", encoding="latin-1") as f:
138
+ lines = [ln.strip() for ln in f if ln.strip()]
139
+ # heuristic: skip header lines until we find a line that begins with an integer id
140
+ data_lines = []
141
+ for ln in lines:
142
+ if re.match(r"^\d+(\s+[-+]?\d+(\.\d+)*)+", ln):
143
+ data_lines.append(ln)
144
+ if not data_lines:
145
+ raise ValueError("Could not find data rows with numeric columns.")
146
+ rows = []
147
+ for ln in data_lines:
148
+ parts = re.split(r"\s+", ln)
149
+ if len(parts) < 7:
150
+ # try to pad or skip
151
+ continue
152
+ cust_no = int(parts[0])
153
+ x = float(parts[1]); y = float(parts[2])
154
+ demand = float(parts[3])
155
+ ready = float(parts[4]); due = float(parts[5]); service = float(parts[6])
156
+ rows.append((cust_no, x, y, demand, ready, due, service))
157
+ if not rows:
158
+ raise ValueError("No valid rows parsed. Confirm file format.")
159
+ df = pd.DataFrame(rows, columns=["cust_no","x","y","demand","ready_time","due_time","service_time"])
160
+ return df
161
+
162
+ def create_from_file(file_path: str) -> Dict:
163
+ """
164
+ Read .TXT and return a structured dict with nodes, depot, capacity guess, etc.
165
+ """
166
+ df = _read_txt(file_path)
167
+ # Ensure depot exists
168
+ if 0 not in set(df["cust_no"].values):
169
+ # Assume first row depot
170
+ df.loc[df.index[0], "cust_no"] = 0
171
+ df = df.sort_values("cust_no").reset_index(drop=True)
172
+ # Estimate default capacity: use 10x 95th percentile demand as a heuristic
173
+ cap = max(1, int(10 * np.percentile(df["demand"].values[1:], 95)))
174
+ out = {
175
+ "meta": {
176
+ "instance_id": pathlib.Path(file_path).name,
177
+ "n_nodes": int(df.shape[0]),
178
+ "n_customers": int(df.shape[0]-1),
179
+ "estimated_capacity": int(cap),
180
+ },
181
+ "data": df
182
+ }
183
+ return out
184
+
185
+ def build_data_model(df: pd.DataFrame, capacity: int, n_vehicles: Optional[int] = None) -> Dict:
186
+ """
187
+ Build a generic data structure expected by cuOpt. We avoid a hard dependency here;
188
+ the solver will convert this dictionary to actual cuOpt DataModel if available.
189
+ """
190
+ coords = df[["x","y"]].to_numpy(dtype=np.float32)
191
+ dist = cdist(coords, coords, metric="euclidean").astype(np.float32)
192
+
193
+ # time windows
194
+ tw = df[["ready_time","due_time"]].to_numpy(dtype=np.float32)
195
+ service = df["service_time"].to_numpy(dtype=np.float32)
196
+ demand = df["demand"].to_numpy(dtype=np.float32)
197
+
198
+ model = {
199
+ "distance_matrix": dist,
200
+ "time_windows": tw,
201
+ "service_times": service,
202
+ "demand": demand,
203
+ "depot": 0,
204
+ "vehicle_capacity": float(capacity),
205
+ "n_vehicles": int(n_vehicles) if n_vehicles else None,
206
+ }
207
+ return model
208
+
209
+ def _to_cuopt_model(model: Dict):
210
+ mods = _try_imports()
211
+ if "cuopt" not in mods:
212
+ raise RuntimeError(f"cuOpt not installed: {mods.get('cuopt_error','unknown')}")
213
+ cuopt = mods["cuopt"]
214
+ # The actual cuOpt API may differ; below is a representative outline.
215
+ dm = cuopt.DataModel()
216
+ dm.add_cost_matrix(model["distance_matrix"])
217
+ dm.add_time_windows(model["time_windows"])
218
+ dm.add_service_times(model["service_times"])
219
+ dm.add_demands(model["demand"], model["vehicle_capacity"])
220
+ dm.set_depot(model["depot"])
221
+ if model.get("n_vehicles"):
222
+ dm.set_number_of_vehicles(model["n_vehicles"])
223
+ return dm
224
+
225
+ def run_solver(model: Dict, time_limit_s: int, seed: Optional[int] = None) -> Dict:
226
+ """Run cuOpt solver with a wall-clock limit. Returns metrics dict for UI."""
227
+ start = time.time()
228
+ mods = _try_imports()
229
+ if "cuopt" not in mods:
230
+ raise RuntimeError(f"cuOpt not installed: {mods.get('cuopt_error','unknown')}")
231
+ cuopt = mods["cuopt"]
232
+ dm = _to_cuopt_model(model)
233
+ cfg = cuopt.SolverConfig()
234
+ cfg.time_limit = int(time_limit_s)
235
+ if seed is not None:
236
+ try:
237
+ cfg.random_seed = int(seed)
238
+ except Exception:
239
+ pass
240
+
241
+ solver = cuopt.Solver(dm, cfg)
242
+ status = solver.solve()
243
+ runtime = time.time() - start
244
+
245
+ # Extract fields safely
246
+ try:
247
+ total_cost = float(solver.get_objective())
248
+ except Exception:
249
+ total_cost = float("nan")
250
+ try:
251
+ vehicles_used = int(solver.get_vehicles_used())
252
+ except Exception:
253
+ vehicles_used = None
254
+
255
+ return {
256
+ "status": int(status) if isinstance(status, (int, np.integer)) else 0,
257
+ "objective": total_cost,
258
+ "vehicles_used": vehicles_used,
259
+ "runtime_s": runtime,
260
+ "time_limit_s": int(time_limit_s),
261
+ "seed": seed,
262
+ }
263
+
264
+ def evaluate_against_bks(metrics: Dict, bks: Dict) -> Dict:
265
+ out = {}
266
+ if bks.get("vehicles"):
267
+ if metrics.get("vehicles_used") is not None:
268
+ out["delta_vehicles"] = metrics["vehicles_used"] - float(bks["vehicles"])
269
+ else:
270
+ out["delta_vehicles"] = None
271
+ if bks.get("cost"):
272
+ if not math.isnan(metrics.get("objective", float("nan"))):
273
+ out["delta_cost"] = metrics["objective"] - float(bks["cost"])
274
+ if bks["cost"] > 0:
275
+ out["pct_over_bks"] = 100.0 * out["delta_cost"] / float(bks["cost"])
276
+ return out
277
+
278
+ def safe_hash(s: str) -> str:
279
+ return hashlib.sha1(s.encode("utf-8")).hexdigest()[:10]
280
+
281
+ def write_csv(path: str, rows: List[Dict]):
282
+ df = pd.DataFrame(rows)
283
+ df.to_csv(path, index=False)
284
+
285
+ def write_json(path: str, obj: Dict):
286
+ with open(path, "w") as f:
287
+ json.dump(obj, f, indent=2)