Fix route optimizer and group assignment; add test suite
Route building / optimization (tatami_masterplan.py): - fast_total_time was permutation-invariant: it applied get_courses to group *values* instead of slots and ignored the permutation, so every ordering scored identically and the annealing optimized nothing. It now maps each rotation slot to its assigned group via the permutation. - Replaced the broken next_permutation/simulated_annealing (enumerated n! orderings per iteration, fed unnormalized Boltzmann weights to np.random.choice -> ValueError, and returned the last random sample) with a standard neighbor-swap annealer that tracks and returns the best solution and handles <2 slots. - Convert the reduced Timedelta matrix to float seconds before annealing (np.exp can't operate on Timedelta). Group building (tatami_masterplan.py): - assign_courses set each group's hosts (sorted by course) before all courses were assigned, so hosts whose course was still None got mis-ordered. Assign all courses first, then wire up hosts. - get_masterplan no longer mutates the caller's participant list. classes.py: - Narrow casts on distance-matrix lookups to satisfy the mypy gate (pre-existing failures). Tests: - Add pytest suite (74 tests) covering the rotation topology, route cost and optimization, the domain model, the masterplan pipeline, and the Routes API wrapper (HTTP mocked). The cost cross-check caught the assign_courses ordering bug above.
This commit is contained in:
+14
-3
@@ -1,5 +1,6 @@
|
||||
import datetime as dt
|
||||
import pandas as pd
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@@ -23,7 +24,10 @@ class Participant:
|
||||
return (
|
||||
self.get_penalty()
|
||||
+ pd.to_timedelta(
|
||||
distance_matrix.loc[self.uuid, after_party_group.main_member.uuid]
|
||||
cast(
|
||||
pd.Timedelta,
|
||||
distance_matrix.loc[self.uuid, after_party_group.main_member.uuid],
|
||||
)
|
||||
).to_pytimedelta()
|
||||
)
|
||||
|
||||
@@ -49,7 +53,9 @@ class Group:
|
||||
self.uuid = "Group_" + str(uuid4())
|
||||
self.members = members
|
||||
self.main_member = members[main_member]
|
||||
self.course: str | None = None # For ordering the groups allowed values: "starter", "main", "dessert"
|
||||
self.course: str | None = (
|
||||
None # For ordering the groups allowed values: "starter", "main", "dessert"
|
||||
)
|
||||
self.hosts: list[Group] | None = None
|
||||
|
||||
def set_course(self, course: str):
|
||||
@@ -89,7 +95,12 @@ class Group:
|
||||
total_time = dt.timedelta()
|
||||
for group, next_group in zip(groups[:-1], groups[1:]):
|
||||
total_time += pd.to_timedelta(
|
||||
distance_matrix.loc[group.main_member.uuid, next_group.main_member.uuid]
|
||||
cast(
|
||||
pd.Timedelta,
|
||||
distance_matrix.loc[
|
||||
group.main_member.uuid, next_group.main_member.uuid
|
||||
],
|
||||
)
|
||||
).to_pytimedelta()
|
||||
total_time += group.main_member.get_penalty()
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import pandas as pd
|
||||
import numpy as np
|
||||
import random
|
||||
from tqdm import tqdm
|
||||
from itertools import permutations
|
||||
|
||||
|
||||
def get_after_party_group(address: str) -> Group:
|
||||
@@ -23,8 +22,9 @@ def get_masterplan(
|
||||
after_party_group: Group,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
groups_per_course = np.floor(len(participants) / 6).astype(int)
|
||||
participants.sort(
|
||||
key=lambda x: x.get_after_party_time(distance_matrix, after_party_group)
|
||||
participants = sorted(
|
||||
participants,
|
||||
key=lambda x: x.get_after_party_time(distance_matrix, after_party_group),
|
||||
)
|
||||
hosts = participants[: 3 * groups_per_course]
|
||||
semi_hosts = participants[3 * groups_per_course :]
|
||||
@@ -59,10 +59,17 @@ def get_masterplan(
|
||||
def assign_courses(groups: list[Group], courses: list[str]) -> None:
|
||||
"""
|
||||
Assign courses to groups.
|
||||
|
||||
Courses are assigned to every group first, then hosts are wired up: a group's
|
||||
hosts are sorted by course (``sort_hosts``), so all course assignments must be
|
||||
in place before any ``set_hosts`` call, otherwise hosts whose course is still
|
||||
unset get mis-ordered.
|
||||
"""
|
||||
for i, (group, course) in enumerate(zip(groups, courses)):
|
||||
for group, course in zip(groups, courses):
|
||||
group.set_course(course)
|
||||
hosts = [groups[i] for i in get_courses(i, len(groups))]
|
||||
|
||||
for i, group in enumerate(groups):
|
||||
hosts = [groups[j] for j in get_courses(i, len(groups))]
|
||||
group.set_hosts(hosts)
|
||||
|
||||
|
||||
@@ -77,11 +84,18 @@ def run_simulated_annealing(
|
||||
"""
|
||||
Run simulated annealing to find the optimal order of groups.
|
||||
"""
|
||||
group_indices = list(range(len(groups)))
|
||||
distance_matrix = reduced_distance_matrix.to_numpy()
|
||||
group_indices = [int(i) for i in range(len(groups))]
|
||||
|
||||
# Convert group indices to a list of integers
|
||||
group_indices = [int(i) for i in group_indices]
|
||||
# The reduced matrix holds pd.Timedelta values; convert to a float matrix of
|
||||
# seconds so it can be used numerically in the acceptance criterion
|
||||
# (np.exp cannot operate on Timedelta objects).
|
||||
distance_matrix = np.array(
|
||||
[
|
||||
[pd.Timedelta(x).total_seconds() for x in row]
|
||||
for row in reduced_distance_matrix.to_numpy()
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
if multiprocessing > 1:
|
||||
raise NotImplementedError("Multiprocessing is not implemented yet.")
|
||||
@@ -98,25 +112,6 @@ def run_simulated_annealing(
|
||||
return best_ordererd_groups
|
||||
|
||||
|
||||
def next_permutation(
|
||||
group_indices: list[int],
|
||||
distance_matrix: np.ndarray,
|
||||
T: float,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Generate the next permutation of group indices that minimizes the total travel time using boltzmann annealing.
|
||||
"""
|
||||
all_permutations = list(permutations(group_indices))
|
||||
times = {
|
||||
i: fast_total_time(distance_matrix, perm)
|
||||
for i, perm in enumerate(all_permutations)
|
||||
}
|
||||
min_time = min(times.values())
|
||||
probabilities = {i: np.exp(-(time - min_time) / T) for i, time in times.items()}
|
||||
key = np.random.choice(list(probabilities.keys()), p=list(probabilities.values()))
|
||||
return all_permutations[key]
|
||||
|
||||
|
||||
def simulated_annealing(
|
||||
distance_matrix: np.ndarray,
|
||||
group_indices: list[int],
|
||||
@@ -125,23 +120,48 @@ def simulated_annealing(
|
||||
max_iterations: int,
|
||||
) -> list[int]:
|
||||
"""
|
||||
Simulated annealing algorithm to find the optimal order of groups.
|
||||
Simulated annealing over assignments of groups to rotation slots.
|
||||
|
||||
``solution[slot]`` is the group index placed in that slot. Each iteration
|
||||
proposes a random two-slot swap and accepts it with the Boltzmann
|
||||
probability; the best solution seen is tracked and returned.
|
||||
"""
|
||||
solution = group_indices.copy()
|
||||
np.random.shuffle(solution)
|
||||
current = group_indices.copy()
|
||||
np.random.shuffle(current)
|
||||
current_cost = fast_total_time(distance_matrix, current)
|
||||
|
||||
best = current.copy()
|
||||
best_cost = current_cost
|
||||
|
||||
# Nothing to optimize with fewer than two slots.
|
||||
if len(current) < 2:
|
||||
return best
|
||||
|
||||
current_temperature = initial_temperature
|
||||
|
||||
for iteration in tqdm(range(max_iterations)):
|
||||
try:
|
||||
solution = next_permutation(solution, distance_matrix, current_temperature)
|
||||
time = fast_total_time(distance_matrix, solution)
|
||||
candidate = current.copy()
|
||||
i, j = np.random.choice(len(candidate), size=2, replace=False)
|
||||
candidate[i], candidate[j] = candidate[j], candidate[i]
|
||||
candidate_cost = fast_total_time(distance_matrix, candidate)
|
||||
|
||||
delta = candidate_cost - current_cost
|
||||
if delta <= 0 or np.random.random() < np.exp(-delta / current_temperature):
|
||||
current, current_cost = candidate, candidate_cost
|
||||
if current_cost < best_cost:
|
||||
best, best_cost = current.copy(), current_cost
|
||||
|
||||
if iteration % 100 == 0:
|
||||
print(f"Iteration {iteration}: Time = {time}")
|
||||
print(
|
||||
f"Iteration {iteration}: current = {current_cost:.0f}s, "
|
||||
f"best = {best_cost:.0f}s"
|
||||
)
|
||||
current_temperature *= cooling_rate
|
||||
except KeyboardInterrupt:
|
||||
print("Simulation interrupted. Returning current solution.")
|
||||
print("Simulation interrupted. Returning best solution so far.")
|
||||
break
|
||||
return solution
|
||||
return best
|
||||
|
||||
|
||||
def get_courses(
|
||||
@@ -155,25 +175,32 @@ def get_courses(
|
||||
return order
|
||||
|
||||
|
||||
def fast_total_time(distance_matrix: np.ndarray, group_indices: list[int]):
|
||||
"""Calculates the total travel time for all groups.
|
||||
def fast_total_time(distance_matrix: np.ndarray, solution: list[int]) -> float:
|
||||
"""Calculates the total travel time for a given slot-to-group assignment.
|
||||
|
||||
The rotation topology is defined over *slots* ``0..n-1`` via ``get_courses``;
|
||||
``solution[slot]`` gives the group placed in that slot. For each slot the
|
||||
occupying group travels starter-host -> main-host -> dessert-host -> after
|
||||
party, and the durations of those legs are summed across all slots.
|
||||
|
||||
Args:
|
||||
distance_matrix (np.ndarray): 2D array representing the distance matrix, where
|
||||
distance_matrix[i][j] is the travel time from group i to group j in seconds. Should be of shape (n+1, n+1). The last column/row should be the after party group.
|
||||
group_indices (list[int]): List of group indices for which to calculate the total travel time. Should be of length n.
|
||||
distance_matrix (np.ndarray): 2D array where ``distance_matrix[i][j]`` is
|
||||
the travel time (seconds) from group ``i`` to group ``j``. Shape
|
||||
``(n+1, n+1)``; the last row/column is the after-party location.
|
||||
solution (list[int]): Permutation mapping each slot to a group index.
|
||||
"""
|
||||
n = len(group_indices)
|
||||
n = len(solution)
|
||||
after_party_idx = n # Since distance_matrix is (n+1)x(n+1)
|
||||
|
||||
total_time = 0
|
||||
total_time = 0.0
|
||||
total_meetings = n
|
||||
|
||||
for group_index in group_indices:
|
||||
a, b, c = get_courses(group_index, total_meetings)
|
||||
total_time += distance_matrix[a][b]
|
||||
total_time += distance_matrix[b][c]
|
||||
total_time += distance_matrix[c][after_party_idx]
|
||||
for slot in range(n):
|
||||
a, b, c = get_courses(slot, total_meetings)
|
||||
ga, gb, gc = solution[a], solution[b], solution[c]
|
||||
total_time += distance_matrix[ga][gb]
|
||||
total_time += distance_matrix[gb][gc]
|
||||
total_time += distance_matrix[gc][after_party_idx]
|
||||
|
||||
return total_time
|
||||
|
||||
|
||||
Reference in New Issue
Block a user