Exemple #1
0
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
Exemple #2
0
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
Exemple #6
0
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
Exemple #7
0
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)
Exemple #8
0
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)
Exemple #9
0
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."
Exemple #13
0
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."
Exemple #16
0
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
Exemple #17
0
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)
Exemple #21
0
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
Exemple #23
0
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
Exemple #25
0
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
Exemple #26
0
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)
Exemple #28
0
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)