def exchange_customer(solution: Solution) -> Solution: """ Performs exchange moves between two customers. Of all such moves, the best is performed and the updated solution is returned. O(n^2), where n is the number of customers. Similar to exchange in Hornstra et al. (2020). References ---------- - Savelsbergh, Martin W. P. 1992. "The Vehicle Routing Problem with Time Windows: Minimizing Route Duration." *ORSA Journal on Computing* 4 (2): 146-154. """ improvements = Heap() costs = routing_costs(solution) for idx1, route1 in enumerate(solution.routes): for idx2, route2 in enumerate(solution.routes[idx1 + 1:], idx1 + 1): iterable = product(range(len(route1)), range(len(route2))) for idx_cust1, idx_cust2 in iterable: if _gain(costs, route1, idx_cust1, route2, idx_cust2) >= 0: continue new_route1 = deepcopy(route1) new_route2 = deepcopy(route2) customer1 = route1.customers[idx_cust1] customer2 = route2.customers[idx_cust2] new_route1.remove_customer(customer1) new_route2.remove_customer(customer2) if not new_route1.can_insert(customer2, idx_cust1): continue if not new_route2.can_insert(customer1, idx_cust2): continue new_route1.insert_customer(customer2, idx_cust1) new_route2.insert_customer(customer1, idx_cust2) current = route1.cost() + route2.cost() proposed = new_route1.cost() + new_route2.cost() if proposed < current: improvements.push(proposed, (idx1, new_route1, idx2, new_route2)) if len(improvements) != 0: _, (idx1, new_route1, idx2, new_route2) = improvements.pop() solution = copy(solution) solution.routes[idx1] = new_route1 solution.routes[idx2] = new_route2 return solution
def cross_customer_exchange(solution: Solution) -> Solution: """ Tries to remove crossing links between routes. Of all such moves, the best is performed and the updated solution is returned. O(n^2), where n is the number of customers. References ---------- - Savelsbergh, Martin W. P. 1992. "The Vehicle Routing Problem with Time Windows: Minimizing Route Duration." *ORSA Journal on Computing* 4 (2): 146-154. """ improvements = Heap() for idx1, route1 in enumerate(solution.routes): for idx2, route2 in enumerate(solution.routes[idx1 + 1:], idx1 + 1): iterable = product(range(len(route1)), range(len(route2))) for idx_cust1, idx_cust2 in iterable: if _gain(route1, idx_cust1, route2, idx_cust2) >= 0: continue new_route1 = deepcopy(route1) new_route2 = deepcopy(route2) first_customers = route1.customers[idx_cust1 + 1:] second_customers = route2.customers[idx_cust2 + 1:] for customer in first_customers: new_route1.remove_customer(customer) for customer in second_customers: new_route2.remove_customer(customer) if not new_route1.attempt_append_tail(second_customers): continue if not new_route2.attempt_append_tail(first_customers): continue current = route1.cost() + route2.cost() proposed = new_route1.cost() + new_route2.cost() if proposed < current: improvements.push(proposed, (idx1, new_route1, idx2, new_route2)) if len(improvements) != 0: _, (idx1, new_route1, idx2, new_route2) = improvements.pop() solution = copy(solution) solution.routes[idx1] = new_route1 solution.routes[idx2] = new_route2 return solution
def break_out(destroyed: Solution, generator: Generator) -> Solution: """ Breaks out instruction activities based on the preferences of the unassigned learners. Where possible, a new activity is formed from these learners, along with some self-study learners that strictly prefer the new activity over self-study. If any learners remain that cannot be assigned to a new activity, those are inserted into existing activities using ``greedy_insert``. """ problem = Problem() histogram = destroyed.preferences_by_module() while len(histogram) != 0: _, module, to_assign = heappop(histogram) try: classroom = destroyed.find_classroom_for(module) teacher = destroyed.find_teacher_for(module) except LookupError: continue max_size = min(classroom.capacity, problem.max_batch) for activity in destroyed.activities: if activity.is_instruction(): continue if len(to_assign) >= max_size: break # We snoop off any self-study learners that can be assigned to # this module as well. learners = [learner for learner in activity.learners if learner.is_qualified_for(module) if learner.prefers_over_self_study(module)] learners = learners[:max_size - len(to_assign)] num_removed = activity.remove_learners(learners) to_assign.extend(learners[:num_removed]) activity = Activity(to_assign[:max_size], classroom, teacher, module) destroyed.add_activity(activity) destroyed.unassigned -= set(activity.learners) return break_out(destroyed, generator) # Insert final learners into existing activities, if no new activity # can be scheduled. return greedy_insert(destroyed, generator)
def analyse(in_files: str, out_file: str): """ Creates a summary file for the passed-in in-files, at the out-file location. This file summarises all passed-in instances. """ file = Path(out_file) if file.exists(): last_modified = file.lstat().st_mtime timestamp = datetime.fromtimestamp(last_modified) print(f"Printing {out_file} (last modified {timestamp})") instances = pd.read_csv(out_file, skipfooter=1, engine="python") else: instances = pd.DataFrame() for location in glob.iglob(in_files): problem = Problem.from_file(location, delimiter=',') sol = Solution.from_file(f"solutions/oracs_{problem.instance}.csv") instance = dict([(func.__name__, func(sol)) for func in PARAMETERS + STATISTICS]) instances = instances.append(instance, ignore_index=True) report = make_pivot_table(instances) if not file.exists(): report.to_csv(out_file) print(report)
def __call__(self, current: Solution, *args) -> Solution: improved = self._improve(deepcopy(current), self._solution_operators) for idx, route in enumerate(improved.routes): improved.routes[idx] = self._improve(route, self._route_operators) assert improved.objective() <= current.objective() return improved
def relocate_customer(solution: Solution) -> Solution: """ Performs the best customer relocation move, based on routing costs. Of all such moves, the best is performed and the updated solution is returned. O(n^2), where n is the number of customers. Similar to reinsertion in Hornstra et al. (2020). References ---------- - Savelsbergh, Martin W. P. 1992. "The Vehicle Routing Problem with Time Windows: Minimizing Route Duration." *ORSA Journal on Computing* 4 (2): 146-154. """ improvements = Heap() costs = routing_costs(solution) for idx_route, curr_route in enumerate(solution.routes): for customer in curr_route: for route in solution.routes[idx_route:]: for idx in range(len(route) + 1): gain = _gain(costs, route, idx, customer) if gain >= 0 or not route.can_insert(customer, idx): # This is either infeasible, or not an improving move. continue # The following performs the proposed move on a copy of the # two routes involved. If the move is an improvement, it is # added to the pool of improving moves. old_route = deepcopy(curr_route) new_route = deepcopy(route) old_route.remove_customer(customer) new_route.insert_customer(customer, idx) current = route.cost() + curr_route.cost() proposed = old_route.cost() + new_route.cost() if proposed < current: improvements.push(proposed, (customer, idx, route)) if len(improvements) != 0: _, (customer, insert_idx, next_route) = improvements.pop() solution = copy(solution) route = solution.find_route(customer) if route is next_route and route.customers.index( customer) < insert_idx: # We re-insert into the same route, and the insert location will # shift once we remove the customer. This accounts for that. insert_idx -= 1 route.remove_customer(customer) next_route.insert_customer(customer, insert_idx) return solution
def reinsert_learner(current: Solution, generator: Generator) -> Solution: """ Computes the best reinsertion moves for each learner, stores these in order, and executes them. This improves the solution further by moving learners into strictly improving assignments, if possible. """ problem = Problem() # Get all instruction activities, grouped by module. We only consider # moves out of self-study (self-study could be better as well, but the # structure of the repair operators makes it unlikely it is preferred over # the current learner assignment). activities_by_module = current.activities_by_module() del activities_by_module[problem.self_study_module] moves = [] for from_activity in current.activities: if not from_activity.can_remove_learner(): continue for learner in from_activity.learners: for module_id in problem.most_preferred[learner.id]: module = problem.modules[module_id] if from_activity.is_self_study() \ and not learner.prefers_over_self_study(module): break gain = problem.preferences[learner.id, module_id] gain -= problem.preferences[learner.id, from_activity.module.id] if gain <= 0: break for to_activity in activities_by_module[module_id]: if to_activity.can_insert_learner(): # Random value only to ensure this orders well - # learners and activities cannot be used to compare # the tuples, and gain is the same for many values. item = (-gain, generator.random(), learner, from_activity, to_activity) heappush(moves, item) has_moved = set() # tracks whether we've already moved a learner. while len(moves) != 0: *_, learner, from_activity, to_activity = heappop(moves) if learner not in has_moved \ and from_activity.can_remove_learner() \ and to_activity.can_insert_learner(): from_activity.remove_learner(learner) to_activity.insert_learner(learner) has_moved.add(learner) return current
def _remove(destroyed: Solution, customer: int, removed: SetList, customers: Set): route = destroyed.find_route(customer) idx = route.customers.index(customer) # Selects the customer and the direct neighbours before and after the # customer, should those exist. selected = route.customers[max(idx - 1, 0):min(idx + 2, len(route))] for candidate in selected: removed.append(candidate) route.remove_customer(candidate) customers.remove(candidate)
def initial_solution() -> Solution: """ Constructs an initial solution, where all learners are in self-study activities, in appropriate classrooms (and with some random teacher assigned to supervise). """ problem = Problem() solution = Solution([]) # Not all classrooms are suitable for self-study. Such a restriction does, # however, not apply to teachers. classrooms = [classroom for classroom in problem.classrooms if classroom.is_self_study_allowed()] learners_to_assign = problem.learners for classroom, teacher in zip_longest(classrooms, problem.teachers): assert classroom is not None assert teacher is not None learners = learners_to_assign[-min(len(learners_to_assign), classroom.capacity):] activity = Activity(learners, classroom, teacher, problem.self_study_module) solution.add_activity(activity) learners_to_assign = learners_to_assign[:-activity.num_learners] if len(learners_to_assign) == 0: break return solution
def compute(parser, args): measures = [] for instance in np.arange(1, 101): location = Path(f"experiments/{args.experiment}/" f"{instance}-{args.method}.json") if not location.exists(): print(f"{parser.prog}: {location} does not exist; skipping.") continue Problem.from_instance(args.experiment, instance) sol = Solution.from_file(location) measures.append({name: fn(sol) for name, fn in MEASURES.items()}) return pd.DataFrame.from_records(measures, columns=MEASURES.keys())
def main(): if len(sys.argv) < 2: raise ValueError(f"{sys.argv[0]}: expected file location.") problem = Problem.from_file(sys.argv[1], delimiter=',') solution = Solution.from_file(f"solutions/oracs_{problem.instance}.csv") is_feasible = True for idx, rule in enumerate(RULES): result, message = rule(solution) print(f"{idx}: {message}") if not result: is_feasible = False exit(0 if is_feasible else 1)
def all_pickups_are_satisfied(solution: Solution) -> Tuple[bool, str]: """ Verifies all pickups are satisfied, that is, the pickup items are loaded according to a feasible loading plan for each customer. """ problem = Problem() for customer in range(problem.num_customers): route = solution.find_route(customer) pickup = problem.pickups[customer] for stacks in route.plan[route.customers.index(customer) + 1:]: try: # Quickly finds the stack this item is stored in, or raises # if no such stack exists. Just the existence is sufficient. stacks.find_stack(pickup) except LookupError: return False, f"{pickup} is not in the solution for all " \ f"appropriate legs of the route." return True, "All pick-ups are satisfied."
def greedy_insert(destroyed: Solution, generator: Generator) -> Solution: """ Greedily inserts learners into the best, feasible activities. If no activity can be found for a learner, (s)he is inserted into self-study instead. """ problem = Problem() unused_teachers = set(problem.teachers) - destroyed.used_teachers() unused_classrooms = set(problem.classrooms) - destroyed.used_classrooms() unused_classrooms = [ classroom for classroom in unused_classrooms if classroom.is_self_study_allowed() ] # It is typically a good idea to prefers using larger rooms for self-study. unused_classrooms.sort(key=attrgetter("capacity")) activities = destroyed.activities_by_module() while len(destroyed.unassigned) != 0: learner = destroyed.unassigned.pop() inserted = False # Attempts to insert the learner into the most preferred, feasible # instruction activity. for module_id in problem.most_preferred[learner.id]: module = problem.modules[module_id] if module not in activities: continue if not learner.prefers_over_self_study(module): break # TODO Py3.8: use assignment expression in if-statement. inserted = _insert(learner, activities[module]) if inserted: break # Could not insert, so the module activities must be exhausted. del activities[module] # Learner could not be inserted into a regular instruction activity, # so now we opt for self-study. if not inserted and not _insert(learner, activities[problem.self_study_module]): if len(unused_classrooms) == 0 or len(unused_teachers) == 0: # This implies we need to remove one or more instruction # activities. Let's do the naive and greedy thing, and switch # the instruction activity with lowest objective value into a # self-study assignment. iterable = [ activity for activity in destroyed.activities if activity.is_instruction() if activity.classroom.is_self_study_allowed() # After switching to self-study, the max_batch # constraint is longer applicable - only capacity. if activity.num_learners < activity.classroom.capacity ] activity = min(iterable, key=methodcaller("objective")) activities[problem.self_study_module].append(activity) activities[activity.module].remove(activity) activity.switch_to_self_study() activity.insert_learner(learner) continue for activity in activities[problem.self_study_module]: biggest_classroom = unused_classrooms[-1] if activity.classroom.capacity < biggest_classroom.capacity: current = activity.classroom activity.classroom = unused_classrooms.pop() unused_classrooms.insert(0, current) destroyed.switch_classrooms(current, activity.classroom) activity.insert_learner(learner) break if activity.can_split(): teacher = unused_teachers.pop() classroom = unused_classrooms.pop() new_activity = activity.split_with(classroom, teacher) activity.insert_learner(learner) destroyed.add_activity(new_activity) activities[problem.self_study_module].append(new_activity) break else: # It could be that there is no self-study activity. In that # case we should make one. Should be rare. classroom = unused_classrooms.pop() teacher = unused_teachers.pop() # TODO what if there are insufficient learners left? learners = [ destroyed.unassigned.pop() for _ in range(problem.min_batch) ] activity = Activity(learners, classroom, teacher, problem.self_study_module) # Since we popped this learner from the unassigned list before, # it is not yet in the new activity. activity.insert_learner(learner) destroyed.add_activity(activity) activities[problem.self_study_module].append(activity) return destroyed
def objective(solution: Solution): return -solution.objective()
def objective(solution: Solution) -> float: """ Returns solution objective. """ return solution.objective()