def _customer_routing_cost(route: Route, customer: int, idx: int) -> float: customers = route.customers problem = Problem() assert 0 <= idx < len(customers) assert customer in route # There is just one customer, which, once removed, would result in a cost # of zero. Hence the cost for this single customer is just the route cost. if len(customers) == 1: return route.routing_cost() if idx == 0: cost = problem.short_distances[DEPOT, customer, customers[1]] cost -= problem.distances[DEPOT + 1, customers[1] + 1] return cost if idx == len(route) - 1: cost = problem.short_distances[customers[-2], customer, DEPOT] cost -= problem.distances[customers[-2] + 1, DEPOT + 1] return cost cost = problem.short_distances[customers[idx - 1], customer, customers[idx + 1]] cost -= problem.distances[customers[idx - 1] + 1, customers[idx + 1] + 1] return cost
def item_reinsert(route: Route) -> Route: """ Reinserts customer demands and pickups item in the optimal stack and position. Stops once an improving move has been found. """ if np.isclose(route.handling_cost(), 0.): return route problem = Problem() for idx, customer in enumerate(route, 1): delivery = problem.demands[customer] pickup = problem.pickups[customer] new_route = deepcopy(route) for stacks in new_route.plan[:idx]: stacks.find_stack(delivery).remove(delivery) for stacks in new_route.plan[idx:]: stacks.find_stack(pickup).remove(pickup) next_route = _insert_item(new_route, delivery) next_route = _insert_item(next_route, pickup) if next_route.handling_cost() < route.handling_cost(): return next_route return route
def in_route_two_opt(route: Route) -> Route: """ Performs the best in-route two-opt swap, based on routing costs. Intra 2-opt in Hornstra et al. (2020). """ problem = Problem() tour = np.array([DEPOT] + route.customers.to_list()) feasible_moves = Heap() feasible_moves.push(route.cost(), route) for first in range(1, len(route)): for second in range(first + 1, len(route)): if _gain(tour, first, second) >= 0: continue # this is not a better move. # This is a better route than the one we have currently. Of course # that does not mean we can find a good handling configuration as # well, so we attempt to create a route for this 2-opt move and skip # if it is infeasible. tour[first:second] = tour[second - 1:first - 1:-1] new_route = Route([], [Stacks(problem.num_stacks)]) if new_route.attempt_append_tail(tour[1:]): feasible_moves.push(new_route.cost(), new_route) _, best_route = feasible_moves.pop() return best_route
def pickup_push_to_front(route: Route) -> Route: """ Pushes pickup items to the front, at various legs of the route. This is somewhat preferred, as these items cannot get in the way if they are positioned at the front. """ if np.isclose(route.handling_cost(), 0.): return route problem = Problem() for idx_customer, customer in enumerate(route, 1): pickup = problem.pickups[customer] idx_stack = route.plan[idx_customer].find_stack(pickup).index # This skips the last offset, as that would not be interesting anyway # (that is the leg towards the depot, where we have only pickups and # handling can no longer be improved). for plan_offset in range(idx_customer, len(route)): new_route = deepcopy(route) for stacks in new_route.plan[plan_offset:]: stack = stacks[idx_stack] idx_current = stack.item_index(pickup) stack.remove(pickup) stack.push(idx_current + 1, pickup) new_route.invalidate_handling_cache() if new_route.handling_cost() < route.handling_cost(): return new_route return route
def cross_route(current: Solution, rnd_state: Generator) -> Solution: """ Selects two customers that are nearest to each other and their neighbours and removes them from the solution. See ``customers_to_remove`` for the degree of destruction done. Similar to cross route removal in Hornstra et al. (2020). """ problem = Problem() destroyed = deepcopy(current) customers = set(range(problem.num_customers)) removed = SetList() while len(removed) < customers_to_remove(): candidate = rnd_state.choice(tuple(customers)) route_candidate = destroyed.find_route(candidate) _remove(destroyed, candidate, removed, customers) # Find the nearest customer that is not yet removed and in a different # route, and remove it and its neighbours as well. for other in problem.nearest_customers[candidate]: if other not in route_candidate and other not in removed: _remove(destroyed, other, removed, customers) break destroyed.unassigned = removed.to_list() return destroyed
def random_nearest(current: Solution, rnd_state: Generator) -> Solution: """ Removes customers from the solution that are near each other in distance. See ``customers_to_remove`` for the degree of destruction done. Similar to related removal in Hornstra et al. (2020). """ destroyed = deepcopy(current) problem = Problem() removed = SetList() while len(removed) != customers_to_remove(): # Either chooses from the removed list, or a random customer if the # list is not yet populated. customer = rnd_state.choice( removed if len(removed) != 0 else problem.num_customers) # Find nearest other customer that's not already removed. This should # be fairly fast in practice, but is at most O(n), with n the number # of customers. for other in problem.nearest_customers[customer]: if other not in removed: removed.append(other) route = destroyed.find_route(other) route.remove_customer(other) break destroyed.unassigned = removed.to_list() return destroyed
def room_type(solver): """ For each classroom-module assignment, this guarantees the room types agree. Room types are categorical ({1, 2, ...}), where only equality suffices - there is no ordering! Note ---- This constraint does not hold for self-study; self-study is checked in the ``self_study_allowed`` constraint. """ problem = Problem() for module, classroom in itertools.product(range(len(problem.modules) - 1), range(len(problem.classrooms))): classroom_module = solver.sum( solver.module_resources[module, classroom, teacher] for teacher in range(len(problem.teachers))) module_room_type = problem.modules[module].room_type classroom_room_type = problem.classrooms[classroom].room_type # Room type is a categorical variable: the only requirement is that, # if the classroom is assigned to the given module, that the room types # match *exactly*. solver.add_constraint(module_room_type * classroom_module == classroom_room_type * classroom_module)
def _max_capacity(classroom: Classroom, module: Module) -> int: """ Computes the maximum room capacity, subject to constraints. """ if module.is_self_study(): return classroom.capacity return min(classroom.capacity, Problem().max_batch)
def learner_preferences(solution: List[Tuple]) -> bool: """ Verifies learners are all assigned to modules they are eligible to take, that is, hold strictly positive preferences for. """ problem = Problem() return all(problem.preferences[learner, module] > 0 for learner, module, *_ in 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 learner_schedule(solver): """ This constraint ensures learners are assigned to exact *one* module. """ problem = Problem() for learner in range(problem.num_learners): assignments = solver.sum(solver.assignment[learner, module] for module in range(len(problem.modules))) solver.add_constraint(assignments == 1)
def vehicle_capacity_is_respected(solution: Solution) -> Tuple[bool, str]: """ Verifies the vehicle capacities are respected by each tour of the solution. """ problem = Problem() for route in solution.routes: if any(stacks.used_capacity() > problem.capacity for stacks in route.plan): return False, "Vehicle capacity is not respected." return True, "Vehicle capacity is respected."
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 classrooms_to_teachers(solution: List[Tuple]) -> bool: """ Verifies each classroom is assigned to only *one* teacher. """ problem = Problem() classroom_teachers = defaultdict(set) for assignment in solution: *_, classroom, teacher = assignment classroom_teachers[classroom].add(teacher) if len(classroom_teachers) > len(problem.classrooms): return False return all(len(value) == 1 for value in classroom_teachers.values())
def all_customers_visited(solution: Solution) -> Tuple[bool, str]: """ Verifies the solution visits all customers (at least once). """ problem = Problem() customers = set() for route in solution.routes: customers.update(route.customers) for customer in range(problem.num_customers): if customer not in customers: return False, f"Customer {customer} is not in the solution." return True, "All customers are visited."
def routing_costs(sol: Solution) -> np.ndarray: """ Computes routing costs for each customer, as the cost made currently for having a customer in a route, against the alternative of not having said customer in the route: e.g., for customer [2] this compares the hypothetical route [1] -> [2] -> [3] with the alternative of [1] -> [3]. The difference in cost is the customer's routing cost. O(|customers|). """ problem = Problem() costs = np.zeros(problem.num_customers) for route in sol.routes: for idx, customer in enumerate(route): costs[customer] = _customer_routing_cost(route, customer, idx) return costs
def _setup_objective(solver: Model): """ Specifies the optimisation objective. """ problem = Problem() preference_max = solver.sum(problem.preferences[i, j] * solver.assignment[i, j] for i in range(len(problem.learners)) for j in range(len(problem.modules))) self_study_penalty = solver.sum( problem.penalty * solver.assignment[i, len(problem.modules) - 1] for i in range(len(problem.learners))) solver.maximize(preference_max - self_study_penalty)
def worst_learners(current: Solution, generator: Generator): """ Computes the costs for each learner, as the difference between their best and current assignments. Using a skewed distribution, q of the worst cost learners are randomly selected and removed from the solution. """ destroyed = deepcopy(current) problem = Problem() costs = np.zeros(problem.num_learners) assigned_activities = {} for activity in destroyed.activities: for learner in activity.learners: assigned_activities[learner.id] = activity learner_ids = activity.learner_ids() # The cost is the cost of the best possible assignment for this # learner, minus the cost of the current assignment (including # self-study penalty, if applicable). The larger the cost, the more # suboptimal the current assignment. best_module_id = problem.most_preferred[learner_ids, 0] curr_module_id = activity.module.id costs[learner_ids] = problem.preferences[learner_ids, best_module_id] costs[learner_ids] -= problem.preferences[learner_ids, curr_module_id] if activity.is_self_study(): # Per the paper: pref(best) - (pref(curr) - <maybe penalty>) # = pref(best) - pref(curr) + <maybe penalty>. costs[learner_ids] += problem.penalty learners = np.argsort(costs) learners = learners[-random_selection(generator)] for learner_id in learners: activity = assigned_activities[learner_id] if activity.can_remove_learner(): learner = problem.learners[learner_id] destroyed.unassigned.add(learner) activity.remove_learner(learner) return destroyed
def classrooms_to_modules(solution: List[Tuple]) -> bool: """ Verifies the number of classrooms assigned is less than the total number of classrooms available, and each classroom is assigned to *one* module only. """ problem = Problem() classroom_modules = defaultdict(set) for assignment in solution: _, module, classroom, _ = assignment classroom_modules[classroom].add(module) if len(classroom_modules) > len(problem.classrooms): return False return all(len(value) == 1 for value in classroom_modules.values())
def create_single_customer_route(customer: int) -> Route: """ Creates a single customer route for the passed-in customer. This route visits the DEPOT, then the customer, and returns to the DEPOT. O(1). """ problem = Problem() # After depot, and after customer: two configurations in total. stacks = [Stacks(problem.num_stacks) for _ in range(2)] # We place the deliveries and pickups in the shortest stack - this # does not really matter much, as each stack is empty at this point # anyway. stacks[0].shortest_stack().push_rear(problem.demands[customer]) stacks[1].shortest_stack().push_rear(problem.pickups[customer]) return Route([customer], stacks)
def _setup_decision_variables(solver: Model): """ Prepares and applies the decision variables to the model. """ problem = Problem() assignment_problem = [ list(range(len(problem.learners))), list(range(len(problem.modules))), list(range(len(problem.classrooms))), list(range(len(problem.teachers))) ] solver.assignment = solver.binary_var_matrix(*assignment_problem[:2], name="learner_module") solver.module_resources = solver.binary_var_cube(*assignment_problem[1:], name="module_resources")
def _gain(route1: Route, idx1: int, route2: Route, idx2: int) -> float: next1 = DEPOT if idx1 == len(route1) - 1 else route1.customers[idx1 + 1] next2 = DEPOT if idx2 == len(route2) - 1 else route2.customers[idx2 + 1] problem = Problem() customer1 = route1.customers[idx1] customer2 = route2.customers[idx2] # Proposed changes. gain = problem.distances[customer2 + 1, next1 + 1] gain += problem.distances[customer1 + 1, next2 + 1] # Current situation. gain -= problem.distances[customer1 + 1, next1 + 1] gain -= problem.distances[customer2 + 1, next2 + 1] return gain
def random_selection(generator: Generator): """ Implements a random selection mechanism, which selects random indices for a certain list of num_learners length (e.g., for a cost computation), favouring smaller indices. """ problem = Problem() triangle = np.arange(learners_to_remove(), 0, -1) probabilities = np.ones(problem.num_learners) probabilities[:learners_to_remove()] = triangle probabilities = probabilities / np.sum(probabilities) return generator.choice(problem.num_learners, learners_to_remove(), replace=False, p=probabilities)
def random_customers(current: Solution, rnd_state: Generator) -> Solution: """ Removes a number of randomly selected customers from the passed-in solution. See ``customers_to_remove`` for the degree of destruction done. Random removal in Hornstra et al. (2020). """ destroyed = deepcopy(current) for customer in rnd_state.choice(Problem().num_customers, customers_to_remove(), replace=False): destroyed.unassigned.append(customer) route = destroyed.find_route(customer) route.remove_customer(customer) return destroyed
def module_classroom_room_type(solution: List[Tuple]) -> bool: """ Verifies each classroom-module assignment satisfies the room type requirement. """ problem = Problem() classrooms = {} for assignment in solution: _, module, classroom, _ = assignment classrooms[classroom] = module for classroom_idx, module_idx in classrooms.items(): classroom = problem.classrooms[classroom_idx] module = problem.modules[module_idx] if not classroom.is_qualified_for(module): return False return True
def self_study_allowed(solver): """ For each classroom, checks if a self-study assignment is allowed. This ensures only classrooms that allow self-study are used for self-study assignments. Note ---- Multiple room types allow self-study, so self-study has its own boolean flag to differentiate. """ problem = Problem() for classroom in range(len(problem.classrooms)): assignment = solver.sum(solver.module_resources[len(problem.modules) - 1, classroom, teacher] for teacher in range(len(problem.teachers))) is_allowed = problem.classrooms[classroom].is_self_study_allowed() solver.add_constraint(assignment <= is_allowed)
def strictly_positive_assignment(solver): """ Ensures learners are assigned only to modules for which they hold a strictly positive preference. This guarantees learners are not assigned to modules they are currently ineligible to take. """ problem = Problem() for learner, module in itertools.product(range(len(problem.learners)), range(len(problem.modules))): preference = problem.preferences[learner, module] \ * solver.assignment[learner, module] grace = solver.B * (1 - solver.assignment[learner, module]) # For each learner and module assignment, the preference for that # module needs to be strictly positive - unless the learner is not # assigned, in which case we use a grace term to ensure the constraint # holds. solver.add_constraint(preference + grace >= 0.00001)
def teacher_module_qualifications(solution: List[Tuple]) -> bool: """ Verifies each teacher-module assignment satisfies the required teacher qualification. """ problem = Problem() teacher_modules = {} for assignment in solution: _, module, _, teacher = assignment teacher_modules[teacher] = module for teacher_idx, module_idx in teacher_modules.items(): teacher = problem.teachers[teacher_idx] module = problem.modules[module_idx] if not teacher.is_qualified_for(module): return False return True
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 teaching_qualification(solver): """ The teaching qualification constraint ensures teacher-module assignments are feasible. Each module has a teaching qualification requirement, and each teacher has a qualification for each module. Qualifications are ordinal as {0, 1, 2, 3}, where ``0`` indicates no qualification, and the rest are in decreasing level of qualification: a ``1`` is better than a ``2``. Example ------- Suppose a module requires a qualification of ``2``. Then any teacher with an equal or better (in this case, ``2`` or ``1``) qualification is eligible to teach this activity. Note ---- Since self-study has a required qualification of ``3``, every teacher is eligible to supervise the activity. As such, we can ignore this assignment here. """ problem = Problem() for module, teacher in itertools.product(range(len(problem.modules) - 1), range(len(problem.teachers))): is_assigned = solver.sum( solver.module_resources[module, classroom, teacher] for classroom in range(len(problem.classrooms))) module_qualification = problem.modules[module].qualification teacher_qualification = problem.qualifications[teacher, module] # Module requirement must be 'less' than teacher qualification. solver.add_constraint(module_qualification * is_assigned >= teacher_qualification * is_assigned) # Zero implies the teacher is not qualified, so we should ensure those # assignments cannot happen. solver.add_constraint(teacher_qualification * is_assigned >= is_assigned)