def test_2pm_updated_route_capacity(self): # route 2 capacity 3.0 , route 1 capacity 4.0 (in _make_improving_move) # node #4 is moved and with it the demand of 1.4 from r1 to r2 d = [0.0, 0.7, 1.0, 1.3, 1.4, 1.5, 0.5, 0.6] result = self._make_improving_2pm_move(C=5.0, d=d) for rd in result: self.assertAlmostEqual( totald(rd[0], d), rd[2], msg= "original route cost + savings should match recalculated route cost" )
def gap_init(points, D, d, C, L=None, st=None, K=None, minimize_K=True, find_optimal_seeds=True, seed_method="cones", seed_edge_weight_type='EUC_2D', use_adaptive_L_constraint_weights=True, increase_K_on_failure=False): #REMOVEME, disable! #increase_K_on_failure=True): """ An implementation of a three phase cluster-first-route-second CVRP construction / route initialization algorithm. The first two phases involve the clustering. First, a seed point is generated for each route, which is then used in approximating customer node service costs in solving generalized assignment problem (GAP) relaxation of the VRP. The resulting assignments are then routed using a TSP solver. The algorithm has been first proposed in (Fisher and Jaikumar 1981). The algorithm assumes that the problem is planar and this implementation allows seed in two ways: * seed_method="cones", the initialization method of Fisher and Jaikumar (1981) which can be described as Sweep with fractional distribution of customer demand and placing the seed points approximately to the center of demand mass of created sectors. * seed_method="kmeans", intialize seed points to k-means cluster centers. * seed_method="large_demands", according to Fisher and Jaikumar (1981) "Customers for which d_i > 1/2 C can also be made seed customers". However applying this rule relies on human operator who then decides the intuitively best seed points. This implementation selects the seed points satisfying the criteria d_i>mC, where m is the fractional capacity multipier, that are farthest from the depot and each other. The m is made iteratively smaller if there are no at least K seed point candidates. * seed_method="ends_of_thoroughfares", this option was descibed in (Fisher and Jaikumar 1981) as "Most distant customers at the end of thoroughfares leaving from the depot are natural seed customers". They relied on human operator. To automate this selection we make a DBSCAN clustering with eps = median 2. nearest neighbor of all nodes and min_samples of 3. The other parameters are: * points is a list of x,y coordinates of the depot [0] and the customers. * D is a numpy ndarray (or equvalent) of the full 2D distance matrix. including the service times (st/2.0 for leaving and entering nodes). * 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 length/duration/cost. * st is the service time. However, also the D should be modified with service times to allow straight computation of the TSP solutions (see above) * K is the optional parameter specifying the required number of vehicles. The algorithm is only allowed to find solutions with this many vehicles. * minimize_K, if set to True (default), makes the minimum number of routes the primary and the solution cost the secondary objective. If set False the algorithm optimizes for mimimum solution / route cost by increasing K as long as it seems beneficial. WARNING: the algorithm suits this use case (cost at the objective) poorly and setting this option to False may significantly increase the required CPU time. * find_optimal_seeds if set to True, tries all possible Sweep start positions / k-Means with N different seeds. If False, only one sweep from the node closest to the depot is done / k-Means clustering is done only once with one random seed value. * seed_edge_weight_type specifies how to round off the distances from the customer nodes (points) to the seed points. Supports all TSPLIB edge weight types. Note1: The GAP is optimized using Gurobi solver. If L constraint is set, the side constraints may make the GAP instance tricky to solve and it is advisable to set a sensible timeout with config.MAX_MIP_SOLVER_RUNTIME * use_adaptive_L_constraint_weights if set True, and the L constraint is set, the algorithm adaptively adjusts the route cost approximation of the relevant side constraints so that a solution which is not L infeasible or GAP infeasible is found. The exact handling of L consraint is vague in (Fisher and Jaikumar 1981) and this was our best guess on how the feasible region of the problem can be found. Note that if GAP solver is terminated due to a timeout, the adaptive multipier is increased and GAP solution is attempted again. However, if increase_K_on_failure is set, (see below) it takes priority over this. * increase_K_on_failure (default False) is another countermeasure against long running GAP solving attempts for problem instances without L constraint (if there is L constraint, and use_adaptive_L_constraint_- weights is enabled, this is ignored) or instances where K estimation does not work and it takes excessively long time to check all initial seed configurations before increasing K. If Gurobi timeout is encountered or the solution is GAP infeasible, and this option is enabled, the K is temporately increased, new seeds points generated for current sweep start location and another GAP solution attempt is made. K is allowed to increased temporarely up to 10% of the mimimum K allowed (or 1, whichever is larger). Note2: logger controls the debug level but running the script with Python -O option disables all debug output. Fisher, M. L. and Jaikumar, R. (1981), A generalized assignment heuristic for vehicle routing. Networks, 11: 109-124. doi:10.1002/net.3230110205 """ #TODO: other alternatives # customers with maximum demand or most distant customer from origin if seed_method == "cones": seed_f = _sweep_seed_points if seed_method == "kmeans": seed_f = _kmeans_seed_points if seed_method == "large_demands": if not C: raise ValueError( """The "large_demands" seed initialization method requires demands and C constraint to be known.""" ) seed_f = _large_demand_seed_points if seed_method == "ends_of_thoroughfares": seed_f = _end_of_thoroughfares_seed_points int_dists = issubclass(D.dtype.type, np.integer) if seed_edge_weight_type == "EXPLICIT": seed_edge_weight_type = "EUC_2D" if int_dists else "EXACT_2D" if not points: raise ValueError( "The algorithm requires 2D coordinates for the points") N = len(D) if K: startK = K maxK = K else: # start from the smallest K possible if C: startK = int(ceil(sum(d) / C)) elif L: # find a lower bound by checking how many visits from the TSP # tour need to add to have any chance of making this L feasible. _, tsp_f = solve_tsp(D, range(1, N)) shortest_depot_edges = list(D[0, 1:]) shortest_depot_edges.sort() startK = int(ceil(tsp_f / L)) while True: if tsp_f + sum( shortest_depot_edges[:startK * 2]) <= startK * L: break startK += 1 else: raise ValueError("If C and L have not been set, K is required") maxK = N - 1 # We only need first row of the distance matrix to calculcate insertion # costs for GAP objective function D_0 = np.copy(D[0, :]) best_sol = None best_f = None best_K = None seed_trial = 0 incK = 0 maxKinc = max(startK + 1, int(startK * INCREASE_K_ON_FAILURE_UPTO)) L_ctr_multipiler = L_MPLR_DEFAULT if L and use_adaptive_L_constraint_weights: # Adaptive L constraint multipier L_ctr_multipiler = L_ADAPTIVE_MPLR_INIT L_ctr_multipiler_tries = 0 try: for currentK in range(startK, maxK + 1): found_improving_solution_for_this_K = False seed_trial = 0 while True: if __debug__: log( DEBUG, "ITERATION:K=%d, trial=%d, L_ctr_mul=%.6f\n" % (currentK + incK, seed_trial, L_ctr_multipiler)) log(DEBUG - 1, "Getting %d seed points...\n" % (currentK + incK)) # Get seed points seed_points = seed_f(points, D, d, C, currentK + incK, seed_trial) if __debug__: log(DEBUG - 1, "...got seed points %s\n" % str(seed_points)) # Extend the distance matrix with seed distances S = calculate_D(seed_points, points, seed_edge_weight_type) if st: # include the "leaving half" of the service_time in the # distances (the other half is already added to the D # prior to gapvrp_init) halftst = int(st / 2) if int_dists else st / 2.0 S[:, 1:] += halftst D_s = np.vstack((D_0, S)) GAP_infeasible = False L_infeasible = False solution = [0] sol_f = 0 solved = False sol_K = 0 take_next_seed = False try: # Distribute the nodes to vehicles using the approxmate # service costs in D_s and by solving it as GAP # #TODO: the model has the same dimensions for all iterations # with the same K and only the weights differ. Consider # replacing the coefficient matrix e.g. via C interface #https://stackoverflow.com/questions/33461329 assignments = _solve_gap(N, D_s, d, C, currentK + incK, L, L_ctr_multipiler) if not assignments: if __debug__: log(DEBUG, "INFEASIBILITY: GAP infeasible solution") corrective_action = "try with another seed = %d" % seed_trial GAP_infeasible = True else: if __debug__: log(DEBUG - 1, "Assignments = %s" % str(assignments)) # Due to floating point inaccuracies in L constrained # cases the feasrelax may be used, which, in turn, can # in some corner cases return solutions that are not # really feasible. Make sure it is not the case if L: served = set([0]) for route_nodes in assignments: if not route_nodes: continue route, route_l = solve_tsp(D, [0] + route_nodes) # Check for feasibility violations due to feasrelax if L: served |= set(route_nodes) if C and d and totald(route, d) - C_EPS > C: if __debug__: log( DEBUG, "INFEASIBILITY: feasRelax " + "caused GAP infeasible solution " + " (capacity constraint violation)") GAP_infeasible = True break # the route loop solution += route[1:] sol_f += route_l sol_K += 1 if __debug__: log( DEBUG - 2, "DEBUG: Got TSP solution %s (%.2f)" % (str(route), route_l)) if L and route_l - S_EPS > L: if __debug__: log( DEBUG, "INFEASIBILITY: L infeasible solution") L_infeasible = True break # break route for loop # Check for feasibility violations due to feasrelax. # Have all customers been served? if not GAP_infeasible and not L_infeasible and\ L and len(served)<len(D): if __debug__: log( DEBUG, "INFEASIBILITY: feasRelax caused GAP " + "infeasible solution (all customers " + "are not served)") GAP_infeasible = True if not GAP_infeasible and not L_infeasible: if __debug__: log( DEBUG, "Yielded feasible solution = %s (%.2f)" % (str(solution), sol_f)) solved = True except GurobiError as grbe: if __debug__: log(WARNING, str(grbe)) if L and use_adaptive_L_constraint_weights and \ L_ctr_multipiler_tries<L_ADAPTIVE_MPLR_MAX_TRIES: L_ctr_multipiler += L_ADAPTIVE_MPLR_INC L_ctr_multipiler_tries += 1 if __debug__: corrective_action = "Gurobi timeout, try with another L_ctr_multipiler = %.2f" % L_ctr_multipiler elif increase_K_on_failure and currentK + incK + 1 <= maxKinc: if L and use_adaptive_L_constraint_weights and\ L_ctr_multipiler_tries>=L_ADAPTIVE_MPLR_MAX_TRIES: # try with all multiplier values for larger K L_ctr_multipiler = L_ADAPTIVE_MPLR_INIT L_ctr_multipiler_tries = 0 incK += 1 if __debug__: corrective_action = "Gurobi timeout, temporarely increase K by %d" % incK elif find_optimal_seeds: take_next_seed = True else: grbe.message += ", consider increasing the MAX_MIP_SOLVER_RUNTIME in config.py" raise grbe else: if L and use_adaptive_L_constraint_weights: ## Adaptive GAP/L constraint multiplier reset # reset multiplier in case it the L feasibility was not violated # or it has reached the max_value. if solved or L_ctr_multipiler_tries >= L_ADAPTIVE_MPLR_MAX_TRIES: L_ctr_multipiler = L_ADAPTIVE_MPLR_INIT L_ctr_multipiler_tries = 0 take_next_seed = True if not solved and increase_K_on_failure and currentK + incK + 1 <= maxKinc: incK += 1 take_next_seed = False if __debug__: corrective_action = "temporarely increase K by %d" % incK else: if __debug__: corrective_action = "try with another seed = %d" % seed_trial ## Adaptive GAP/L constraint multiplier update else: L_ctr_multipiler += L_ADAPTIVE_MPLR_INC L_ctr_multipiler_tries += 1 if __debug__: corrective_action = "try with another L_ctr_multipiler = %.2f" % L_ctr_multipiler else: if not solved and increase_K_on_failure and currentK + incK + 1 <= maxKinc: incK += 1 if __debug__: corrective_action = "temporarely increase K by %d" % incK else: take_next_seed = True # Store the best so far if solved: if is_better_sol(best_f, best_K, sol_f, sol_K, minimize_K): best_sol = solution best_f = sol_f best_K = sol_K found_improving_solution_for_this_K = True else: # No feasible solution was found for this trial (max route cost # or capacity constraint was violated). if __debug__: if GAP_infeasible or L_infeasible: log(DEBUG, "Constraint is violated, " + corrective_action) else: log(DEBUG, "Continuing search, " + corrective_action) if take_next_seed: incK = 0 seed_trial += 1 if not find_optimal_seeds: break # seed loop, possibly try next K if seed_trial == N: incK = 0 break # seed loop, possibly try next K if minimize_K: # do not try different K if we found a solution if best_sol: break # K loop else: # not minimize_K # We already have an feasible solution for K<K_current, and could # not find a better solution than that on K_current. Therefore, it # is improbable we will find one even if we increase K and we # should stop here. if best_sol and not found_improving_solution_for_this_K: break except KeyboardInterrupt: #or SIGINT # pass on the current best_sol raise KeyboardInterrupt(best_sol) return best_sol
def _calculate_penalty(sol_or_route, D, d, C): penalty = 0 routes = sol2routes(sol_or_route) for r in routes: penalty += C - totald(r, d) * objf(r, D) return penalty
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