Skip to content

Commit 4432b04

Browse files
authored
Merge pull request #12 from Jij-Inc/09-routing
Introduce 09-routing
2 parents 1e08ecb + 1680237 commit 4432b04

File tree

5 files changed

+752
-0
lines changed

5 files changed

+752
-0
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
from math import sqrt
3+
4+
5+
def read_vrp_tsplib(
6+
path: str,
7+
vehicle_limit: int,
8+
*,
9+
euc2d_round: bool = True, # TSPLIB EUC_2D convention: round distances to nearest integer
10+
depot_policy: str = "first", # Handling of multiple depots (only "first" supported)
11+
) -> dict:
12+
"""Read a TSPLIB-style CVRP instance and return JijModeling instance data.
13+
14+
Supports the standard TSPLIB format for the Capacitated Vehicle Routing
15+
Problem (CVRP), including parsing coordinates, demands, and depot
16+
definitions. Distances are computed with the EUC_2D convention.
17+
18+
Args:
19+
path (str): Path to the TSPLIB-format CVRP file.
20+
vehicle_limit (int): Maximum number of vehicles allowed.
21+
euc2d_round (bool, optional): Whether to round EUC_2D distances to
22+
the nearest integer (default True, per TSPLIB convention).
23+
depot_policy (str, optional): Policy for selecting the depot if
24+
multiple depots are provided. Currently only "first" is supported.
25+
26+
Returns:
27+
Dict: A dictionary formatted for JijModeling instance_data with keys:
28+
- "n" (int): Number of nodes (dimension).
29+
- "VEHICLE_LIMIT" (int): Vehicle limit.
30+
- "CAPACITY" (int): Vehicle capacity.
31+
- "DEMAND" (List[int]): Demand at each node (0-based).
32+
- "D" (List[List[int|int]]): Distance matrix (symmetric).
33+
- "DEPOT" (int): Depot index (0-based).
34+
"""
35+
# --- 1) Read file lines ---
36+
with open(path, "r", encoding="utf-8") as f:
37+
raw_lines = [ln.strip() for ln in f]
38+
39+
lines = [ln for ln in raw_lines if ln != ""]
40+
41+
# --- 2) Parse header ---
42+
header: dict[str, str] = {}
43+
idx = 0
44+
while idx < len(lines):
45+
ln = lines[idx]
46+
if ln.upper().endswith("SECTION") or ln.upper() == "EOF":
47+
break
48+
if ":" in ln:
49+
key, val = ln.split(":", 1)
50+
header[key.strip().upper()] = val.strip()
51+
idx += 1
52+
53+
try:
54+
dim = int(header["DIMENSION"])
55+
cap = int(header["CAPACITY"])
56+
edge_type = header.get("EDGE_WEIGHT_TYPE", "EUC_2D").upper()
57+
if edge_type not in ("EUC_2D",):
58+
raise ValueError(f"Unsupported EDGE_WEIGHT_TYPE: {edge_type}")
59+
except KeyError as e:
60+
raise ValueError(f"Missing required header field: {e}")
61+
62+
# --- 3) Section indices ---
63+
def find_section(name: str) -> int:
64+
nameU = name.upper()
65+
for k in range(idx, len(lines)):
66+
if lines[k].upper().startswith(nameU):
67+
return k
68+
return -1
69+
70+
sec_coord = find_section("NODE_COORD_SECTION")
71+
sec_demand = find_section("DEMAND_SECTION")
72+
sec_depot = find_section("DEPOT_SECTION")
73+
sec_eof = find_section("EOF")
74+
if sec_coord < 0 or sec_demand < 0 or sec_depot < 0:
75+
raise ValueError(
76+
"Missing one of required sections: NODE_COORD_SECTION / DEMAND_SECTION / DEPOT_SECTION"
77+
)
78+
if sec_eof < 0:
79+
sec_eof = len(lines)
80+
81+
# --- 4) Node coordinates ---
82+
coords: list[tuple[int, int]] = []
83+
k = sec_coord + 1
84+
while k < len(lines) and k < sec_demand:
85+
ln = lines[k]
86+
if ln.upper().endswith("SECTION"):
87+
break
88+
parts = ln.split()
89+
if len(parts) >= 3:
90+
x = int(parts[1])
91+
y = int(parts[2])
92+
coords.append((x, y))
93+
k += 1
94+
if len(coords) != dim:
95+
raise ValueError(f"NODE_COORD_SECTION count {len(coords)} != DIMENSION {dim}")
96+
97+
# --- 5) Demands ---
98+
demand: list[int] = [0] * dim
99+
k = sec_demand + 1
100+
while k < len(lines) and k < sec_depot:
101+
ln = lines[k]
102+
if ln.upper().endswith("SECTION"):
103+
break
104+
parts = ln.split()
105+
if len(parts) >= 2:
106+
idx1 = int(parts[0])
107+
dem = int(parts[1])
108+
demand[idx1 - 1] = dem
109+
k += 1
110+
111+
# --- 6) Depot section ---
112+
depots_1b: list[int] = []
113+
k = sec_depot + 1
114+
while k < len(lines) and k < sec_eof:
115+
ln = lines[k]
116+
if ln.upper().endswith("SECTION"):
117+
break
118+
v = ln.split()[0]
119+
if v == "-1":
120+
break
121+
depots_1b.append(int(v))
122+
k += 1
123+
if not depots_1b:
124+
raise ValueError("DEPOT_SECTION is empty")
125+
if depot_policy != "first":
126+
raise ValueError(f"Unsupported depot_policy={depot_policy}")
127+
depot0 = depots_1b[0] - 1
128+
129+
# --- 7) Distance matrix ---
130+
def euc2d(a: tuple[int, int], b: tuple[int, int]) -> float:
131+
return sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
132+
133+
D: list[list[float]] = [[0.0] * dim for _ in range(dim)]
134+
for u in range(dim):
135+
for v in range(dim):
136+
if u == v:
137+
D[u][v] = 0.0
138+
else:
139+
d = euc2d(coords[u], coords[v])
140+
D[u][v] = round(d) if euc2d_round else d
141+
142+
# --- 8) Return instance_data ---
143+
instance_data = {
144+
"n": dim,
145+
"VEHICLE_LIMIT": int(vehicle_limit),
146+
"CAPACITY": cap,
147+
"DEMAND": demand,
148+
"D": D,
149+
"DEPOT": depot0,
150+
}
151+
return instance_data
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import jijmodeling as jm
2+
3+
4+
def build_vrp_ilp() -> jm.Problem:
5+
"""Create Vehicle Routing Problem (VRP) ILP model.
6+
7+
Formulates the capacitated vehicle routing problem with a single depot
8+
using a standard integer linear programming approach. The objective is
9+
to minimize total travel distance subject to vehicle limit and capacity
10+
constraints.
11+
12+
Parameters (placeholders):
13+
- n (int): Number of nodes (including depot).
14+
- VEHICLE_LIMIT (int): Maximum number of vehicles allowed.
15+
- CAPACITY (int): Capacity of each vehicle.
16+
- DEMAND (ndarray, shape (n,)): Demand at each node.
17+
- D (ndarray, shape (n, n)): Distance matrix.
18+
- DEPOT (int): Index of the depot node (0-based).
19+
20+
Variables:
21+
- x[i, j] ∈ {0,1}: 1 if arc (i → j) is used, 0 otherwise.
22+
- y[i] ∈ ℤ, [0, CAPACITY]: Load upon arrival at node i.
23+
24+
Objective:
25+
- Minimize total distance: Σ_{i, j} D[i, j] · x[i, j].
26+
27+
Constraints:
28+
- Each customer visited exactly once (excl. depot).
29+
- Flow conservation for all non-depot nodes.
30+
- Departures from depot ≤ VEHICLE_LIMIT.
31+
- Capacity propagation (MTZ-style) to prevent subtours.
32+
- Capacity bounds at all customer nodes.
33+
34+
Returns:
35+
jm.Problem: JijModeling problem instance with variables, objective,
36+
and constraints for the capacitated VRP.
37+
"""
38+
# ------------ Placeholders ------------
39+
n = jm.Placeholder("n")
40+
VEHICLE_LIMIT = jm.Placeholder("VEHICLE_LIMIT")
41+
CAPACITY = jm.Placeholder("CAPACITY")
42+
DEMAND = jm.Placeholder("DEMAND", ndim=1) # shape (n,)
43+
D = jm.Placeholder("D", ndim=2) # shape (n,n)
44+
DEPOT = jm.Placeholder("DEPOT") # single depot index (0-based)
45+
46+
# ------------ Indices ------------
47+
i = jm.Element("i", belong_to=(0, n))
48+
j = jm.Element("j", belong_to=(0, n))
49+
h = jm.Element("h", belong_to=(0, n))
50+
51+
# ------------ Variables ------------
52+
x = jm.BinaryVar("x", shape=(n, n), description="arc i->j used")
53+
y = jm.IntegerVar(
54+
"y",
55+
shape=(n,),
56+
lower_bound=0,
57+
upper_bound=CAPACITY,
58+
description="load upon arrival at node",
59+
)
60+
61+
# ------------ Problem & Objective ------------
62+
problem = jm.Problem("vrp_ilp", sense=jm.ProblemSense.MINIMIZE)
63+
problem += jm.sum([i, j], D[i, j] * x[i, j]) # (10) minimize total distance
64+
65+
# ------------ Constraints ------------
66+
# (11) Each customer visited exactly once (excluding depot)
67+
problem += jm.Constraint(
68+
"customer_visited_once",
69+
jm.sum([(j, j != i)], x[i, j]) == 1,
70+
forall=[(i, i != DEPOT)],
71+
)
72+
73+
# (12) Flow conservation for non-depot nodes
74+
problem += jm.Constraint(
75+
"flow_conservation",
76+
jm.sum([(i, i != h)], x[i, h]) - jm.sum([(i, i != h)], x[h, i]) == 0,
77+
forall=[(h, h != DEPOT)],
78+
)
79+
80+
# (13) Vehicle limit: departures from depot ≤ VEHICLE_LIMIT
81+
problem += jm.Constraint(
82+
"vehicle_limit", jm.sum([(j, j != DEPOT)], x[DEPOT, j]) <= VEHICLE_LIMIT
83+
)
84+
85+
# (14) Capacity propagation (MTZ-style), exclude depot and i=j
86+
problem += jm.Constraint(
87+
"capacity_limit",
88+
y[j] >= y[i] + DEMAND[j] * x[i, j] - CAPACITY * (1 - x[i, j]),
89+
forall=[i, (j, (j != DEPOT) & (j != i))],
90+
)
91+
92+
# (15) Capacity bounds at nodes
93+
problem += jm.Constraint("capacity_limit_node_ub", y[i] <= CAPACITY, forall=[i])
94+
problem += jm.Constraint("capacity_limit_node_lb", DEMAND[i] <= y[i], forall=[i])
95+
96+
return problem
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import os
2+
import glob
3+
import jijmodeling as jm
4+
from ommx.artifact import ArtifactBuilder
5+
from model import build_vrp_ilp
6+
from dat_reader import read_vrp_tsplib
7+
from sol_reader import parse_vrp_solution_file
8+
9+
10+
def _pick_solution_file(sol_dir: str, base: str) -> str | None:
11+
"""Pick first existing solution file for a basename."""
12+
candidates = [
13+
os.path.join(sol_dir, f"{base}.opt.sol"),
14+
os.path.join(sol_dir, f"{base}.best.sol"),
15+
os.path.join(sol_dir, f"{base}.bst.sol"),
16+
os.path.join(sol_dir, f"{base}.sol"),
17+
]
18+
for p in candidates:
19+
if os.path.exists(p):
20+
return p
21+
return None
22+
23+
24+
def batch_process(
25+
inst_dir: str = "../../instances",
26+
sol_root: str = "../../solutions",
27+
output_directory: str = "./ommx_output",
28+
):
29+
"""
30+
Process instances from a QBench JSON file and corresponding solution files,
31+
convert them into OMMX artifacts, and save them to the output directory.
32+
33+
Parameters:
34+
inst_dir (str): Path to the gph..
35+
sol_root (str): Path to the root solutions directory.
36+
output_directory (str): Path to save the generated .ommx files.
37+
"""
38+
os.makedirs(output_directory, exist_ok=True)
39+
40+
problem = build_vrp_ilp()
41+
42+
vrp_paths = sorted(glob.glob(os.path.join(inst_dir, "*.vrp")))
43+
if not vrp_paths:
44+
print(f"[MIS] No .vrp files found under: {inst_dir}")
45+
return
46+
47+
processed_count = 0
48+
error_count = 0
49+
50+
for vrp_path in vrp_paths:
51+
base = os.path.splitext(os.path.basename(vrp_path))[0]
52+
try:
53+
print(f"[{base}] Reading: {vrp_path}")
54+
instance_data = read_vrp_tsplib(vrp_path, vehicle_limit=4, euc2d_round=True)
55+
56+
interpreter = jm.Interpreter(instance_data)
57+
ommx_instance = interpreter.eval_problem(problem)
58+
59+
sol_path = _pick_solution_file(sol_root, base)
60+
solution = None
61+
if sol_path:
62+
try:
63+
print(f" → Evaluating solution: {sol_path}")
64+
objective_value, solution_dict = parse_vrp_solution_file(
65+
sol_path,
66+
depot=0,
67+
demand=instance_data["DEMAND"],
68+
n=instance_data["n"],
69+
)
70+
solution = ommx_instance.evaluate(solution_dict)
71+
if (
72+
solution.feasible
73+
and abs(solution.objective - objective_value) < 1e-6
74+
):
75+
print(
76+
f" objective={solution.objective}, feasible={solution.feasible}"
77+
)
78+
else:
79+
print(
80+
" ! Objective mismatch or infeasible; will save instance only."
81+
)
82+
solution = None
83+
except Exception as sol_err:
84+
print(f" ! Solution evaluation failed: {sol_err}")
85+
solution = None
86+
87+
out_path = os.path.join(output_directory, f"{base}.ommx")
88+
if os.path.exists(out_path):
89+
os.remove(out_path)
90+
91+
builder = ArtifactBuilder.new_archive_unnamed(out_path)
92+
builder.add_instance(ommx_instance)
93+
if solution is not None:
94+
builder.add_solution(solution)
95+
builder.build()
96+
97+
print(f" ✓ Created: {out_path}")
98+
print("-" * 50)
99+
processed_count += 1
100+
101+
except Exception as e:
102+
print(f"[{base}] Error: {e}")
103+
print("-" * 50)
104+
error_count += 1
105+
106+
print(f"Batch complete — processed: {processed_count}, errors: {error_count}")
107+
108+
109+
if __name__ == "__main__":
110+
batch_process(
111+
inst_dir="../../instances",
112+
sol_root="../../solutions",
113+
output_directory="./ommx_output",
114+
)

0 commit comments

Comments
 (0)