def course_scheduler(course_descriptions, goal_conditions, initial_state): """ State consists of a conjunction of courses/high-level requirements that are to be achieved. When conjunction set is empty a viable schedule should be in the schedule_set. :param course_descriptions: Course catalog. A Python dictionary that uses Course as key and CourseInfo as value :param goal_conditions: A list of courses or high-level requirements that a viable schedule would need to fulfill :param initial_state: A list of courses the student has already taken :return: A List of scheduled courses in format (course, scheduled_term, course_credits) """ depths = range(1, 9) best_schedule = None best_schedule_num = float('inf') schedule = Schedule(course_descriptions, initial_state, goal_conditions) empty_schedule = schedule.copy() for depth in depths: schedule.max_semester = depth schedule.assign(empty_schedule) frontier = [] append_to_queue(frontier, goal_conditions, schedule) search(frontier, schedule) if 0 < schedule.num_of_courses_scheduled() < best_schedule_num: best_schedule = schedule.copy() best_schedule_num = schedule.num_of_courses_scheduled() schedule.assign(best_schedule) return schedule.get_plan()
class Scheduler: """Creates a Schedule.""" ghost = None @classmethod def spawn_ghost_dropoff(cls, map_data): """Instantiate a ghost dropoff, save it on the class.""" cls.ghost = GhostDropoff(map_data) @classmethod def remove_ghost_dropoff(cls, map_data): """Remove the ghost dropoff from the class.""" cls.ghost = None map_data.all_dropoffs = map_data.dropoffs def __init__(self, game, map_data): self.game_map = game.game_map self.me = game.me self.turn_number = game.turn_number self.turns_left = constants.MAX_TURNS - game.turn_number self.ships = self.me.get_ships() self.map_data = map_data if self.ghost: self.ghost.map_data = map_data self.schedule = Schedule(game, map_data) self.ships_per_dropoff = len(self.ships) / len(map_data.dropoffs) self.update_returning_to_dropoff() self.expected_halite = self._expected_halite() self.ghost_droppers = [] def mining_profit(self, bonus_factor, halite): """Calculate the total profit after mining up to 5 turns. Args: bonussed_halite (np.array): halite, including bonus factor. Returns: list(np.array): [<profit after 1 turn>, <profit after 2 turns>, ..] """ multipliers = (0.25, 0.4375, 0.578125, 0.68359375, 0.7626953125) return [bonus_factor * np.ceil(c * halite) for c in multipliers] def move_cost(self, halite): """Calculate the cost of moving after mining up to 3 turns. Args: halite (np.array): halite, not including bonus factor. Returns: list(np.array): [<cost after 1 turn>, <cost after 2 turns>, ..] """ multipliers = (0.075, 0.05625, 0.0421875) return [c * halite for c in multipliers] def _neighbour_profit(self, profit): """Profit after mining up to 3 turns at the best neighbour cell. Note: Neighbour profit is capped by twice the profit on the cell itself. Args: profit (list(np.array)): result of mining_profit(). Returns: list(np.array): [<profit after 1 turn>, <profit after 2 turns>, ..] """ m = self.game_map.width * self.game_map.height profit_mining_once = profit[0] key = lambda index: profit_mining_once[index] best_neighbours = [max(neighbours(i), key=key) for i in range(m)] return [ np.minimum(profit[0][best_neighbours], param['neighbour_profit_factor'] * profit[0]), np.minimum(profit[1][best_neighbours], param['neighbour_profit_factor'] * profit[1]), np.minimum(profit[2][best_neighbours], param['neighbour_profit_factor'] * profit[2]) ] def multiple_turn_halite(self): """Max gathered halite within x turns, under some simple conditions. Reasoning: - Before, we used to maximize the average halite per turn up to and including the next mining turn. Turtles should not be this greedy and plan a little bit further ahead. It is feasible to calculate the maximum halite minable within the next x turns, for small x, which is done by this method. This information is then used to see if the average halite per turn can be greater if we consider a couple of mining turns at once. - Bonus factor is 3 instead of 2, because you also take halite from the enemy, by taking it first. Conditions: - A single neighbouring cell is allowed to contribute, but this contribution cannot be much larger than the contribution of the cell itself, in order to avoid that neighbours of high halite cells always receive a high value. Implementation: the profit per turn on the neighbour is capped by twice the profit on the cell itself. - The first mining turn should be on the cell itself. Returns: list(np.array): [<maximum gathered halite after 1 turn>, <maximum gathered halite after 2 turns>, ..] """ halite = self.map_data.halite bonus_factor = 1 + (1 + param['extra_bonus']) * ( self.map_data.in_bonus_range > 1) profit = self.mining_profit(bonus_factor, halite) move_cost = self.move_cost(halite) neighbour_profit = self._neighbour_profit(profit) return self._find_max(profit, move_cost, neighbour_profit) def _find_max(self, profit, move_cost, neighbour_profit): """"Bookkeeping: find maximum halite gathered in x turns. Example: In 3 turns, the maximum halite gathered under the specified conditions is the max of the following two options: 1) mining 3 times at the cell. 2) mining once, moving to the best neighbour, mining once. Args: profit (list(np.array)): [<gathered halite after 1 turn>, <gathered halite after 2 turns>, ..] move_cost (list(np.array)): [<move cost after 1 turn>, <move cost after 2 turns>, ..] neighbour_profit (list(np.array)): Similar to profit. Returns: list(np.array): [<maximum gathered halite after 1 turn>, <maximum gathered halite after 2 turns>, ..] """ profit_mining_once_and_move = profit[0] - move_cost[0] profit_mining_twice_and_move = profit[1] - move_cost[1] profit_mining_thrice_and_move = profit[2] - move_cost[2] max_1turn = profit[0] max_2turns = profit[1] max_3turns = np.maximum( profit[2], profit_mining_once_and_move + neighbour_profit[0]) max_4turns = np.maximum.reduce([ profit[3], profit_mining_twice_and_move + neighbour_profit[0], profit_mining_once_and_move + neighbour_profit[1] ]) max_5turns = np.maximum.reduce([ profit[4], profit_mining_thrice_and_move + neighbour_profit[0], profit_mining_twice_and_move + neighbour_profit[1], profit_mining_once_and_move + neighbour_profit[2] ]) return [max_1turn, max_2turns, max_3turns, max_4turns, max_5turns] def valuable(self, ship, best_average_halite): """True if ship is expected to add a reasonable amount of halite.""" expected_yield = best_average_halite * self.turns_left return (ship.halite_amount > 100 or ship.halite_amount + expected_yield > 200) def return_distances(self, ship): """Extra turns necessary to return to a dropoff.""" dropoff_distances = self.map_data.calculator.simple_dropoff_distances dropoff_distance = dropoff_distances[to_index(ship)] return dropoff_distances - dropoff_distance def move_turns(self, ship, halite): """Turns spent on moving.""" distances = self.map_data.get_distances(ship) index = to_index(ship) distances[index] += self.map_data.calculator.threat_to_self(ship) return_distances = self.return_distances(ship) space = max(1, constants.MAX_HALITE - ship.halite_amount) move_turns = distances + param['return_distance_factor'] * ( halite / space) * return_distances move_turns[move_turns < 0.0] = 0.0 return move_turns def average(self, custom_mt_halite, ship): """Average halite gathered per turn (including movement turns).""" average_mt_halite = [] for extra_turns, halite in enumerate(custom_mt_halite): mine_turns = 1.0 + extra_turns move_turns = self.move_turns(ship, halite) total_turns = mine_turns + move_turns halite[total_turns > self.turns_left] = 0.0 average_mt_halite.append(halite / total_turns) return average_mt_halite def customize(self, mt_halite, ship): """Customize multiple_turn_halite for a specific ship.""" space = constants.MAX_HALITE - ship.halite_amount custom_halite = [np.minimum(halite, space) for halite in mt_halite] loot = self.map_data.loot(ship) custom_halite[0] = np.maximum(custom_halite[0], loot) return custom_halite def initialize_cost_matrix(self, ships): """Ïnitialize the cost matrix with the correct shape.""" m = self.game_map.width * self.game_map.height return np.zeros((len(ships), m)) def create_cost_matrix(self, ships): """Cost matrix for linear_sum_assignment() to determine destinations.""" mt_halite = self.multiple_turn_halite() cost_matrix = self.initialize_cost_matrix(ships) for i, ship in enumerate(ships): custom_mt_halite = self.customize(mt_halite, ship) average_mt_halite = self.average(custom_mt_halite, ship) best_average_halite = np.maximum.reduce(average_mt_halite) cost_matrix[i][:] = -1.0 * best_average_halite return cost_matrix def _kamikaze_cost(self, dropoff_index, ship_index, enemy_ship): """Cost value used to determine which enemy ship should be attacked.""" i = to_index(enemy_ship) return simple_distance(dropoff_index, i) + simple_distance( ship_index, i) def assign_kamikaze(self, ship): """Attack with a ship that is no longer valuable, guard dropoffs.""" dropoff = self.map_data.get_closest_dropoff(ship) dropoff_index = to_index(dropoff) ship_index = to_index(ship) possible_targets = list(enemy_ships()) if possible_targets: target = min(possible_targets, key=lambda enemy_ship: self._kamikaze_cost( dropoff_index, ship_index, enemy_ship)) self.schedule.assign(ship, target.position) else: self.schedule.assign(ship, ship.position) def _return_average_halite(self, ship): """Average returned halite per turn needed to return.""" dropoff = self.map_data.get_closest_dropoff(ship) distance = self.map_data.get_entity_distance(ship, dropoff) return param['return_factor'] * ship.halite_amount / (2.0 * distance + 1.0) def assignment(self, ships): """Assign destinations to ships using an assignment algorithm.""" cost_matrix = self.create_cost_matrix(ships) row_ind, col_ind = LinearSum.assignment(cost_matrix, ships) for i, j in zip(row_ind, col_ind): ship = ships[i] best_average_halite = -1.0 * cost_matrix[i, j] if not self.valuable(ship, best_average_halite): self.assign_kamikaze(ship) elif (ship.halite_amount > 550 and self._return_average_halite(ship) > best_average_halite): self.assign_return(ship) else: destination = to_cell(j).position self.schedule.assign(ship, destination) def update_returning_to_dropoff(self): """Update the set of ships that are returning to a dropoff.""" required_turns = math.ceil(self.ships_per_dropoff / 4.0) + 2 for ship in self.ships: if ship.halite_amount < 0.25 * constants.MAX_HALITE: returning_to_dropoff.discard(ship.id) if self.map_data.free_turns(ship) < required_turns: returning_to_dropoff.add(ship.id) def assign_return(self, ship): """Assign this ship to return to closest dropoff.""" returning_to_dropoff.add(ship.id) destination = self.map_data.get_closest_dropoff(ship) if isinstance(destination, GhostDropoff): self.ghost_droppers.append(ship) self.schedule.assign(ship, destination) def is_returning(self, ship): """Determine if ship has to return to a dropoff.""" return (ship.id in returning_to_dropoff or ship.halite_amount > 0.95 * constants.MAX_HALITE) def preprocess(self, ships): """Process some ships in a specific way.""" for ship in ships.copy(): if not can_move(ship): self.schedule.assign(ship, ship.position) ships.remove(ship) elif self.is_returning(ship): self.assign_return(ship) ships.remove(ship) def get_schedule(self): """Create the Schedule, main method of Scheduler.""" remaining_ships = self.ships.copy() self.dropoff_planning(remaining_ships) self.preprocess(remaining_ships) self.assignment(remaining_ships) self.schedule.deadlock = self.deadlock() return self.schedule def dropoff_planning(self, remaining_ships): """Handle dropoff planning and placement.""" if self.is_dropoff_time(): if self.ghost: ship = self.dropoff_ship() if ship: self.schedule.dropoff(ship) self.map_data.dropoffs.append(ship) remaining_ships.remove(ship) self.remove_ghost_dropoff(self.map_data) else: self.ghost.move() else: if self.expected_halite > constants.DROPOFF_COST: self.spawn_ghost_dropoff(self.map_data) if self.ghost and self.ghost.position is None: self.remove_ghost_dropoff(self.map_data) else: self.remove_ghost_dropoff(self.map_data) Scheduler.free_halite = self._free_halite() def is_dropoff_time(self): """Determine if it is time to create a dropoff.""" end_game = self.turns_left < 0.2 * constants.MAX_TURNS ships_required = param['draw_from_shipyard'] + param[ 'is_dropoff_time'] * (len(self.map_data.dropoffs) - 1) return not end_game and len(self.ships) >= ships_required def dropoff_cost(self, ship): """Cost of building a dropoff, taking into account reductions.""" ship_halite = ship.halite_amount cell_halite = self.game_map[ship].halite_amount return max(constants.DROPOFF_COST - ship_halite - cell_halite, 0) def dropoff_ship(self): """Determine ship that creates the ghost dropoff.""" for ship in self.ships: if ship.position == self.ghost.position: if (self.me.halite_amount < self.dropoff_cost(ship) or to_cell(to_index(ship)).has_structure): return None return ship return None def returning_ships(self): """List of ships that are returning to a dropoff.""" ships = [] for ship_id in returning_to_dropoff.copy(): if self.me.has_ship(ship_id): ships.append(self.me.get_ship(ship_id)) else: returning_to_dropoff.discard(ship_id) return ships def _delivers_in_time(self, ship, max_turns): """True if the ship delivers its cargo within max_turns.""" destination = self.map_data.get_closest_dropoff(ship) distance = self.map_data.get_entity_distance(ship, destination) return distance < max_turns and destination != self.ghost def _expected_halite(self): """Halite expected to be available before the next dropoff creation.""" max_turns = self.ghost.distance(self.ships) if self.ghost else 10 returning_halite = 0.0 for ship in self.returning_ships(): if self._delivers_in_time(ship, max_turns): returning_halite += 0.8 * ship.halite_amount return self.me.halite_amount + returning_halite def _free_halite(self): """Calculate the halite that is free to be used for spawning ships.""" halite = self.me.halite_amount if self.schedule.dropoff_assignments: ship = self.schedule.dropoff_assignments[0] return halite - self.dropoff_cost(ship) elif self.ghost: ship = entity.Ship(None, None, self.ghost.position, 500) return min(self.expected_halite - self.dropoff_cost(ship), halite) else: return halite def deadlock(self): """Check for deadlock situation. Note: Can occur when every ship is returning to the ghost dropoff, but the ghost dropoff cannot be constructed. Probably lost the game already, so it does not really matter what we do. """ if len(self.ghost_droppers) == len(self.ships): for ship in self.ships: if ship.position == self.ghost.position: if self.me.halite_amount < self.dropoff_cost(ship): return True return False