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:
2026-06-19 14:19:00 +02:00
parent 51bc491cc8
commit 4da624bfcf
9 changed files with 880 additions and 159 deletions
+14 -3
View File
@@ -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()
+75 -48
View File
@@ -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