Last active
December 3, 2025 12:40
-
-
Save noel-friedrich/4d07deaafec5c5b906bf92d2b0f53e75 to your computer and use it in GitHub Desktop.
Solves for an advent calendar that maximises the average (euclidean) distance between consecutive entries, assuming they're placed in a 4x6 grid.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import numpy as np | |
| import math | |
| import matplotlib.pyplot as plt | |
| from ortools.constraint_solver import pywrapcp, routing_enums_pb2 | |
| vertices = tuple(range(25)) | |
| dummy_vertex = 24 | |
| def i_from_xy(x, y): | |
| return y * 6 + x | |
| def xy_from_i(i): | |
| y = i // 6 | |
| x = i - y * 6 | |
| return x, y | |
| def solve_tsp(cost_matrix): | |
| n = len(cost_matrix) | |
| dummy = n - 1 # e.g. 24 | |
| manager = pywrapcp.RoutingIndexManager(n, 1, 0) # depot = 0 | |
| routing = pywrapcp.RoutingModel(manager) | |
| def cost_callback(from_index, to_index): | |
| from_node = manager.IndexToNode(from_index) | |
| to_node = manager.IndexToNode(to_index) | |
| return int(cost_matrix[from_node][to_node]) | |
| transit_callback_index = routing.RegisterTransitCallback(cost_callback) | |
| routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) | |
| search_parameters = pywrapcp.DefaultRoutingSearchParameters() | |
| search_parameters.first_solution_strategy = ( | |
| routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC | |
| ) | |
| solution = routing.SolveWithParameters(search_parameters) | |
| if not solution: | |
| return None, None | |
| # Extract tour as a cycle | |
| index = routing.Start(0) | |
| tour = [] | |
| while not routing.IsEnd(index): | |
| node = manager.IndexToNode(index) | |
| tour.append(node) | |
| index = solution.Value(routing.NextVar(index)) | |
| # Rotate so that the tour starts at the dummy node | |
| if dummy in tour: | |
| k = tour.index(dummy) | |
| tour = tour[k:] + tour[:k] | |
| # remove dummy | |
| return tour[1:] | |
| precision = 1e8 | |
| print("building cost matrix for tsp...") | |
| metric = "manhattan" | |
| def compute_metric(x1, y1, x2, y2): | |
| if metric == "manhattan": | |
| return abs(x1 - x2) + abs(y1 - y2) | |
| elif metric == "euclidean": | |
| return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2) | |
| elif metric == "square-euclidean": | |
| return (x1 - x2) ** 2 + (y1 - y2) ** 2 | |
| else: | |
| raise Exception(f"Unknown metric {metric!r}") | |
| # build a cost matrix | |
| cost_matrix = np.zeros((25, 25), dtype=int) | |
| for i in range(24): | |
| x1, y1 = xy_from_i(i) | |
| for j in range(24): | |
| x2, y2 = xy_from_i(j) | |
| distance = compute_metric(x1, y1, x2, y2) | |
| edge_length = round(distance * precision) | |
| cost_matrix[i, j] = 0 if i == j else edge_length | |
| # invert edge weights and make them all positive | |
| cost_matrix = cost_matrix.max() - cost_matrix | |
| # print(cost_matrix) | |
| print("solving tsp...") | |
| tsp_tour = solve_tsp(cost_matrix) | |
| print("tsp success!") | |
| print(f"{tsp_tour=}") | |
| def tour_to_arrangement(tour): | |
| arr = [] | |
| for i in tour: | |
| x, y = xy_from_i(i) | |
| arr.append([x, y]) | |
| return np.array(arr) | |
| def score_arrangement(arrangement): | |
| distances = [] | |
| for (x1, y1), (x2, y2) in zip(arrangement, arrangement[1:]): | |
| distances.append(compute_metric(x1, y1, x2, y2)) | |
| return sum(distances) / len(distances) | |
| def print_arrangement(arrangement): | |
| calendar = np.zeros((4, 6), dtype=int) | |
| for i, (x, y) in enumerate(arrangement): | |
| calendar[y, x] = i + 1 | |
| print(calendar) | |
| def save_arrangement_img(arr, title=None): | |
| fig, ax = plt.subplots(figsize=(6, 4)) # wider than tall: 6×4 | |
| # 6 columns (0..5) → need grid lines at 0..6 | |
| # 4 rows (0..3) → need grid lines at 0..4 | |
| ax.set_xlim(0, 6) | |
| ax.set_ylim(0, 4) | |
| ax.set_xticks(np.arange(0, 7)) | |
| ax.set_yticks(np.arange(0, 5)) | |
| ax.grid(which='major') | |
| # origin at top-left: y=0 at top, y=3 at bottom | |
| ax.invert_yaxis() | |
| # place day numbers centered in each cell | |
| for day, (x, y) in enumerate(arr, start=1): | |
| ax.text(x + 0.5, y + 0.5, str(day), | |
| ha='center', va='center', fontsize=14) | |
| # no tick labels | |
| ax.set_xticklabels([]) | |
| ax.set_yticklabels([]) | |
| ax.set_aspect('equal') | |
| score = score_arrangement(arr) | |
| if title is not None: | |
| title = f"{title} (avg_len={score:.08f})" | |
| else: | |
| title = str(score) | |
| ax.set_title(title) | |
| plt.tight_layout() | |
| plt.savefig(f"auto-exports/{title}.png") | |
| arrangement = tour_to_arrangement(tsp_tour) | |
| score = score_arrangement(arrangement) | |
| print(f"cycle_tour_score={score}") | |
| print() | |
| print_arrangement(arrangement) | |
| # save_arrangement_img(arrangement, title=f"best {metric}") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment