def test_redistribute_to_one_route(self): r1 = [0, 1, 4, 6, 0] rd1_redistribute = RouteData(r1, route_l(r1, self.D), route_d(r1, self.d), None) r2 = [0, 2, 3, 5, 7, 0] rd2_recieving = RouteData(r2, route_l(r2, self.D), route_d(r2, self.d), None) result = do_redistribute_move(rd1_redistribute, rd2_recieving, self.D, strategy=LSOPT.FIRST_ACCEPT) self.assertEqual( len(result), 3, "The redistribute operator should return the redistributed and the new combined routes and the delta" ) self.assertEqual( result[1].route, [0, 2, 3, 5, 7, 1, 4, 6, 0], "It should be possible to insert all and they sould be appended to the route" ) result = do_redistribute_move(rd1_redistribute, rd2_recieving, self.D, strategy=LSOPT.BEST_ACCEPT) self.assertEqual( len(result), 3, "The redistribute operator should return the redistributed and the new combined routes and the delta" ) self.assertEqual(result[1].route, [0, 1, 2, 3, 4, 5, 6, 7, 0], "It should be possible to insert all")
def _refine_solution(sol, D, d, C, L, minimize_K): # refine until stuck at a local optima local_optima_reached = False while not local_optima_reached: sol = without_empty_routes(sol) if not minimize_K: sol.append(0) #make sure there is an empty route to move the pt to # improve with relocation and keep 2-optimal sol = do_local_search([do_1point_move, do_2opt_move], sol, D, d, C, L, LSOPT.BEST_ACCEPT) # try to redistribute the route with smallest demand sol = without_empty_routes(sol) routes = RouteData.from_solution(sol, D, d) min_rd = min(routes, key=lambda rd: rd.demand) routes.remove(min_rd) if not minimize_K: routes.append(RouteData()) if __debug__: log( DEBUG, "Applying do_redistribute_move on %s (%.2f)" % (str(sol), objf(sol, D))) redisribute_result = do_redistribute_move( min_rd, routes, D, d, C, L, strategy=LSOPT.FIRST_ACCEPT, #Note: Mole and Jameson do not specify exactly # how the redistribution is done (how many # different combinations are tried). # Increase the recombination_level if the # for more agressive and time consuming search # for redistributing the customers on other # routes. recombination_level=0) redisribute_delta = redisribute_result[-1] if (redisribute_delta is not None) and\ (minimize_K or redisribute_delta<0.0): updated_sol = RouteData.to_solution(redisribute_result[:-1]) if __debug__: log(DEBUG - 1, ("Improved from %s (%.2f) to %s (%.2f)" % (sol, objf(sol, D), updated_sol, objf(updated_sol, D))) + "using inter route heuristic do_redistribute_move\n") sol = updated_sol else: local_optima_reached = True if __debug__: log(DEBUG - 1, "No move with do_redistribute_move\n") return sol
def test_insert_from_empty(self): rd1 = RouteData() r2 = [0, 1, 3, 4, 0] rd2 = RouteData(r2, route_l(r2, self.D), route_d(r2, self.d), None) _, result_rd, result_delta = do_insert_move(rd1, rd2, self.D) self.assertEqual(result_rd.route, [0, 1, 3, 4, 0], "All should be inserted")
def _make_improving_move(self, r1, r2, C=None, d=None, L=None): D = self.D r1rd = RouteData(r1, route_l(r1, D), route_d(r1, d), None) r2rd = RouteData(r2, route_l(r2, D), route_d(r2, d), None) return do_2optstar_move(r1rd, r2rd, D, d=d, C=C, L=L, strategy=LSOPT.BEST_ACCEPT)
def _make_improving_move(self, C=None, d=None, L=None): D = self.D r1 = [0, 5, 6, 4, 0] r2 = [0, 1, 2, 3, 7, 0] r1rd = RouteData(r1, route_l(r1, D), route_d(r1, d), None) r2rd = RouteData(r2, route_l(r2, D), route_d(r2, d), None) return do_2point_move(r1rd, r2rd, D, d=d, C=C, L=L, strategy=LSOPT.BEST_ACCEPT)
def test_non_improving_move(self): D = self.D r1 = [0, 5, 6, 7, 0] r2 = [0, 1, 2, 3, 4, 0] r1d = RouteData(r1, route_l(r1, D), 3, r1[:-1]) r2d = RouteData(r2, route_l(r2, D), 4, r2[:-1]) result = do_2point_move(r1d, r2d, D) self.assertEqual( result, (None, None, None), "r1, r2 configuration should already be at local optima") result = do_2point_move(r2d, r1d, D) self.assertEqual( result, (None, None, None), "r1, r2 configuration should already be at local optima")
def _insert_one(self, strategy, result_target, msg_if_fail, C=None, L=None, to_insert=2): r1 = [0, 1, 3, 4, 0] rd1 = RouteData(r1, route_l(r1, self.D), route_d(r1, self.d), None) _, result_rd, result_delta = do_insert_move(to_insert, rd1, self.D, C=C, d=self.d, L=L, strategy=strategy) result_route = None if (result_rd is None) else result_rd.route self.assertEqual(result_route, result_target, msg_if_fail) if result_delta is not None: self.assertAlmostEqual( rd1.cost + result_delta, result_rd.cost, msg="The delta based and stored objective functions differ") self.assertAlmostEqual( rd1.cost + result_delta, route_l(result_rd.route, self.D), msg= "The delta based and recalculated objective functions differ")
def test_until_no_improvements_move(self): D = self.D r1 = [0, 4, 6, 1, 0] r2 = [0, 7, 2, 3, 5, 0] r1d = RouteData(r1, route_l(r1, D), -1, r1[:-1]) r2d = RouteData(r2, route_l(r2, D), -1, r2[:-1]) while True: result = do_2point_move(r1d, r2d, D) if result[0] is None: break # unpack result r1d, r2d, delta = result # unconstrained should run until other route is empty self.assertEqual(r2d[0], [0, 1, 2, 3, 4, 0], "should reach a local optima")
def test_redistribute_to_two_routes(self): r1 = [0, 1, 4, 6, 0] rd1_redistribute = RouteData(r1, route_l(r1, self.D), route_d(r1, self.d), None) r2 = [0, 2, 3, 0] rd2_recieving = RouteData(r2, route_l(r2, self.D), route_d(r2, self.d), None) r3 = [0, 7, 5, 0] rd3_recieving = RouteData(r3, route_l(r3, self.D), route_d(r3, self.d), None) # depending on how the recombinations are made, the results differ FI = LSOPT.FIRST_ACCEPT BE = LSOPT.BEST_ACCEPT tests = [(0, "FIRST", FI, ([0, 2, 3, 1, 4, 0], [0, 7, 5, 6, 0])), (0, "BEST", BE, ([0, 1, 2, 3, 4, 0], [0, 7, 6, 5, 0])), (1, "FIRST", FI, ([0, 2, 3, 4, 1, 0], [0, 7, 5, 6, 0])), (1, "BEST", BE, ([0, 1, 2, 3, 4, 0], [0, 7, 6, 5, 0])), (2, "FIRST", FI, ([0, 2, 3, 1, 4, 0], [0, 7, 5, 6, 0])), (2, "BEST", BE, ([0, 1, 2, 3, 4, 0], [0, 7, 6, 5, 0])), (3, "FIRST", FI, ([0, 2, 3, 4, 1, 0], [0, 7, 5, 6, 0])), (3, "BEST", BE, ([0, 1, 2, 3, 4, 0], [0, 7, 6, 5, 0]))] for recombination_level, strategy_name, strategy, target_result in tests: result = do_redistribute_move( rd1_redistribute, [rd2_recieving, rd3_recieving], self.D, C=4.0, d=self.d, strategy=strategy, recombination_level=recombination_level) #print("QUALITY", strategy_name, "/", recombination_level, "delta", result[-1]) self.assertEqual( len(result), 4, "The redistribute operator should return the redistributed and the new combined routes and the delta" ) self.assertEqual( result[1].route, target_result[0], "It should be possible to insert all and they sould be appended to the route on recombination level %d with strategy %s" % (recombination_level, strategy_name)) self.assertEqual( result[2].route, target_result[1], "It should be possible to insert all and they sould be appended to the route on recombination level %d with strategy %s" % (recombination_level, strategy_name))
def _insert_many(self, strategy, result_target, msg_if_fail, C=None, L=None): to_insert = [3, 2] r1 = [0] + to_insert + [0] rd1 = RouteData(r1, route_l(r1, self.D), route_d(r1, self.d), None) r2 = [0, 1, 4, 0] rd2 = RouteData(r2, route_l(r2, self.D), route_d(r2, self.d), None) _, list_input_result_rd, list_input_result_delta = do_insert_move( to_insert, rd2, self.D, C=C, d=self.d, L=L, strategy=strategy) _, rd_input_result_rd, rd_input_result_delta = do_insert_move( rd1, rd2, self.D, C=C, d=self.d, L=L, strategy=strategy) list_input_result_route = None if ( list_input_result_rd is None) else list_input_result_rd.route rd_input_result_route = None if ( rd_input_result_rd is None) else rd_input_result_rd.route self.assertEqual(list_input_result_route, result_target, msg_if_fail) self.assertEqual( list_input_result_route, rd_input_result_route, "Inserting from a list or RouteData should lead to same result") if list_input_result_delta is not None: self.assertAlmostEqual( rd2.cost + list_input_result_delta, list_input_result_rd.cost, msg="The delta based and stored objective functions differ") self.assertAlmostEqual( rd2.cost + list_input_result_delta, route_l(list_input_result_rd.route, self.D), msg= "The delta based and recalculated objective functions differ") if rd_input_result_delta is not None: self.assertAlmostEqual( rd2.cost + rd_input_result_delta, rd_input_result_rd.cost, msg="The delta based and stored objective functions differ") self.assertAlmostEqual( rd2.cost + rd_input_result_delta, route_l(rd_input_result_rd.route, self.D), msg= "The delta based and recalculated objective functions differ")
def test_redistribute_does_not_fit(self): demands = [0.0, 1, 1, 1, 1, 1, 1, 1] r1 = [0, 1, 2, 0] rd1_redistribute = RouteData(r1, route_l(r1, self.D), route_d(r1, demands), None) r2 = [0, 3, 4, 5, 6, 7, 0] rd2_recieving = RouteData(r2, route_l(r2, self.D), route_d(r2, demands), None) result = do_redistribute_move(rd1_redistribute, [rd2_recieving], self.D, C=6.0, d=self.d, strategy=LSOPT.BEST_ACCEPT, recombination_level=1) # Fails -> None,None...None is returned (no partial fits) self.assertTrue( all(rp == None for rp in result), "The redistribute operator should return the redistributed and the new combined routes and the delta" )
def test_redistribute_find_best_fit(self): demands = [0.0, 1, 1, 2, 3, 1, 3, 1] r1 = [0, 1, 4, 6, 0] rd1_redistribute = RouteData(r1, route_l(r1, self.D), route_d(r1, demands), None) r2 = [0, 2, 3, 0] rd2_recieving = RouteData(r2, route_l(r2, self.D), route_d(r2, demands), None) r3 = [0, 5, 0] rd3_recieving = RouteData(r3, route_l(r3, self.D), route_d(r3, demands), None) r4 = [0, 7, 0] rd4_recieving = RouteData(r4, route_l(r4, self.D), route_d(r4, demands), None) result = do_redistribute_move( rd1_redistribute, [rd2_recieving, rd3_recieving, rd4_recieving], self.D, C=4.0, d=demands, strategy=LSOPT.BEST_ACCEPT, recombination_level=1) self.assertEqual( len(result), 5, "The redistribute operator should return the redistributed and the new combined routes and the delta" ) self.assertEqual(result[0].route, [0, 0], "It should be possible to insert all customers") self.assertEqual(_normalise_route_order(result[1].route), [0, 1, 2, 3, 0], "n1 should be redistributed to first route") self.assertEqual(_normalise_route_order(result[2].route), [0, 4, 5, 0], "n4 should be redistributed to first route") self.assertEqual(_normalise_route_order(result[3].route), [0, 6, 7, 0], "n6 should be redistributed to first route")
def do_1point_move( route1_data, route2_data, D, d=None, C=None, L=None, # constraints strategy=LSOPT.FIRST_ACCEPT, best_delta=None): """ Move one point from route1 to route2. Tries all possible combinations of moving a node from route 1 to any valid position in route 2. Sometimes called "relocate" (e.g., Bräysy & M. Gendreau 2005, Savelsbergh 1992), but here we use the name "one point move" (Groër et al 2010) to differentiate from the intra-route version. If an improving move was found and made, operation returns new routes in same format as route_data inputs, but if C and d are not given the 3. field of the tuple is None. If there is no improving move, returns None. Groër, C., Golden, B. and Wasil, E., 2010. A library of local search heuristics for the vehicle routing problem. Mathematical Programming Computation, 2(2), pp.79-101. Bräysy, O. & Gendreau, M. 2005. Vehicle Routing Problem, Part I: Route Construction and Local Search Algorithms Transportation Science 39(1), pp. 104–118 Savelsbergh, M. W. P. 1992. The vehicle routing problem with time windows: Minimizing route duration. J.Comput. 4 146-154 """ if route1_data == route2_data: return None, None, None # unpack route, current cost, and current demand route1, r1_l, r1_d, _ = route1_data route2, r2_l, r2_d, _ = route2_data if not best_delta: best_delta = 0 best_move = None accept_move = False for i in xrange(1, len(route1) - 1): remove_after = route1[i - 1] to_move = route1[i] remove_before = route1[i + 1] remove_delta = D[remove_after,remove_before]\ -D[remove_after,to_move]\ -D[to_move,remove_before] # capacity constraint feasibility check if C and r2_d + d[to_move] - C_EPS > C: continue for j in xrange(1, len(route2)): insert_after = route2[j - 1] insert_before = route2[j] insert_delta = D[insert_after,to_move]+D[to_move,insert_before]\ -D[insert_after,insert_before] # route cost constraint feasibility check if L and r2_l + insert_delta - S_EPS > L: continue delta = remove_delta + insert_delta if delta + S_EPS < best_delta: best_delta = delta best_move = (i, j, remove_delta, insert_delta) if strategy == LSOPT.FIRST_ACCEPT: accept_move = True # break j-loop break # break i-loop if accept_move: break if best_move: # unpack best move i, j, remove_delta, insert_delta = best_move to_move = route1[i] return (RouteData(route1[:i] + route1[i + 1:], r1_l + remove_delta, None if not C else r1_d - d[to_move]), RouteData(route2[:j] + [to_move] + route2[j:], r2_l + insert_delta, None if not C else r2_d + d[to_move]), remove_delta + insert_delta) return None, None, None
def do_3optstar_3route_move( route1_data, route2_data, route3_data, D, demands=None, C=None, L=None, # constraints strategy=LSOPT.FIRST_ACCEPT, best_delta=None): """ 3-opt* inter-route local search operation for the symmetric distances D Remove 3 edges from different routes and check if reconnecting them in any configuration would improve the solution, while also making sure the move does not violate constraints. For two route checks, the first and second route can be same i.e. route1_data==route2_data. However, if route2_data==route3_data or route1_data==route3_data a ValueError is raised. """ if route1_data==route2_data or\ route2_data==route3_data or\ route1_data==route3_data: raise ValueError("Use do_3opt_move to find intra route moves") # make sure we have the aux data for constant time feasibility checks if not isinstance(route1_data, RouteData): route1_data = RouteData(*route1_data) if not isinstance(route2_data, RouteData): route2_data = RouteData(*route2_data) if not isinstance(route3_data, RouteData): route3_data = RouteData(*route3_data) if not route1_data.aux_data_updated: route1_data.update_auxiliary_data(D, demands) if not route2_data.aux_data_updated: route2_data.update_auxiliary_data(D, demands) if not route3_data.aux_data_updated: route3_data.update_auxiliary_data(D, demands) if not best_delta: best_delta = 0 best_move = None accept_move = False # segment end nodes and cumulative d and l at those nodes # Note the indexing of segment end nodes here: # # __0/i 1__ route 1 # / \ # n0---2/j 3--n0 route 2 # \__ __/ # 4/k 5 route 3 # end_n = [0] * 6 cum_d = [0] * 6 cum_l = [0] * 6 for i in xrange(0, len(route1_data.route) - 1): end_n[0] = route1_data.route[i] end_n[1] = route1_data.route[i + 1] # make it so that i<j if route1==route2 for j in xrange(0, len(route2_data.route) - 1): # the edge endpoints end_n[2] = route2_data.route[j] end_n[3] = route2_data.route[j + 1] if C: cum_d[0] = route1_data.fwd_d[i] cum_d[1] = route1_data.rwd_d[i + 1] cum_d[2] = route2_data.fwd_d[j] cum_d[3] = route2_data.rwd_d[j + 1] if L: cum_l[0] = route1_data.fwd_l[i] cum_l[1] = route1_data.rwd_l[i + 1] cum_l[2] = route2_data.fwd_l[j] cum_l[3] = route2_data.rwd_l[j + 1] for k in xrange(0, len(route3_data.route) - 1): # the edge endpoints end_n[4] = route3_data.route[k] end_n[5] = route3_data.route[k + 1] if C: cum_d[4] = route3_data.fwd_d[k] cum_d[5] = route3_data.rwd_d[k + 1] if L: cum_l[4] = route3_data.fwd_l[k] cum_l[5] = route3_data.rwd_l[k + 1] removed_weights = D[end_n[0],end_n[1]]+\ D[end_n[2],end_n[3]]+\ D[end_n[4],end_n[5]] for e1, e2, e3 in MOVES_3OPTSTAR_3ROUTES: e1_wt = D[end_n[e1[0]], end_n[e1[1]]] e2_wt = D[end_n[e2[0]], end_n[e2[1]]] e3_wt = D[end_n[e3[0]], end_n[e3[1]]] delta = e1_wt + e2_wt + e3_wt - removed_weights if ((delta + S_EPS < best_delta) and (not C or (cum_d[e1[0]] + cum_d[e1[1]] - C_EPS < C and cum_d[e2[0]] + cum_d[e2[1]] - C_EPS < C and cum_d[e3[0]] + cum_d[e3[1]] - C_EPS < C)) and (not L or (cum_l[e1[0]] + cum_l[e1[1]] + e1_wt - S_EPS < L and cum_l[e2[0]] + cum_l[e2[1]] + e2_wt - S_EPS < L and cum_l[e3[0]] + cum_l[e3[1]] + e3_wt - S_EPS < L))): best_move = ((i, j, k), (e1, e2, e3), delta) best_delta = delta if strategy == LSOPT.FIRST_ACCEPT: accept_move = True break # move loop if accept_move: break # k loop if accept_move: break # j loop if accept_move: break # i loop if best_move: # unpack the move (best_ijk, best_edges, best_delta) = best_move routes = [route1_data, route2_data, route3_data] ret = [] for edge in best_edges: # Unfortunately, the splicing is a bit complex, but basic idea # is to use the move and its edges in best_edges to get the # route to splice from and if the splice should be inverted. # # edge[i]//2 gets the route index of the move endpoint # edge[i]%2 tells if it is the head segment or tail segment # # ___a c___ r1 # / \ # n0 n1 # \___ ___/ # b d r2 # r1_idx = edge[0] // 2 r2_idx = edge[1] // 2 r1_i = best_ijk[r1_idx] r2_i = best_ijk[r1_idx] a = routes[r1_idx].route[r1_i] b = routes[r1_idx].route[r1_i + 1] c = routes[r2_idx].route[r2_i] d = routes[r2_idx].route[r2_i + 1] # combine the tails of route1 and route2 if edge[0] % 2 and edge[1] % 2: new_route = (routes[r1_idx].route[None:r1_i:-1] + routes[r2_idx].route[r2_i + 1:None:1]) new_cost = (D[b, d] + routes[r1_idx].rwd_l[r1_i + 1] + routes[r2_idx].rwd_l[r2_i + 1]) new_demand = (routes[r1_idx].rwd_d[r1_i + 1] + routes[r2_idx].rwd_d[r2_i + 1]) # combine the head of route1 with the tail of route2 elif not edge[0] % 2 and edge[1] % 2: new_route = (routes[r1_idx].route[None:r1_i + 1:1] + routes[r2_idx].route[r2_i + 1:None:1]) new_cost = (D[a, d], routes[r1_idx].fwd_l[r1_i] + routes[r2_idx].rwd_l[r2_i + 1]) new_demand = (routes[r1_idx].fwd_d[r1_i] + routes[r2_idx].rwd_d[r2_i + 1]) # combine the heads of route1 and route2 elif not edge[0] % 2 and not edge[1] % 2: new_route = (routes[r1_idx].route[None:r1_i + 1:1] + routes[r2_idx].route[r2_i:None:-1]) new_cost = (D[a, c], routes[r1_idx].fwd_l[r1_i] + routes[r2_idx].fwd_l[r2_i]) new_demand = (routes[r1_idx].fwd_d[r1_i] + routes[r2_idx].fwd_d[r2_i]) else: assert False, "there is no move to combine the tail of"+\ "route1 with the (reversed) head of route2" ret.append(RouteData(new_route, new_cost, new_demand)) ret.append(best_delta) return tuple(ret) return None, None, None, None
def do_3optstar_2route_move( route1_data, route2_data, D, demands=None, C=None, L=None, # constraints strategy=LSOPT.FIRST_ACCEPT, best_delta=None): if route1_data == route2_data: raise ValueError("Use do_3opt_move to find intra route moves") # make sure we have the aux data for constant time feasibility checks if not isinstance(route1_data, RouteData): route1_data = RouteData(*route1_data) if not isinstance(route2_data, RouteData): route2_data = RouteData(*route2_data) if not route1_data.aux_data_updated: route1_data.update_auxiliary_data(D, demands) if not route2_data.aux_data_updated: route2_data.update_auxiliary_data(D, demands) if not best_delta: best_delta = 0 best_move = None accept_move = False # segment end nodes and cumulative d and l at those nodes is specified # by the indices i,j,k. In the two route case this means: # # _0/i 1_2/j 3_ route 1 # / \ # n0 n0 # \____ _____/ # 4/k 5 route 2 # end_n = [0] * 6 cum_d = [0] * 6 cum_l = [0] * 6 for i in xrange(0, len(route1_data.route) - 1): end_n[0] = route1_data.route[i] end_n[1] = route1_data.route[i + 1] # make it so that i<j for j in xrange(i + 1, len(route1_data.route) - 1): # the edge endpoints end_n[2] = route2_data.route[j] end_n[3] = route2_data.route[j + 1] if C: cum_d[0] = route1_data.fwd_d[i] cum_d[1] = route1_data.rwd_d[i+1]\ -route1_data.rwd_d[j+1] cum_d[2] = route1_data.fwd_d[j]\ -route1_data.fwd_d[i] cum_d[3] = route1_data.rwd_d[j + 1] if L: cum_l[0] = route1_data.fwd_l[i] cum_l[1] = route1_data.rwd_l[i+1]\ -route1_data.rwd_l[j] cum_l[2] = route1_data.fwd_l[j]\ -route1_data.fwd_l[i+1] cum_l[3] = route1_data.rwd_l[j + 1] for k in xrange(0, len(route2_data.route) - 1): # the edge endpoints end_n[4] = route2_data.route[k] end_n[5] = route2_data.route[k + 1] if C: cum_d[2] = route2_data.fwd_d[k] cum_d[3] = route2_data.rwd_d[k + 1] if L: cum_l[2] = route2_data.fwd_l[k] cum_l[3] = route2_data.rwd_l[k + 1] removed_weights = D[end_n[0],end_n[1]]+\ D[end_n[2],end_n[3]]+\ D[end_n[4],end_n[5]] for e1, e2, e3 in MOVES_3OPTSTAR_2ROUTES: e1_wt = D[end_n[e1[0]], end_n[e1[1]]] e2_wt = D[end_n[e2[0]], end_n[e2[1]]] e3_wt = D[end_n[e3[0]], end_n[e3[1]]] delta = e1_wt + e2_wt + e3_wt - removed_weights if ((delta + S_EPS < best_delta) and (not C or (cum_d[e1[0]] + cum_d[e1[1]] - C_EPS < C and cum_d[e2[0]] + cum_d[e2[1]] + cum_d[e3[1]] - C_EPS < C)) and (not L or (cum_l[e1[0]] + cum_l[e1[1]] + e1_wt - S_EPS < L and cum_l[e2[0]] + cum_l[e2[1]] + cum_l[e3[1]] + e2_wt + e3_wt - S_EPS < L))): best_move = ((i, j, k), (e1, e2, e3), delta) best_delta = delta if strategy == LSOPT.FIRST_ACCEPT: accept_move = True break # move loop if accept_move: break # k loop if accept_move: break # j loop if accept_move: break # i loop if best_move: raise NotImplementedError("Not yet completely implemented")
def do_chain_move( route1_data, route2_data, route3_data, D, d=None, C=None, L=None, # constraints strategy=LSOPT.FIRST_ACCEPT, best_delta=None): """ This is the "pair" operation described in Wren and Holliday (1972). It involves moving a repacing a node on route 2 with a node on route 1. The replaced node is then inserted on route 3 (if able). Route 1!=2!=3 This is a very expensive operation, corresponding to 5-opt with chain length of 1, use with care. Wren, A. and Holliday, A., 1972. Computer scheduling of vehicles from one or more depots to a number of delivery points. Journal of the Operational Research Society, 23(3), pp.333-344. """ if not best_delta: best_delta = 0 best_move = None accept_move = False route1, r1_l, r1_d, _ = route1_data route2, r2_l, r2_d, _ = route2_data route3, r3_l, r3_d, _ = route3_data for i in xrange(1, len(route1) - 1): remove_after = route1[i - 1] to_move = route1[i] remove_before = route1[i + 1] remove_delta = +D[remove_after,remove_before]\ -D[remove_after,to_move]\ -D[to_move,remove_before] for j in xrange(1, len(route2) - 1): replace_after = route2[j - 1] to_replace = route2[j] replace_before = route2[j + 1] # can do capacity constraint feasibility check here if C and (r2_d - d[to_replace] + d[to_move] > C or r3_d + d[to_replace] > C): continue replace_delta = +D[replace_after,to_move]\ +D[to_move,replace_before]\ -D[replace_after,to_replace]\ -D[to_replace,replace_before] if L and r2_l + replace_delta > L: continue for k in xrange(1, len(route3)): insert_after = route3[k - 1] insert_before = route3[k] insert_delta = +D[insert_after, to_replace]\ +D[to_replace, insert_before]\ -D[insert_after,insert_before] if L and (r3_l + insert_delta > L): continue # check if this is best so far delta = remove_delta + replace_delta + insert_delta if delta + S_EPS < best_delta: best_delta = delta best_move = (i, j, k, remove_delta, replace_delta, insert_delta) if strategy == LSOPT.FIRST_ACCEPT: accept_move = True break # k loop if accept_move: break # j loop if accept_move: break # i loop if best_move: # unpack best move i, j, k, remove_delta, replace_delta, insert_delta = best_move to_move = route1[i] to_replace = route2[j] return (RouteData(route1[:i] + route1[i + 1:], r1_l + remove_delta, None if not C else r1_d - d[to_move]), RouteData(route2[:j] + [to_move] + route2[j + 1:], r2_l + replace_delta, None if not C else r2_d - d[to_replace] + d[to_move]), RouteData(route3[:k] + [to_replace] + route3[k:], r3_l + insert_delta, None if not C else r3_d + d[to_replace]), best_delta) return None, None, None, None
def do_insert_move(unrouted, recieving_route_data, D, d=None, C=None, L=None, strategy=LSOPT.FIRST_ACCEPT, best_delta=None): """ Try to insert (unrouted) node(s) on the existing recieving route. The operation succeeds only if all customers can be inserted on the recieving route. The difference to the one point move is that there is no delta for removing the unrouted node from its RouteData / list. Returns a 3-tuple: An empty RouteData, A copy of the updated RouteData and the route cost (usually route length) delta; or (None,None,None) if the insertion fails. * unrouted Can be a single customer (int), a list of those, or a route (RouteData). If RouteData is given its .demand field must be set. * recieving_route_data is the route (RouteData) that the customers to insert are inserted to. Its .demand field must be set. * D,d,C,L the problem definition and constraints * strategy is the applied loca search strategy to use. Please note that if there is more than one customer to insert, the maximum route cost constraint L is set, and strategy is set to first accept, the best insertion position for the customers are still searched for to leave the most amount of room for subsequent insertions. """ if type(unrouted) is int: unrouted_demand = d[unrouted] if C else 0 unrouted = [unrouted] if isinstance(unrouted, RouteData): unrouted_demand = unrouted.demand if C else 0 unrouted = unrouted.route[1:-1] elif C: unrouted_demand = sum(d[n] for n in unrouted) # make an ansatz for fitting the inserted customers into ansatz_route = list(recieving_route_data.route) ansatz_d = recieving_route_data.demand ansatz_l = recieving_route_data.cost total_delta = 0 # is not possible because of constraint C if C and ansatz_d + unrouted_demand - C_EPS > C: return None, None, None for ni, node in enumerate(unrouted): best_insert_pos = None if strategy == LSOPT.BEST_ACCEPT or L: # need to find a place where it can be inserted (if at all) best_insert_delta = None for i in xrange(1, len(ansatz_route)): insert_after = ansatz_route[i - 1] insert_before = ansatz_route[i] insert_delta = +D[insert_after,node]\ +D[node,insert_before]\ -D[insert_after,insert_before] if not L or ansatz_l + insert_delta - S_EPS <= L: if (best_insert_delta is None) or\ (insert_delta<best_insert_delta): best_insert_pos = i best_insert_delta = insert_delta # to avoid complexity, only allow early termination with # FIRST_ACCEPT when customers are inserted one # by one if strategy == LSOPT.FIRST_ACCEPT and ni == len( unrouted) - 1: break # i loop else: # we know it fits, just append it best_insert_pos = -1 insert_after = ansatz_route[-2] # the last non-depot node best_insert_delta = +D[insert_after, node] + D[node, 0] - D[insert_after, 0] # no L valid routing if best_insert_pos is None: return None, None, None else: if C: ansatz_d += d[node] ansatz_l += best_insert_delta # we found a valid insertion location accept it ansatz_route = ansatz_route[:best_insert_pos]\ +[node]\ +ansatz_route[best_insert_pos:] total_delta += best_insert_delta # must be better than the preset level (if applicable) if (best_delta is not None) and (total_delta + S_EPS > best_delta): return None, None, None return RouteData(), RouteData(ansatz_route, ansatz_l, ansatz_d), total_delta
def _improvement_callback(route_data, callback_datastructures, sweep_rhos, sweep_phis, sweep_J_pos, step_inc, routed): """ This callback implements the Gillett and Miller (1974) improvement heuristic. It involves trying to remove a node and insertion of several candidates. Therefore, the algorithm involves Steps 8-15 in the appedix of Gillett and Miller (1974). """ # unpack callback data structures (packed in pack_datastructures) N, D, NN_D, d, C, L, node_to_pos, pos_to_node, avr = callback_datastructures # do not try to improve, if the J node is already routed (already Swept # full circle) node_J = pos_to_node[sweep_J_pos] if routed[node_J]: # nothing to do, no nodes added, no nodes removed, route is complete return route_data, [], [], True # unpack route information, (route, route_cost, route_demand) D1_route, D1, D1_demand, D1_nodes = route_data # G&M Step 8. This messy looking line vectorizes the minimization of # R(K(I))+An(K(I))*AVR # # What makes it a little more messier, is that the angle is pointing # "the right way" depending on the cw/ccw direction (encoded in step_inc) # +1 is there to convert indexing as there is no depot in node_rho_phis route_K_nodes = list(D1_nodes[1:]) route_K_positions = [node_to_pos[n] for n in route_K_nodes] route_rhos = sweep_rhos[route_K_positions] route_phis = sweep_phis[route_K_positions] rem_choose_function = route_rhos + route_phis * avr to_remove_node_KII = route_K_nodes[np.argmin(rem_choose_function)] if __debug__: log( DEBUG - 2, "G&M improvement phase for route %s (%.2f). Trying to replace KII=n%d." % (str(D1_route), D1, to_remove_node_KII)) log( DEBUG - 3, "This is due to R(K(I))+An(K(I)*AVR = %s" % str(zip(route_K_nodes, route_phis, list(rem_choose_function)))) # take the node J-1 (almost always the node last added on the route) sweep_prev_of_J_pos = _step(sweep_J_pos, -step_inc, N - 2) prev_node_J = pos_to_node[sweep_prev_of_J_pos] # Get the insertion candidates try: candidate_node_JJX = next((node_idx for node_idx, _ in NN_D[prev_node_J] if not routed[node_idx])) except StopIteration: if __debug__: log(DEBUG - 2, "G&M Step 9, not enough unrouted nodes left for JJX.") log(DEBUG - 2, "-> EXIT with no changes") return route_data, [], [], False try: candidate_node_JII = next( (node_idx for node_idx, _ in NN_D[candidate_node_JJX] if (not routed[node_idx] and node_idx != candidate_node_JJX))) except StopIteration: candidate_node_JII = None # construct and route to get the modified route cost D2 D2_route_nodes = OrderedSet(D1_nodes) D2_route_nodes.remove(to_remove_node_KII) D2_route_nodes.add(candidate_node_JJX) D2_route, D2 = solve_tsp(D, list(D2_route_nodes)) D2_demand = D1_demand - d[to_remove_node_KII] + d[ candidate_node_JJX] if C else 0 ## G&M Step 9 if not ((L and D2 - S_EPS < L) and (C and D2_demand - C_EPS <= C)): if __debug__: log( DEBUG - 2, "G&M Step 9, rejecting replacement of KII=n%d with JJX=n%d" % (to_remove_node_KII, candidate_node_JJX)) log( DEBUG - 3, " which would have formed a route %s (%.2f)" % (str(D2_route), D2)) if (C and D2_demand - C_EPS > C): log(DEBUG - 3, " violating the capacity constraint") else: log(DEBUG - 3, " violating the maximum route cost constraint") log(DEBUG - 2, " -> EXIT with no changes") # go to G&M Step 10 -> # no changes, no skipping, route complete return route_data, [], [], True ## G&M Step 11 D3_nodes = OrderedSet() # the min. dist. from 0 through J,J+1...J+4 to J+5 D4_nodes = OrderedSet() # the min. dist. /w JJX excluded, KII included D6_nodes = OrderedSet() # the min. dist. /w JJX and JII excl., KII incl. JJX_in_chain = False JII_in_chain = False # step back so that the first node to lookahead is J lookahead_pos = sweep_prev_of_J_pos for i in range(5): lookahead_pos = _step(lookahead_pos, step_inc, N - 2) lookahead_node = pos_to_node[lookahead_pos] if routed[lookahead_node]: continue D3_nodes.add(lookahead_node) if lookahead_node == candidate_node_JJX: # inject KII instead of JJX D4_nodes.add(to_remove_node_KII) D6_nodes.add(to_remove_node_KII) JJX_in_chain = True elif lookahead_node == candidate_node_JII: D4_nodes.add(lookahead_node) JII_in_chain = True else: D4_nodes.add(lookahead_node) D6_nodes.add(lookahead_node) # if JJX was not in the sequence J, J+1, ... J+5 if not JJX_in_chain: if __debug__: log( DEBUG - 2, "G&M Step 11, JJX=n%d not in K(J)..K(J+4)" % candidate_node_JJX) log(DEBUG - 3, " which consists of nodes %s" % str(list(D3_nodes))) log(DEBUG - 2, "-> EXIT with no changes") # go to G&M Step 10 -> # no changes, no skipping, route complete return route_data, [], [], True # The chain *end point* J+5 last_chain_pos = _step(lookahead_pos, step_inc, N - 2) last_chain_node = pos_to_node[last_chain_pos] if routed[last_chain_node]: last_chain_node = 0 # D3 -> EVALUATE the MINIMUM distance from 0 through J,J+1...J+4 to J+5 _, D3 = _shortest_path_through_nodes(D, 0, last_chain_node, D3_nodes) # D4 -> DETERMINE the MINIMUM distance with JJX excluded, KII included _, D4 = _shortest_path_through_nodes(D, 0, last_chain_node, D4_nodes) if not (D1 + D3 < D2 + D4): ## G&M Step 12 if __debug__: log( DEBUG - 2, "G&M Step 12, accept an improving move where " + "KII=n%d is removed and JJX=n%d is added" % (to_remove_node_KII, candidate_node_JJX)) log(DEBUG - 3, " which forms a route %s (%.2f)" % (str(D2_route), D2)) log(DEBUG - 2, " -> EXIT and continue adding nodes") ignored_nodes = [to_remove_node_KII] if candidate_node_JJX != node_J: ignored_nodes += [node_J] # go to G&M Step 4 -> # route changed, KII removed and skip current node J, not complete return RouteData(D2_route, D2, D2_demand, D2_route_nodes),\ [candidate_node_JJX], ignored_nodes, False else: ## G&M Step 13 # JII and JJX (checked earlier) should be in K(J)...K(J+4) to continue if not JII_in_chain: if __debug__: if candidate_node_JII is None: log(DEBUG - 2, "G&M Step 13, no unrouted nodes left for JII.") else: log( DEBUG - 2, "G&M Step 13, JII=n%d not in K(J)..K(J+4)" % candidate_node_JII) log(DEBUG - 3, " which consists of nodes %s" % str(list(D3_nodes))) log(DEBUG - 2, "-> EXIT with no changes") # go to G&M Step 10 -> no changes, no skipping, route complete return route_data, [], [], True # construct and route to get the modified route cost D2 D5_route_nodes = D2_route_nodes D5_route_nodes.add(candidate_node_JII) D5_route, D5 = solve_tsp(D, list(D5_route_nodes)) D5_demand = D2_demand + d[candidate_node_JII] if C else 0 if not ((L and D5 - S_EPS < L) and (C and D5_demand - C_EPS <= C)): if __debug__: log( DEBUG - 2, "G&M Step 13, rejecting replacement of KII=n%d with JJX=n%d and JII=n%d" % (to_remove_node_KII, candidate_node_JJX, candidate_node_JII)) log( DEBUG - 3, " which would have formed a route %s (%.2f)" % (str(D5_route), D5)) if D5_demand - C_EPS > C: log(DEBUG - 3, " violating the capacity constraint") else: log(DEBUG - 3, " violating the maximum route cost constraint") log(DEBUG - 2, "-> EXIT with no changes") # go to G&M Step 10 -> no changes, no skipping, route complete return route_data, [], [], True ## G&M Step 14 # D6 -> DETERMINE the MINIMUM distance with JJX and JII excluded and # KII ncluded _, D6 = _shortest_path_through_nodes(D, 0, last_chain_node, D6_nodes) if D1 + D3 < D5 + D6: if __debug__: log( DEBUG - 2, "G&M Step 14, rejecting replacement of KII=n%d with JJX=n%d and JII=n%d" % (to_remove_node_KII, candidate_node_JJX, candidate_node_JII)) log( DEBUG - 3, " which would have formed a route %s (%.2f)" % (str(D5_route), D5)) log(DEBUG - 2, "-> EXIT with no changes") # go to G&M Step 10 -> no changes, no skipping, route complete return route_data, [], [], True ## G&M Step 15 if __debug__: log( DEBUG - 2, "G&M Step 15, accept improving move where " + "KII=n%d is removed and JJX=n%d and JII=n%d are added" % (to_remove_node_KII, candidate_node_JJX, candidate_node_JII)) log(DEBUG - 3, " which forms a route %s (%.2f)" % (str(D2_route), D2)) log(DEBUG - 2, " -> EXIT and continue adding nodes") ignored_nodes = [to_remove_node_KII] if candidate_node_JJX != node_J and candidate_node_JII != node_J: ignored_nodes += [node_J] # go to G&M Step 4 -> # route changed, KII removed and skip current node J, not complete return RouteData(D5_route, D5, D5_demand, D5_route_nodes),\ [candidate_node_JJX, candidate_node_JII],\ ignored_nodes, False
def do_2optstar_move( route1_data, route2_data, D, d=None, C=None, L=None, # constraints strategy=LSOPT.FIRST_ACCEPT, best_delta=None): """ 2-opt* inter-route local search operation for the symmetric distances D Remove 2 edges from different routes and check if swapping the edge halves (in two different ways) would yield an improvement, while making sure the move does not violate constraints. """ # use 2-opt if route1_data == route2_data: raise ValueError("Use do_2opt_move to find intra route moves") # make sure we have the aux data for constant time feasibility checks if not isinstance(route1_data, RouteData): route1_data = RouteData(*route1_data) if not isinstance(route2_data, RouteData): route2_data = RouteData(*route2_data) if not route1_data.aux_data_updated: route1_data.update_auxiliary_data(D, d) if not route2_data.aux_data_updated: route2_data.update_auxiliary_data(D, d) if not best_delta: best_delta = 0 best_move = None accept_move = False #print("REMOVEME: 2opt* on %s %s"%(list(route1_data.route), list(route2_data.route)) ) for i in xrange(0, len(route1_data.route) - 1): for j in xrange(0, len(route2_data.route) - 1): a = route1_data.route[i] b = route1_data.route[i + 1] c = route2_data.route[j] d = route2_data.route[j + 1] #print("REMOVEME: attempt remove %d-%d and %d-%d"%(a,b,c,d) ) # a->c b->d # __________ # / \ # 0->-a b-<-0-<-c d->-0 # \___________/ # delta = D[a,c] + D[b,d] \ -D[a,b]-D[c,d] if delta + S_EPS < best_delta: # is an improving move, check feasibility constraint_violated = False r1_new_demand = None r2_new_demand = None if C: r1_new_demand = route1_data.fwd_d[i] + route2_data.fwd_d[j] r2_new_demand = route1_data.rwd_d[ i + 1] + route2_data.rwd_d[j + 1] if r1_new_demand - C_EPS > C or r2_new_demand - C_EPS > C: constraint_violated = True if not constraint_violated and L and ( route1_data.fwd_l[i] + D[a, c] + route2_data.fwd_l[j] - S_EPS > L or route1_data.rwd_l[i + 1] + D[b, d] + route2_data.rwd_l[j + 1] - S_EPS > L): constraint_violated = True if not constraint_violated: # store segments, deltas, and demands best_move = (((None, i + 1, 1), (j, None, -1), D[a, c] - D[a, b], r1_new_demand), ((None, i, -1), (j + 1, None, 1), D[b, d] - D[c, d], r2_new_demand)) best_delta = delta if strategy == LSOPT.FIRST_ACCEPT: accept_move = True break # j loop # a->d c->b # ______________ # / \ # 0->-a b-<-0-<-c d->-0 # \______/ # delta = D[a,d] + D[b,c] \ -D[a,b]-D[c,d] if delta + S_EPS < best_delta: # is an improving move, check feasibility constraint_violated = False r1_new_demand = None r2_new_demand = None if C: r1_new_demand = route1_data.fwd_d[i] + route2_data.rwd_d[ j + 1] r2_new_demand = route1_data.rwd_d[i + 1] + route2_data.fwd_d[j] if r1_new_demand - C_EPS > C or r2_new_demand - C_EPS > C: constraint_violated = True if not constraint_violated and L and ( route1_data.fwd_l[i] + D[a, d] + route2_data.rwd_l[j + 1] - S_EPS > L or route1_data.rwd_l[i + 1] + D[b, c] + route2_data.fwd_l[j] - S_EPS > L): constraint_violated = True if not constraint_violated: # store segments, deltas, and demands best_move = (((None, i + 1, 1), (j + 1, None, 1), D[a, d] - D[a, b], r1_new_demand), ((None, i, -1), (j, None, -1), D[b, c] - D[c, d], r2_new_demand)) best_delta = delta if strategy == LSOPT.FIRST_ACCEPT: accept_move = True break # j loop if accept_move: break # i loop if best_move: # unpack the move ((r1_sgm1, r2_sgm1, r1_delta, r1_new_demand), (r1_sgm2, r2_sgm2, r2_delta, r2_new_demand)) = best_move return ( # route 1 RouteData( route1_data.route[r1_sgm1[0]:r1_sgm1[1]:r1_sgm1[2]]+\ route2_data.route[r2_sgm1[0]:r2_sgm1[1]:r2_sgm1[2]], route1_data.cost+r1_delta, r1_new_demand), # route 2 RouteData( route1_data.route[r1_sgm2[0]:r1_sgm2[1]:r1_sgm2[2]]+\ route2_data.route[r2_sgm2[0]:r2_sgm2[1]:r2_sgm2[2]], route1_data.cost+r1_delta, r1_new_demand), # delta best_delta) return None, None, None
def do_2point_move( route1_data, route2_data, D, d=None, C=None, L=None, # constraints strategy=LSOPT.FIRST_ACCEPT, best_delta=None): """ Swap one point from route1 with one point on route2 if it improves the solution. This operation is sometimes referred to as "exchange" (e.g. in Bräysy & M. Gendreau 2005, Savelsbergh 1992), but we use the name "two point move" (Groër et al 2010) to differentiate it from the inter- route one. If an improving move was found and made, operation returns new routes in same format as route_data inputs, but if C and d are not given the 3. field of the tuple is None. If there is no improving move, returns None. Groër, C., Golden, B. and Wasil, E., 2010. A library of local search heuristics for the vehicle routing problem. Mathematical Programming Computation, 2(2), pp.79-101. Bräysy, O. & Gendreau, M. 2005. Vehicle Routing Problem, Part I: Route Construction and Local Search Algorithms Transportation Science 39(1), pp. 104–118 Savelsbergh, M. W. P. 1992. The vehicle routing problem with time windows: Minimizing route duration. J.Comput. 4 146-154 """ # unpack route, current cost, and current demand route1, r1_l, r1_d, _ = route1_data route2, r2_l, r2_d, _ = route2_data if not best_delta: best_delta = 0 best_move = None accept_move = False for i in xrange(1, len(route1) - 1): to_swap1 = route1[i] swap1_after = route1[i - 1] swap1_before = route1[i + 1] for j in xrange(1, len(route2) - 1): to_swap2 = route2[j] # capacity constraint feasibility check if C and (r1_d - d[to_swap1] + d[to_swap2] - C_EPS > C or r2_d - d[to_swap2] + d[to_swap1] - C_EPS > C): continue swap2_after = route2[j - 1] swap2_before = route2[j + 1] route1_delta = -D[swap1_after,to_swap1]\ -D[to_swap1,swap1_before]\ +D[swap1_after,to_swap2]\ +D[to_swap2,swap1_before] route2_delta = -D[swap2_after,to_swap2]\ -D[to_swap2,swap2_before]\ +D[swap2_after,to_swap1]\ +D[to_swap1,swap2_before] # route cost constraint feasibility check if L and (r1_l + route1_delta > L or r2_l + route2_delta > L): continue delta = route1_delta + route2_delta if delta + S_EPS < best_delta: best_delta = delta best_move = (i, j, route1_delta, route2_delta) if strategy == LSOPT.FIRST_ACCEPT: accept_move = True break # j loop if accept_move: break # i loop if best_move: # unpack best move i, j, route1_delta, route2_delta = best_move to_swap1 = route1[i] to_swap2 = route2[j] return (RouteData(route1[:i] + [to_swap2] + route1[i + 1:], r1_l + route1_delta, None if not C else r1_d - d[to_swap1] + d[to_swap2]), RouteData(route2[:j] + [to_swap1] + route2[j + 1:], r2_l + route2_delta, None if not C else r2_d - d[to_swap2] + d[to_swap1]), route1_delta + route2_delta) return None, None, None
def do_local_search(ls_ops, sol, D, d, C, L=None, operator_strategy=LSOPT.FIRST_ACCEPT, iteration_strategy=ITEROPT.ALL_ACCEPT, max_iterations=None): """ Repeatedly apply ls_ops until no more improvements can be made. The procedure keeps track of the changed routes and searches only combinations that have been changed. Optionally the operator_strategy FIRST_ACCEPT (default)/BEST_ACCEPT can be given as well as the maximum number of iterations (that is, how many times all given operations are applied until giving up on reaching local optima). The iteration_strategy has an effect on which order the operations are applied. If ALL_ACCEPT (default), each operator is applied in turn until no improving moves are found. The options are: * FIRST_ACCEPT accept every improving move returned by the operator, and start again from the first operator. * BEST_ACCEPT accept the very best (single) move over all operators. * ALL_ACCEPT accept every improving move of each operator and continue. * REPEATED_ACCEPT run operator until no improving moves are found before moving on to the next operator. Note that these may freely be combined with the operator_strategy. """ current_sol = sol route_datas = RouteData.from_solution(sol, D, d) route_data_idxs = list(range(len(route_datas))) # We keep track of the operations to avoid search when it has already been # unsuccesfully applied at_lsop_optimal = defaultdict(set) customer_to_at_lsopt_optimal = defaultdict(list) iteration = 0 improving_iteration = True while improving_iteration: improving_iteration = False best_iteration_result = None best_iteration_delta = None best_iteration_operator = None ls_op_idx = 0 while ls_op_idx<len(ls_ops): ls_op = ls_ops[ls_op_idx] ls_op_args = getargspec(ls_op)[0] route_count = ls_op_args.index('D') op_order_sensitive = ls_op in ROUTE_ORDER_SENSITIVE_OPERATORS op_improved = False if __debug__: log(DEBUG-1, "Applying %s on %s"%(ls_op.__name__, str(current_sol))) # TODO: Consider using a counter to check for this # check if we already reached local optima on all routes with ls_op #if all( (ls_op in lsop_optimal[ri]) for ri in route_data_idxs ): # if __debug__: # log(DEBUG-2, "All route combinations already searched for %s, skipping it."%ls_op.__name__) # break best_delta = None best_result = None no_improving_lsop_found = set() for route_indices in permutations(route_data_idxs,route_count): # If the order does not matter, require that the route indices # are ordered from smallest to largest. if (not op_order_sensitive) and (not is_sorted(route_indices)): continue # ls_op is already at local optima with this combination of routes if ls_op in at_lsop_optimal[route_indices]: if __debug__: log(DEBUG-2, "Route combination %s already searched for %s, skipping it."% (str(route_indices), ls_op.__name__)) continue # The one route case has different call signature if route_count==1: op_params = [route_datas[route_indices[0]].route, D, operator_strategy] else: op_params = [route_datas[ri] for ri in route_indices]+\ [D, d, C, L, operator_strategy] # Ideally, best_delta can be used as an upper # bound to avoid unnecessary result generation # and to allow early ls_op termination. # However, then we lose the ability to mark # some route combinations as ls_optimal. #+[best_delta] result = ls_op(*op_params) #print("REMOVEME:",route_datas[route_indices[0]].route, "->", result) # route was changed, record the change in route datas delta = result[-1] if delta is None: no_improving_lsop_found.update((route_indices,)) else: # For route_count==1 every route contributes for the same # best_delta (unless trying to find the very best *single* # move!) if route_count==1: skip_result = ( (best_delta != None and delta+S_EPS>best_delta) and (iteration_strategy==ITEROPT.BEST_ACCEPT) ) if not skip_result: if ((best_result is None) or (iteration_strategy==ITEROPT.BEST_ACCEPT)): best_result = [] best_delta = 0 old_rd = route_datas[route_indices[0]] new_rd = RouteData(result[0],old_rd.cost+delta,old_rd.demand) best_result.append( (route_indices[0], new_rd) ) best_delta+=delta else: if (best_result is None) or (delta+S_EPS<best_delta): best_result = zip(route_indices, result[:-1]) best_delta = delta # Found a first improving with this operator, move on. if operator_strategy==LSOPT.FIRST_ACCEPT: break # route combination loop # end route combination loop # Mark the routes that had no potential improvements to be at # local optima to avoid checking the same moves again. for ris in no_improving_lsop_found: at_lsop_optimal[ris].add(ls_op) for ri in ris: customer_to_at_lsopt_optimal[ri].append(ris) if best_result is not None: if iteration_strategy==ITEROPT.BEST_ACCEPT: if (best_iteration_result is None) or \ (best_delta+S_EPS<best_iteration_delta): best_iteration_result = best_result best_iteration_delta = best_delta best_iteration_operator = ls_op.__name__ else: op_improved = True improving_iteration = True for ri, new_rd in best_result: route_datas[ri] = new_rd # The route was modified, allow other operators to # check if it can be improved again. for ris in customer_to_at_lsopt_optimal[ri]: at_lsop_optimal[ris].clear() # Check if route is [0,0] or [0] or [] if len(new_rd.route)<=2: # remove this route from the future search route_data_idxs.remove(ri) if __debug__: op_improved = True opt_sol = RouteData.to_solution(route_datas) log(DEBUG, "Improved from %s (%.2f) to %s (%.2f) using %s"% (str(current_sol),objf(current_sol,D),str(opt_sol),objf(opt_sol,D),ls_op.__name__)) current_sol = opt_sol if iteration_strategy==ITEROPT.FIRST_ACCEPT: ls_op_idx = 0 break # the ls_op loop (start from the beginning) if __debug__: if best_result is None: log(DEBUG-1, "No improving move with %s"%ls_op.__name__) if op_improved and iteration_strategy==ITEROPT.FIRST_ACCEPT: # after an improvement start from the first operator ls_op_idx = 0 if op_improved and iteration_strategy==ITEROPT.REPEATED_ACCEPT: # keep repeating the operator until no improvement is found ls_op_idx = ls_op_idx else: # BEST_ACCEPT and ALL_ACCEPT always move on ls_op_idx += 1 #END OF LS_OP LOOP if (iteration_strategy==ITEROPT.BEST_ACCEPT) and\ (best_iteration_result is not None): improving_iteration = True for ri, new_rd in best_iteration_result: route_datas[ri] = new_rd # The route was modified, allow other operators to # check if it can be improved again. for ris in customer_to_at_lsopt_optimal[ri]: at_lsop_optimal[ris].clear() # Check if route is [0,0] or [0] or [] if len(new_rd.route)<=2: # remove this route from the future search route_data_idxs.remove(ri) if __debug__: op_improved = True opt_sol = RouteData.to_solution(route_datas) log(DEBUG, "Improved from %s (%.2f) to %s (%.2f) using %s"% (str(current_sol),objf(current_sol,D),str(opt_sol),objf(opt_sol,D),best_iteration_operator)) current_sol = opt_sol iteration+=1 if max_iterations and iteration>=max_iterations: break # iteration loop current_sol = RouteData.to_solution(route_datas) if __debug__: log(DEBUG,"Repeadedly applying %s resulted in %s"% (",".join(ls_op.__name__ for ls_op in ls_ops),str(current_sol))) return current_sol
def do_redistribute_move(redisributed_route_data, receiving_route_or_routes_data, D, d=None, C=None, L=None, strategy=LSOPT.FIRST_ACCEPT, best_delta=None, recombination_level=0): """ Try to insert the nodes of the first route on the other routes if possible. Note that second argument receiving_route_data can also be a list of routes. The insertion order matters, so it is possible to try all permutations of nodes to be inserted and routes to insert to (and return the best). However, this will be very computationally intesive as the number of combinations explode. Therefore, there are different options available: * recombination_level=0, insert the nodes in the order they are on the route that will be destroyed and consider the recieving routes in the order of the "routes" parameter. * recombination_level=1, the nodes are inserted in all possible order. The recieving routes are checked in the order of the "routes" parameter. * recombination_level=2, insert the nodes in the order they are on the route that will be destroyed. However, every possible order of the recieving routes is checked. * recombination_level=3, all possible orderings of the nodes to be inserted AND the recieving routes is checked. There could be yet another level (try_all_insertions_level=4), where for each insertion of each insertion node ordering, but it has not been implemented. """ # allow only single redistributing to a single other route e.g. when using # (local_search API) if isinstance(receiving_route_or_routes_data, RouteData): receiving_route_or_routes_data = [receiving_route_or_routes_data] # before iterating the permuations, check that it is at all possible to # fit all from the route to be destroyed to the other routes. if C: total_d_slack = sum(C - rrd.demand for rrd in receiving_route_or_routes_data) if redisributed_route_data.demand - C_EPS > total_d_slack: return [None] * (len(receiving_route_or_routes_data) + 2) if not best_delta: # allows move that makes the overall solution cost worse best_delta = float("inf") redistribution_succesfull = False best_insertion_routes = None # unpack route, current cost, and current demand route1, r1l, r1d, _ = redisributed_route_data if recombination_level == 0: insertion_node_orderings = [(route1[1:-1], receiving_route_or_routes_data)] elif recombination_level == 1: insertion_node_orderings = product(permutations(route1[1:-1]), [receiving_route_or_routes_data]) elif recombination_level == 2: insertion_node_orderings = product( [route1[1:-1]], permutations(receiving_route_or_routes_data)) elif recombination_level == 3: insertion_node_orderings = product( list(permutations(route1[1:-1])), list(permutations(receiving_route_or_routes_data))) #print(list(po for po in insertion_node_orderings)) for route_customers, candidate_routes in insertion_node_orderings: total_delta = 0 all_succesfully_inserted = True ansatz_route_data = list(candidate_routes) for to_insert in route_customers: node_succesfully_inserted = False for rd2_ar_idx, rd2 in enumerate(ansatz_route_data): _, new_rd2, delta = do_insert_move(to_insert, rd2, D, d, C, L, strategy=strategy) if delta is None: continue #print("REMOVEME:", "succesfully inserted %d to create %s"%(to_insert, new_rd2.route)) ansatz_route_data[rd2_ar_idx] = new_rd2 total_delta += delta if total_delta + S_EPS < best_delta: node_succesfully_inserted = True break else: continue if not node_succesfully_inserted: all_succesfully_inserted = False break if all_succesfully_inserted: #print("REMOVEME:", "all inserted to create %s (delta %.2f)"%([rd.route for rd in ansatz_route_data], total_delta)) if total_delta < best_delta: best_delta = total_delta best_insertion_routes = ansatz_route_data redistribution_succesfull = True if redistribution_succesfull: # return an empty route and n routes the customers were distributed to return tuple([RouteData()] + best_insertion_routes + [best_delta]) else: return [None] * (len(receiving_route_or_routes_data) + 2)
def disentangle_heuristic(routes, sweep, node_phis, D, C, d, L): # find a overlapping / "tanged" pair improvement_found = False entangled = True route_phi_ranges = [] for rd in routes: route = rd.route if rd.is_empty(): route_phi_ranges.append(None) else: r_phis = node_phis[route[1:-1]] r_phi_range = _get_route_phi_range(r_phis) route_phi_ranges.append(r_phi_range) while (entangled): entangled = False for rd1_idx, rd2_idx in permutations(range(len(routes)), 2): # unpack route, current cost, and current demand route1, r1l, r1d, _ = routes[rd1_idx] route2, r2l, r2d, _ = routes[rd2_idx] r1_phi_range = route_phi_ranges[rd1_idx] r2_phi_range = route_phi_ranges[rd2_idx] # the route may have become empty if (r1_phi_range is None) or (r2_phi_range is None): continue # ranges overlap overlap = ((r1_phi_range[0][0] < r2_phi_range[0][1] and r2_phi_range[0][0] < r1_phi_range[0][1]) or\ (r1_phi_range[1][0] < r2_phi_range[1][1] and\ r2_phi_range[1][0] < r1_phi_range[1][1])) if overlap: et_nodes = route1[:-1] + route2[1:-1] et_nodes.sort() # Check for backwards compatibility: numpy.isin was introduced # in 1.13.0. If it not availabe (as is the case e.g. in Ubuntu # 16.04 LTS), use the equivalent list comprehension instead. if hasattr(np, 'isin'): sweep_mask = np.isin(sweep[2], et_nodes, assume_unique=True) else: sweep_mask = np.array( [item in et_nodes for item in sweep[2]]) et_sweep = sweep[:, sweep_mask] over_wrap_phi = (et_sweep[0][0] + 2 * pi) - et_sweep[0][-1] et_seed_node = [ np.argmax(np.ediff1d(et_sweep[0], to_end=over_wrap_phi)) ] reconstructed_et_sol = sweep_init(et_sweep, D, d, C, L, seed_node=et_seed_node, direction="cw", routing_algo=None) # translate nodes back to master problem node indices #reconstructed_et_sol = [et_nodes[n] for n in reconstructed_et_sol] # construct an array of RouteData objects for heuristics et_routes = RouteData.from_solution(reconstructed_et_sol, D, d) omitted = set() for extra_route in et_routes[2:]: omitted |= set(extra_route.route[1:-1]) et_routes = et_routes[:2] if __debug__: log(DEBUG - 1, "DISENTANGLE omitted %s" % str(list(omitted))) _log_after_ls_op("DISENTANGLE/SWEEP", True, et_routes, D) refined = True while refined: refined = False refined |= inspect_heuristic(et_routes, D, C, d, L) if __debug__: _log_after_ls_op("DISENTANGLE/INSPECT", refined, et_routes, D) refined |= single_heuristic(et_routes, D, C, d, L) if __debug__: _log_after_ls_op("DISENTANGLE/SINGLE", refined, et_routes, D) # there were omitted nodes, try to complain them in if omitted: refined |= complain_heuristic(et_routes, omitted, D, C, d, L) if __debug__: _log_after_ls_op("DISENTANGLE/COMPLAIN", refined, et_routes, D) # all nodes routed and disentangling improves -> accept this if len(et_routes) == 1: # keep an empty route (for now) to avoid indexing errors et_routes.append(RouteData([0, 0], 0.0, 0.0)) disentange_improves = True else: disentange_improves = et_routes[0].cost + et_routes[ 1].cost + S_EPS < r1l + r2l if len(omitted) == 0 and disentange_improves: routes[rd1_idx] = et_routes[0] routes[rd2_idx] = et_routes[1] # The routes were updated, update also the ranges r1_phis = node_phis[et_routes[0].route[1:-1]] r2_phis = node_phis[et_routes[1].route[1:-1]] r1_phi_range = _get_route_phi_range(r1_phis) r2_phi_range = _get_route_phi_range(r2_phis) route_phi_ranges[rd1_idx] = r1_phi_range route_phi_ranges[rd2_idx] = r2_phi_range if __debug__: if len(omitted) > 0: log( DEBUG - 1, "DISENTANGLE rejected ( due to omitted %s)" % str(omitted)) elif disentange_improves: log( DEBUG - 1, "DISENTANGLE rejected ( due to not improving %.2f vs. %.2f )" % (et_routes[0].cost + et_routes[1].cost, r1l + r2l)) else: log( DEBUG - 1, "DISENTANGLE accepted ( due to inserting omitted and improving %.2f vs. %.2f )" % (et_routes[0].cost + et_routes[1].cost, r1l + r2l)) return improvement_found
def do_one_sweep(N, D, d, C, L, routing_algo, sweep, start, step_inc, generate_alternative_first_routes = False, intra_route_callback=None, inter_route_callback=None, callback_data=None): """ This function does one full circle starting from start index of the sweep and proceeds to add the nodes one by one in the direction indicated by the step_inc parameter. A new route is started if either of the constraints constraints C and L (L can be None) are violated. If generate_alternative_first_routes is set, every possible route until C OR L constraints are generated and then the sweep terminates. This is used by some constuction heuristics to build potential routes. The generated routes are TSP optimized using routing_algo and may be further optimized with intra and inter route improvement callbacks.""" # make sure all before the start node have larger angles than the start node # and that they are of range [0-2*pi] if intra_route_callback or inter_route_callback: sweep_phis = np.copy( sweep[0] ) #sweep_phis = -step_inc/abs(step_inc)*sweep_phis sweep_phis-=(pi+sweep_phis[start]) # the first is at -pi sweep_phis[start-step_inc::-step_inc]+=2*pi sweep_phis = -1*sweep_phis #node_idx_to_sweep_pos = lambda n: int(sweep[3,n-1]) sweep_pos_to_node_idx = lambda idx: int(sweep[2,idx]) max_sweep_idx = len(sweep[2])-1 if __debug__: int_sweep = list(sweep[2].astype(int)) log(DEBUG-1, "Sweep node order %s\n"%str(int_sweep[start:]+int_sweep[:start])) # Routes routes = [] routed = [False]*N routed[0] = True routed_cnt = 0 total_to_route = len(sweep[0]) # Emerging route current_route = RouteData([0]) current_route.node_set = OrderedSet([0]) current_route_cost_upper_bound = 0 route_complete = False # Backlog # improvement heuristics may leave some nodes unrouted, collect them here # and insert them on next (or last) routes blocked_nodes = set() # THE MAIN SWEEP LOOP # iterate until a full sweep is done and the backlog is empty sweep_pos = start sweep_node = sweep_pos_to_node_idx(sweep_pos) while True: if __debug__: if sweep_node: prev_pos = _step(sweep_pos, -step_inc, max_sweep_idx) next_pos = _step(sweep_pos, step_inc, max_sweep_idx) prev_ray = bisect_angle(sweep[0][prev_pos], sweep[0][sweep_pos], direction=step_inc) next_ray = bisect_angle(sweep[0][sweep_pos], sweep[0][next_pos], direction=step_inc) log(DEBUG-2, "Considering n%d between rays %.2f, %.2f" % (sweep_node,prev_ray,next_ray)) # append route to the list of routes if # 1. all nodes have been added to the routes OR # 2. the route demand would be higher than the vehicle capacity # (check possible L constraint later) OR # 3. if there is only L constraint, check if adding sweep node would # break the maximum route distance constraint. if not route_complete and C: would_break_C_ctr = current_route.demand+d[sweep_node]-C_EPS>C route_complete = would_break_C_ctr if not route_complete and L and current_route_cost_upper_bound>L: tsp_sol, tsp_cost = \ routing_algo(D, list(current_route.node_set)+[sweep_node]) current_route_cost_upper_bound = tsp_cost would_break_L_ctr = tsp_cost-S_EPS>L route_complete = would_break_L_ctr route_interesting = generate_alternative_first_routes and \ len(current_route.node_set)>1 if route_complete or route_interesting: tsp_sol, tsp_cost = routing_algo(D, list(current_route.node_set)) current_route.route = tsp_sol current_route.cost = tsp_cost current_route_cost_upper_bound = current_route.cost if __debug__: if route_complete: log(DEBUG-2, "Route %s full."%str(list(current_route.node_set))) log(DEBUG-3, "Got TSP solution %s (%.2f)"%(str(tsp_sol),tsp_cost)) if L: L_feasible_pos = _ensure_L_feasible(D, d, C, L, current_route, sweep_pos, step_inc, max_sweep_idx, routed, sweep_pos_to_node_idx, routing_algo) current_route_cost_upper_bound = current_route.cost if L_feasible_pos!=sweep_pos: sweep_pos = L_feasible_pos if sweep_node!=None: sweep_node = sweep_pos_to_node_idx(sweep_pos) # route is full if L constraint was violated route_complete = True route_interesting = False if sweep_node!=None and intra_route_callback: # Do intra route improvement. It may include new unrouted # customers to the route or remove existing ones! current_route, included_nodes, ignored_nodes, route_complete = \ intra_route_callback( current_route, callback_data, sweep[1], sweep_phis, sweep_pos, step_inc, routed ) current_route_cost_upper_bound = current_route.cost if __debug__: log(DEBUG-1, "Improved the route to %s (%.2f)"%(current_route.route, current_route.cost)) # Make sure all routed nodes are routed. for rn in included_nodes: routed[rn]=True # Make sure skipped and removed nodes are added in later. for lon in ignored_nodes: routed[lon] = False if __debug__: log(DEBUG-2, "Block n%d for now"%lon) # avoid trying to add it on next iter. blocked_nodes.add( lon ) if lon==sweep_node: # skip adding current node sweep_node = None # If route is complete, store it and start a new one if route_complete: if __debug__: log(DEBUG-1, "Route completed %s\n"%str(current_route.route)) #log(DEBUG-1, "with backlog of %s\n"%str(node_backlog)) # Check if we have all routed, and can exit the main sweep loop route_customer_cnt = len(current_route.node_set) if route_customer_cnt>1: routed_cnt+=route_customer_cnt-1 routes.append( current_route ) if routed_cnt>=total_to_route: if __debug__: log(DEBUG-1,"Last route completed %s\n"%str(current_route.route)) break # SWEEP current_route = RouteData([0]) current_route.node_set = OrderedSet([0]) current_route_cost_upper_bound = 0.0 route_complete = False # New route, allow customers in backlog blocked_nodes = set() if generate_alternative_first_routes: break # if the route demand is higher than the lower bound, record it elif route_interesting: if __debug__: log(DEBUG-1, "Route recorded %s\n"%str(current_route.route)) # store a deep copy routes.append( RouteData(list(current_route.route), current_route.cost, current_route.demand, OrderedSet(current_route.node_set)) ) # Route improvement can route nodes, so ensure this was not routed. if (sweep_node is not None) and (not routed[sweep_node]): current_route.node_set.add(sweep_node) routed[sweep_node] = True if C: current_route.demand+=d[sweep_node] if L: prev_node = 0 if len(current_route.route)>2: prev_node = current_route.route[-1] \ if current_route.route[-1]!=0\ else current_route.route[-2] # calculate and add the delta of just appending the swept node ub_delta = -D[prev_node, 0]+D[prev_node, sweep_node]+D[sweep_node,0] current_route_cost_upper_bound+=ub_delta if __debug__: log(DEBUG-2, "Added n%d to the route set"%sweep_node) if __debug__: log(DEBUG-3, "Step to a next sweep node from position %d (n%d) with %s blocked."% (sweep_pos, sweep_pos_to_node_idx(sweep_pos), list(blocked_nodes))) start_stepping_from = sweep_pos while True: sweep_pos = _step(sweep_pos, step_inc, max_sweep_idx) sweep_node = sweep_pos_to_node_idx(sweep_pos) if (not routed[sweep_node]) and (sweep_node not in blocked_nodes): break # found an unrouted node continue with it if sweep_pos == start_stepping_from: # We checked, and it seems there is no unrouted non-blocked # nodes left -> start a new route, reset blocked and try again. sweep_node = None route_complete = True blocked_nodes = set() break # (optional) improvement phase if inter_route_callback: routes = inter_route_callback(routes, callback_data) return routes
def _generate_solution_relaxation_petals(routes, discard_at_most, D, C=None, d=None, L=None): """ This is the route improvement, or overconstraint i&ii relaxations phase, where customers of a LP solution can be moved from route to another if the move improves the solution. Note that this differs from the do_1point_move local_search because the prodecure allows regaining feasibility if the move would improve the solution (see _regain_feasibility for details).""" relaxed_route_datas = [] route_datas = RouteData.from_routes(routes, D, d) route_indices = range(len(route_datas)) # Try to move a customer from route 1 to route 2 for ri1 in route_indices: route1, r1_l, r1_d, _ = route_datas[ri1] i = 0 while i < len(route1) - 2: i += 1 remove_after = route1[i - 1] to_move = route1[i] remove_before = route1[i + 1] r1_delta = +D[remove_after,remove_before]\ -D[remove_after,to_move]\ -D[to_move,remove_before] for ri2 in route_indices: # Do not try to move nodes from ri1 to ri1! if ri1 == ri2: continue route2, r2_l, r2_d, _ = route_datas[ri2] # Find the best place to insert it to j = 0 best_r2_delta = float('inf') best_j = None while j < len(route2) - 1: j += 1 insert_after = route2[j - 1] insert_before = route2[j] r2_delta = +D[insert_after,to_move]\ +D[to_move,insert_before]\ -D[insert_after,insert_before] if r2_delta < best_r2_delta: best_r2_delta = r2_delta best_j = j if r1_delta + best_r2_delta < -S_EPS: d_excess = r2_d+d[to_move]-C \ if (C and r2_d+d[to_move]-C_EPS>C) \ else None l_excess = r2_l+best_r2_delta-L \ if (L and r2_l+best_r2_delta-S_EPS>L) \ else None new_route1 = route1[:i] + route1[i + 1:] new_rd1 = RouteData(new_route1, r1_l + r1_delta, r1_d - d[to_move] if d else 0) new_route2 = route2[:best_j] + [to_move] + route2[best_j:] new_rd2 = RouteData(new_route2, r2_l + best_r2_delta, r2_d + d[to_move] if d else 0) # If operation would break a constraint... if d_excess or l_excess: # ... discard and redistribute some nodes mod_route_datas = _regain_feasibility( to_move, r1_delta, best_r2_delta, new_rd1, ri1, ri2, discard_at_most, d_excess, l_excess, route_datas, D, d, C, L) else: mod_route_datas = [new_rd1, new_rd2] relaxed_route_datas.extend(mod_route_datas) return relaxed_route_datas
def complain_heuristic( routes, unrouted_nodes, #input / outputs D, C, d, L): """ This procedure tries to add unrouted nodes that were e.g. omitted from the initial routes on the existing routes by removing one node from them and reinserting it to another route. It is possible that not all nodes can be routed. Then a list of nodes that could not be inserted, or that were removed to make a better insertions, is returned. Empty list means that unrouted_nodes were successfully inserted. """ was_assigned = set() was_omitted = set() improvement_found = False for node in unrouted_nodes: best_replace_delta = None best_replace_move = None node_routed = False for rd1_idx in range(len(routes)): # unpack route, current cost, and current demand rd1 = routes[rd1_idx] if rd1.is_empty(): continue route1, r1l, r1d, _ = rd1 for i in xrange(1, len(route1) - 1): replace_after = route1[i - 1] was_replaced = route1[i] replace_before = route1[i + 1] replace_d = 0 if C: replace_d = r1d - d[was_replaced] + d[node] if replace_d - C_EPS > C: continue replace_delta = +D[replace_after, node]\ +D[node, replace_before]\ -D[replace_after, was_replaced ]\ -D[was_replaced , replace_before] if L and r1l + replace_delta - S_EPS > L: continue if (best_replace_delta is None) or\ (replace_delta<best_replace_delta): best_replace_delta = replace_delta best_replace_move = (rd1_idx, i) # the unrouted insertion is feasible, now fit the removed back for rd2_idx in range(len(routes)): if rd1_idx == rd2_idx: continue rd2 = routes[rd2_idx] # the insertion position does not matter, but it must fit # and be C and L feasible _, new_rd2, insert_delta = do_insert_move( was_replaced, rd2, D, d, C, L, LSOPT.FIRST_ACCEPT) if insert_delta is not None: # operation successful, accept the move routes[rd2_idx] = new_rd2 routes[rd1_idx] = RouteData( route1[:i] + [node] + route1[i + 1:], r1l + replace_delta, replace_d) node_routed = True improvement_found = True was_assigned.add(node) break if node_routed: break if node_routed: break if not node_routed: if best_replace_delta is not None: (best_rd_idx, i) = best_replace_move # place that of highest priority on the route best_route, best_rl, best_rd, _ = routes[best_rd_idx] to_remove = best_route[i] # Wren and Holliday propose that priority is given to the customer # requesting the greater load. if C and d[node] > d[to_remove]: routes[best_rd_idx] = RouteData( best_route[:i] + [node] + best_route[i + 1:], best_rl + best_replace_delta, best_rd - d[to_remove] + d[node]) was_assigned.add(node) was_omitted.add(to_remove) improvement_found = True # update the situation of unrouted_nodes unrouted_nodes -= was_assigned unrouted_nodes |= was_omitted return improvement_found
def _regain_feasibility(to_move, r1_delta, r2_delta, rd1_aftermove, ri1, ri2, discard_at_most, d_excess, l_excess, route_datas, D, d, C, L): """ From Foster & Ryan (1976) "The result of such a (1 point) move may be to cause the receiving route to exceed the mileage or capacity restriction and so it must be permitted to discard deliveries to regain feasibility. ... these ... are in turn relocated in the current solution schedule without causing further infeasibility and that the total effect of all the relocations must be a reduction in the mileage." Thus, after the first of the stored 1 point moves causes the solution to become infeasible, this function tries to regain the feasibility by redistributing some of the customers from the recieving route to other routes so that the moves still produce an improved solution. However, to keep the entire operation improving, the redistribution should not increase the cost more than delta_slack.""" ## REPLACE MOVE # there is no room, have to replace one *OR MORE* nodes # We need to check removing all combinations of customers to make room and # store those that still would remove the problem. route2, r2_l, r2_d, _ = route_datas[ri2] assert route2[0] == 0 and route2[ -1] == 0, "All routes must start and end to the depot" r2_min_d = min(d[n] for n in route2[1:-1]) if d else 0 # First route is changed and reserve an empty route to receive the nodes. new_empty_rd = RouteData() routes_with_slack = [rd for ri, rd in enumerate(route_datas) if (ri!=ri1 and ri!=ri2 and (not C or C-rd.demand+C_EPS>r2_min_d))]\ +[rd1_aftermove,new_empty_rd] # A depth first search (no recursion) for removing JUST enough customers if d: stack = [(i + 1, [n], d[n]) for i, n in enumerate(route2[1:-1])] else: stack = [(i + 1, [n], None) for i, n in enumerate(route2[1:-1])] improving_rds = [] while stack: last_n_i, to_remove_ns, to_remove_d = stack.pop() # We have to recalculate the delta_r2 as removing nodes almost surely # will change the best position to insert to. new_route2 = [n for n in route2 if n not in to_remove_ns] new_r2_delta = float('inf') new_r2_l = 0.0 best_j = None for j in xrange(1, len(new_route2)): insert_after = new_route2[j - 1] insert_before = new_route2[j] new_r2_l += D[insert_after, insert_before] delta = +D[insert_after,to_move]\ +D[to_move,insert_before]\ -D[insert_after,insert_before] if delta < new_r2_delta: new_r2_delta = delta best_j = j to_remove_l = (r2_l + r2_delta) - (new_r2_l + new_r2_delta) new_route2 = new_route2[:best_j] + [to_move] + new_route2[best_j:] if (not d_excess or to_remove_d+C_EPS >= d_excess) and\ (not l_excess or to_remove_l+S_EPS >= l_excess): # Redistributing ALWAYS increases the cost at least a little, # it makes no sense to even try sometimes. # If all of the improvements goes to inserting to_move customer, # there is none left to try to redistribute. delta_slack = -(r1_delta + new_r2_delta) if delta_slack + S_EPS < 0: continue # After removing customers in to_remove_ns route2 becomes feasible, # now we have to check if redistributing the removed back to the # solution is possible. to_redistribute_r = [0] + to_remove_ns + [0] to_redisribute_rd = RouteData(to_redistribute_r, objf(to_redistribute_r, D), totald(to_redistribute_r, d)) result = do_redistribute_move(to_redisribute_rd, routes_with_slack, D, d, C, L, strategy=LSOPT.BEST_ACCEPT, recombination_level=0, best_delta=delta_slack) redistribute_delta = result[-1] if redistribute_delta is not None: new_r2_d = r2_d - to_remove_d + d[to_move] if d else 0 new_rd2 = RouteData(new_route2, new_r2_l + new_r2_delta, new_r2_d) improving_rds.append(new_rd2) improving_rds += result[1:-1] # includes modified rd1 #TODO: if one would like to explore all discard combinations, # one would do branching also here or at least if the redisribute # fails. elif not discard_at_most or len(to_remove_ns) < discard_at_most: # branch out for candidate_j, candidate_n in enumerate(route2[last_n_i + 1:-1]): stack.append( (last_n_i + 1 + candidate_j, to_remove_ns + [candidate_n], to_remove_d + d[candidate_n] if d else None)) return improving_rds
def wren_holliday_init(points, D, d, C, L=None, minimize_K=False, seed_node=BEST_OF_FOUR, direction='both', full_convergence=True): """ This implements the Wren and Holliday improvement heuristic. The initial solution is generated using the generic sweep procedure of `sweep.py`, and the improvement procedure works as specified in Wren & Holliday (1972) Fig 1 (Flowchart of program). Basically, the refining processes, also known as local search improvement / post-optimization phase, repeadedly applies local search operators to improve the initial solutions and returns the best one. * D is a numpy ndarray (or equvalent) of the full 2D distance matrix. * d is a list of demands. d[0] should be 0.0 as it is the depot. * C is the capacity constraint limit for the identical vehicles. * L is the optional constraint for the maximum route cost/duration/length. * seed_node sets how many different seed nodes are tried for the Sweep based initial solution generation. If LEAST_DENSE, the sweep is started from the direction from the depot that has lowest customer density. The same applies to BEST_OF_FOUR (default), but also 3 other directions spaced by ~90deg are considered. Also a complete (but costly) generation of all possible initial solutions with BEST_ALTERNATIVE can be used. * direction can be 'ccw' for counter-clockwise Sweep, 'cw' (default) for clockwise or 'both' for trying both. * full_convergence determines if the improvement is stopped after the "delete" operation fails the first time (False) or if the local search continues until no operation is capable of finding an improving more (True, default). Returns the solution. Wren, A., & Holliday, A. (1972). Computer scheduling of vehicles from one or more depots to a number of delivery points. Journal of the Operational Research Society, 23(3), 333-344. """ if not points: raise ValueError( "The algorithm requires 2D coordinates for the points") N = len(D) # Calculate the sweep coordinates # this is 99% same as _get_sweep..., but we need to get also # the node_phis for later use, so duplicated some code here. np_pts = points if isinstance(points, np.ndarray) else np.asarray(points) depot_x, depot_y = points[0] node_rhos, node_phis = cart2pol(np_pts[:, 0] - depot_x, np_pts[:, 1] - depot_y) sweep = get_sweep_from_polar_coordinates(node_rhos, node_phis) sweep_phis = sweep[0] directions = ['cw', 'ccw'] if direction == 'both' else [direction] sweeps = [] for cur_dir in directions: if seed_node == BEST_OF_FOUR or seed_node == LEAST_DENSE: # Wren & Holliday method of selecting starting customers for the # Sweep initialization from the "least dense direction". # Turn the polar coordinates so that the smallest angles are to the # direction of the smallest density, weighted by their demands. o = np.array([depot_x, depot_y]) if d: weighted_xy_from_origin = np.multiply( np_pts[1:] - o, np.transpose(np.array([d[1:], d[1:]]))) avgx, avgy = np.average(weighted_xy_from_origin, axis=0) else: avgx, avgy = np.average(np_pts[1:] - o, axis=0) avg_rho, avg_phi = cart2pol(np.array([avgx]), np.array([avgy])) # to range [-pi, pi] if avg_phi > 0: least_dense_phi = avg_phi[0] - pi else: least_dense_phi = avg_phi[0] + pi # evenly spaced by pi/2 for i in range(4): angle_tgt = least_dense_phi + i * pi / 2 if angle_tgt > pi: angle_tgt -= pi * 2 # take the first satisfying the condition start_from_here = np.argmax(sweep_phis > angle_tgt) start_node = start_from_here-1 if cur_dir=="cw" \ else start_from_here if start_node == -1: start_node = N - 2 sweeps.append((start_node, cur_dir)) if seed_node == LEAST_DENSE: break # do not take the ones at 90 deg intervals elif seed_node == BEST_ALTERNATIVE: nodes = list(range(N - 1)) sweeps.extend(zip(nodes, [cur_dir] * len(nodes))) elif type(seed_node) == int: sweeps.append([seed_node, cur_dir]) ## PHASE 1 : "Generate ... initial solutions and choose the best" initial_sols = [] try: for start_node, cur_dir in sweeps: isol = sweep_init(sweep, D, d, C, L, seed_node=[start_node], direction=cur_dir, routing_algo=None) initial_sols.append(isol) except KeyboardInterrupt as e: # or SIGINT # if interrupted on initial sol gen, return the best of those if len(e.args) > 0 and type(e.args[0]) is list: initial_sols.append(e.args[0]) if not initial_sols: raise e else: best_isol, best_if, best_iK = None, float('inf'), float('inf') for isol in initial_sols: isol_f = objf(isol, D) isol_K = isol.count(0) - 1 if is_better_sol(best_if, best_iK, isol_f, isol_K, minimize_K): best_isol = isol best_if = isol_f best_iK = isol_K raise KeyboardInterrupt(best_isol) best_sol, best_f, best_K = None, float('inf'), float('inf') interrupted = False for sol in initial_sols: # Construct an array of RouteData objects for local search improvement # heuristics to use routes = RouteData.from_solution(sol, D, d) if __debug__: log(DEBUG, "Improving solution %s (%.2f)" % (sol, objf(sol, D))) _log_after_ls_op("SWEEP(S)", False, routes, D) ## PHASE 2 : Improvement phase (see Wren & Holliday 1974, Figure 1) # Variables storing the state converging = False deleted = True omitted_nodes = set() prev_Q_point_sol_f = None prev_iteration_sol_f = None changed = False try: while True: _remove_empty_in_place(routes) if not minimize_K: # +1 empty route can allow local search to find an improvement routes.append(RouteData()) changed = False ## "INSPECT, SINGLE" ## # run 2opt on each route to remove any crossing edges inspect_improved = inspect_heuristic(routes, D, C, d, L) if inspect_improved and minimize_K: _remove_empty_in_place(routes) changed |= inspect_improved if __debug__: _log_after_ls_op("INSPECT", changed, routes, D) # move a node to a better position on the route or other routes single_improved = single_heuristic(routes, D, C, d, L) if single_improved and minimize_K: _remove_empty_in_place(routes) changed |= single_improved if __debug__: _log_after_ls_op("SINGLE", changed, routes, D) ## "Are customers omitted?" ## omitted_were_assigned = False if omitted_nodes: inserted_nodes = set() ## "Take omitted ... in order and try to fit into existing routes" ## for node in sorted(list(omitted_nodes)): for rdi, rd in enumerate(routes): _, new_rd, delta = do_insert_move( node, rd, D, d, C, L, LSOPT.BEST_ACCEPT) if delta is not None: routes[rdi] = new_rd inserted_nodes.add(node) omitted_were_assigned = True omitted_nodes -= inserted_nodes if omitted_were_assigned and minimize_K: _remove_empty_in_place(routes) changed |= omitted_were_assigned if __debug__: _log_after_ls_op("INSERT", changed, routes, D) ## "Are customers omitted still?" ## if omitted_nodes: omitted_were_assigned |= complain_heuristic( routes, omitted_nodes, D, C, d, L) if omitted_were_assigned and minimize_K: _remove_empty_in_place(routes) changed |= omitted_were_assigned if __debug__: _log_after_ls_op("COMPLAIN", changed, routes, D) sol_f = 0 for rd in routes: sol_f += rd.cost ## Q-point : "Has distance been reduced by more that 5% OR # has a previously omitted customer been assigned?" ## if (prev_Q_point_sol_f is None) or\ (sol_f<prev_Q_point_sol_f*0.95) or \ omitted_were_assigned: prev_Q_point_sol_f = sol_f converging = False continue else: prev_Q_point_sol_f = sol_f converging = True ## "Is problem small?" -> PAIR ## if len(D) <= 80: pair_improved = pair_heuristic(routes, D, C, d, L) if pair_improved and minimize_K: _remove_empty_in_place(routes) changed |= pair_improved if __debug__: _log_after_ls_op("PAIR", changed, routes, D) ## "Is deleted true?" ## if deleted: # "DELETE" -> "Was delete succesful?" ## deleted = delete_heuristic(routes, D, C, d, L) if deleted and minimize_K: _remove_empty_in_place(routes) changed |= deleted if __debug__: _log_after_ls_op("DELETE", changed, routes, D) ## DISENTANGLE ## disentangle_improved = disentangle_heuristic( routes, sweep, node_phis, D, C, d, L) if disentangle_improved and minimize_K: _remove_empty_in_place(routes) changed |= disentangle_improved if __debug__: _log_after_ls_op("DISENTANGLE", changed, routes, D) ## "Has situation changed in interation?" ## solution_changed_between_iterations = True if prev_iteration_sol_f: if prev_iteration_sol_f == sol_f: solution_changed_between_iterations = False prev_iteration_sol_f = sol_f if converging and ((full_convergence and not changed) or (not full_convergence and not deleted) or (not solution_changed_between_iterations)): ## STOP ## break except KeyboardInterrupt: interrupted = True # return the optimized solutios sol = [0] + [n for r in routes for n in r.route[1:]] # LS may cause empty routes sol = without_empty_routes(sol) sol_K = sol.count(0) - 1 sol_f = objf(sol, D) if __debug__: log(DEBUG, "Improved solution %s (%.2f)" % (sol, sol_f)) if is_better_sol(best_f, best_K, sol_f, sol_K, minimize_K): best_sol = sol best_f = sol_f best_K = sol_K if interrupted: raise KeyboardInterrupt(best_sol) return best_sol
def test_insert_from_empty_to_empty(self): _, result_rd, result_delta = do_insert_move(RouteData(), RouteData(), self.D) self.assertEqual(result_rd.route, [0, 0], "Should remain empty")