multi-puzzle-solver 0.9.13__py3-none-any.whl → 0.9.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- {multi_puzzle_solver-0.9.13.dist-info → multi_puzzle_solver-0.9.15.dist-info}/METADATA +120 -9
- {multi_puzzle_solver-0.9.13.dist-info → multi_puzzle_solver-0.9.15.dist-info}/RECORD +18 -15
- puzzle_solver/__init__.py +3 -1
- puzzle_solver/core/utils.py +228 -127
- puzzle_solver/core/utils_ortools.py +237 -172
- puzzle_solver/puzzles/battleships/battleships.py +1 -0
- puzzle_solver/puzzles/black_box/black_box.py +313 -0
- puzzle_solver/puzzles/filling/filling.py +117 -192
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
- puzzle_solver/puzzles/inertia/tsp.py +4 -1
- puzzle_solver/puzzles/lits/lits.py +2 -95
- puzzle_solver/puzzles/pearl/pearl.py +12 -44
- puzzle_solver/puzzles/range/range.py +2 -51
- puzzle_solver/puzzles/singles/singles.py +9 -50
- puzzle_solver/puzzles/tracks/tracks.py +12 -41
- {multi_puzzle_solver-0.9.13.dist-info → multi_puzzle_solver-0.9.15.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.13.dist-info → multi_puzzle_solver-0.9.15.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Iterable, Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, Direction, get_next_pos, in_bounds, get_opposite_direction, get_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, or_constraint, force_connected_component
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_numpy(galaxies: np.ndarray) -> list[tuple[Pos, ...]]:
|
|
12
|
+
result = defaultdict(list)
|
|
13
|
+
for pos, arr_id in np.ndenumerate(galaxies):
|
|
14
|
+
if not arr_id.strip():
|
|
15
|
+
continue
|
|
16
|
+
result[arr_id].append(get_pos(x=pos[1], y=pos[0]))
|
|
17
|
+
return [positions for _, positions in sorted(result.items(), key=lambda x: x[0])]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Board:
|
|
21
|
+
def __init__(self, galaxies: Union[list[tuple[Pos, ...]], np.ndarray], V: int = None, H: int = None):
|
|
22
|
+
if isinstance(galaxies, np.ndarray):
|
|
23
|
+
V, H = galaxies.shape
|
|
24
|
+
galaxies = parse_numpy(galaxies)
|
|
25
|
+
else:
|
|
26
|
+
assert V is not None and H is not None, 'V and H must be provided if galaxies is not a numpy array'
|
|
27
|
+
assert V >= 1 and H >= 1, 'V and H must be at least 1'
|
|
28
|
+
assert all(isinstance(galaxy, Iterable) for galaxy in galaxies), 'galaxies must be a list of Iterables'
|
|
29
|
+
assert all(len(galaxy) in [1, 2, 4] for galaxy in galaxies), 'each galaxy must be exactly 1, 2, or 4 positions'
|
|
30
|
+
self.V = V
|
|
31
|
+
self.H = H
|
|
32
|
+
self.n_galaxies = len(galaxies)
|
|
33
|
+
self.galaxies = galaxies
|
|
34
|
+
self.prelocated_positions: set[Pos] = {pos: i for i, galaxy in enumerate(galaxies) for pos in galaxy}
|
|
35
|
+
|
|
36
|
+
self.model = cp_model.CpModel()
|
|
37
|
+
self.pos_to_galaxy: dict[Pos, dict[int, cp_model.IntVar]] = {p: {} for p in get_all_pos(V, H)} # each position can be part of exactly one out of many possible galaxies
|
|
38
|
+
self.allocated_pairs: set[tuple[Pos, Pos]] = set() # each pair is allocated to exactly one galaxy
|
|
39
|
+
|
|
40
|
+
self.create_vars()
|
|
41
|
+
self.add_all_constraints()
|
|
42
|
+
|
|
43
|
+
def create_vars(self):
|
|
44
|
+
for i in range(self.n_galaxies):
|
|
45
|
+
galaxy = self.galaxies[i]
|
|
46
|
+
if len(galaxy) == 1:
|
|
47
|
+
p1, p2 = galaxy[0], galaxy[0]
|
|
48
|
+
elif len(galaxy) == 2:
|
|
49
|
+
p1, p2 = galaxy[0], galaxy[1]
|
|
50
|
+
elif len(galaxy) == 4:
|
|
51
|
+
p1, p2 = galaxy[0], galaxy[3] # [1] and [2] will be linked with symmetry
|
|
52
|
+
self.expand_galaxy(p1, p2, i)
|
|
53
|
+
|
|
54
|
+
def expand_galaxy(self, p1: Pos, p2: Pos, galaxy_idx: int):
|
|
55
|
+
if (p1, p2) in self.allocated_pairs or (p2, p1) in self.allocated_pairs:
|
|
56
|
+
return
|
|
57
|
+
if p1 in self.prelocated_positions and self.prelocated_positions[p1] != galaxy_idx:
|
|
58
|
+
return
|
|
59
|
+
if p2 in self.prelocated_positions and self.prelocated_positions[p2] != galaxy_idx:
|
|
60
|
+
return
|
|
61
|
+
if not in_bounds(p1, self.V, self.H) or not in_bounds(p2, self.V, self.H):
|
|
62
|
+
return
|
|
63
|
+
self.bind_pair(p1, p2, galaxy_idx)
|
|
64
|
+
# symmetrically expand the galaxy until illegal position is hit
|
|
65
|
+
for direction in [Direction.RIGHT, Direction.UP, Direction.DOWN, Direction.LEFT]:
|
|
66
|
+
symmetrical_direction = get_opposite_direction(direction)
|
|
67
|
+
new_p1 = get_next_pos(p1, direction)
|
|
68
|
+
new_p2 = get_next_pos(p2, symmetrical_direction)
|
|
69
|
+
self.expand_galaxy(new_p1, new_p2, galaxy_idx)
|
|
70
|
+
|
|
71
|
+
def bind_pair(self, p1: Pos, p2: Pos, galaxy_idx: int):
|
|
72
|
+
assert galaxy_idx not in self.pos_to_galaxy[p1], f'p1={p1} already has galaxy idx={galaxy_idx}'
|
|
73
|
+
assert galaxy_idx not in self.pos_to_galaxy[p2], f'p2={p2} already has galaxy idx={galaxy_idx}'
|
|
74
|
+
self.allocated_pairs.add((p1, p2))
|
|
75
|
+
v1 = self.model.NewBoolVar(f'{p1}:{galaxy_idx}')
|
|
76
|
+
v2 = self.model.NewBoolVar(f'{p2}:{galaxy_idx}')
|
|
77
|
+
self.model.Add(v1 == v2)
|
|
78
|
+
self.pos_to_galaxy[p1][galaxy_idx] = v1
|
|
79
|
+
self.pos_to_galaxy[p2][galaxy_idx] = v2
|
|
80
|
+
|
|
81
|
+
def add_all_constraints(self):
|
|
82
|
+
galaxy_vars = {}
|
|
83
|
+
for pos in get_all_pos(self.V, self.H):
|
|
84
|
+
pos_vars = list(self.pos_to_galaxy[pos].values())
|
|
85
|
+
self.model.AddExactlyOne(pos_vars)
|
|
86
|
+
for galaxy_idx, v in self.pos_to_galaxy[pos].items():
|
|
87
|
+
galaxy_vars.setdefault(galaxy_idx, {})[pos] = v
|
|
88
|
+
for galaxy_idx, pos_vars in galaxy_vars.items():
|
|
89
|
+
force_connected_component(self.model, pos_vars)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def solve_and_print(self, verbose: bool = True):
|
|
93
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
94
|
+
assignment: dict[Pos, int] = {}
|
|
95
|
+
for pos, galaxy_vars in board.pos_to_galaxy.items():
|
|
96
|
+
for galaxy_idx, var in galaxy_vars.items(): # every pos is part of exactly one galaxy
|
|
97
|
+
if solver.Value(var) == 1:
|
|
98
|
+
assignment[pos] = galaxy_idx
|
|
99
|
+
break
|
|
100
|
+
return SingleSolution(assignment=assignment)
|
|
101
|
+
def callback(single_res: SingleSolution):
|
|
102
|
+
print("Solution found")
|
|
103
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
104
|
+
for pos in get_all_pos(self.V, self.H):
|
|
105
|
+
set_char(res, pos, str(single_res.assignment[pos]).zfill(2))
|
|
106
|
+
print('[')
|
|
107
|
+
for row in range(self.V):
|
|
108
|
+
print(' ', res[row].tolist(), end=',\n')
|
|
109
|
+
print(']')
|
|
110
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
|
|
3
|
+
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
|
+
The output json is used in the test_solve.py file to test the solver.
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import numpy as np
|
|
8
|
+
cv = None
|
|
9
|
+
Image = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_lines(bw):
|
|
13
|
+
# Create the images that will use to extract the horizontal and vertical lines
|
|
14
|
+
horizontal = np.copy(bw)
|
|
15
|
+
vertical = np.copy(bw)
|
|
16
|
+
|
|
17
|
+
cols = horizontal.shape[1]
|
|
18
|
+
horizontal_size = cols // 5
|
|
19
|
+
# Create structure element for extracting horizontal lines through morphology operations
|
|
20
|
+
horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
|
|
21
|
+
horizontal = cv.erode(horizontal, horizontalStructure)
|
|
22
|
+
horizontal = cv.dilate(horizontal, horizontalStructure)
|
|
23
|
+
horizontal_means = np.mean(horizontal, axis=1)
|
|
24
|
+
horizontal_cutoff = np.percentile(horizontal_means, 50)
|
|
25
|
+
# location where the horizontal lines are
|
|
26
|
+
horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
|
|
27
|
+
# print(f"horizontal_idx: {horizontal_idx}")
|
|
28
|
+
height = len(horizontal_idx)
|
|
29
|
+
# show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
|
|
30
|
+
|
|
31
|
+
rows = vertical.shape[0]
|
|
32
|
+
verticalsize = rows // 5
|
|
33
|
+
# Create structure element for extracting vertical lines through morphology operations
|
|
34
|
+
verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
|
|
35
|
+
vertical = cv.erode(vertical, verticalStructure)
|
|
36
|
+
vertical = cv.dilate(vertical, verticalStructure)
|
|
37
|
+
vertical_means = np.mean(vertical, axis=0)
|
|
38
|
+
vertical_cutoff = np.percentile(vertical_means, 50)
|
|
39
|
+
vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
|
|
40
|
+
# print(f"vertical_idx: {vertical_idx}")
|
|
41
|
+
width = len(vertical_idx)
|
|
42
|
+
# print(f"height: {height}, width: {width}")
|
|
43
|
+
# print(f"vertical_means: {vertical_means}")
|
|
44
|
+
# show_wait_destroy("vertical", vertical) # this has the vertical lines
|
|
45
|
+
|
|
46
|
+
vertical = cv.bitwise_not(vertical)
|
|
47
|
+
# show_wait_destroy("vertical_bit", vertical)
|
|
48
|
+
|
|
49
|
+
return horizontal_idx, vertical_idx
|
|
50
|
+
|
|
51
|
+
def show_wait_destroy(winname, img):
|
|
52
|
+
cv.imshow(winname, img)
|
|
53
|
+
cv.moveWindow(winname, 500, 0)
|
|
54
|
+
cv.waitKey(0)
|
|
55
|
+
cv.destroyWindow(winname)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def mean_consecutives(arr: np.ndarray) -> np.ndarray:
|
|
59
|
+
"""if a sequence of values is consecutive, then average the values"""
|
|
60
|
+
sums = []
|
|
61
|
+
counts = []
|
|
62
|
+
for i in range(len(arr)):
|
|
63
|
+
if i == 0:
|
|
64
|
+
sums.append(arr[i])
|
|
65
|
+
counts.append(1)
|
|
66
|
+
elif arr[i] == arr[i-1] + 1:
|
|
67
|
+
sums[-1] += arr[i]
|
|
68
|
+
counts[-1] += 1
|
|
69
|
+
else:
|
|
70
|
+
sums.append(arr[i])
|
|
71
|
+
counts.append(1)
|
|
72
|
+
return np.array(sums) // np.array(counts)
|
|
73
|
+
|
|
74
|
+
def main(image):
|
|
75
|
+
global Image
|
|
76
|
+
global cv
|
|
77
|
+
import matplotlib.pyplot as plt
|
|
78
|
+
from PIL import Image as Image_module
|
|
79
|
+
import cv2 as cv_module
|
|
80
|
+
Image = Image_module
|
|
81
|
+
cv = cv_module
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
image_path = Path(image)
|
|
85
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
86
|
+
src = cv.imread(image, cv.IMREAD_COLOR)
|
|
87
|
+
assert src is not None, f'Error opening image: {image}'
|
|
88
|
+
if len(src.shape) != 2:
|
|
89
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
90
|
+
else:
|
|
91
|
+
gray = src
|
|
92
|
+
# now the image is in grayscale
|
|
93
|
+
|
|
94
|
+
# Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
|
|
95
|
+
gray = cv.bitwise_not(gray)
|
|
96
|
+
bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
97
|
+
cv.THRESH_BINARY, 15, -2)
|
|
98
|
+
# show_wait_destroy("binary", bw)
|
|
99
|
+
|
|
100
|
+
# show_wait_destroy("src", src)
|
|
101
|
+
horizontal_idx, vertical_idx = extract_lines(bw)
|
|
102
|
+
horizontal_idx = mean_consecutives(horizontal_idx)
|
|
103
|
+
vertical_idx = mean_consecutives(vertical_idx)
|
|
104
|
+
height = len(horizontal_idx)
|
|
105
|
+
width = len(vertical_idx)
|
|
106
|
+
print(f"height: {height}, width: {width}")
|
|
107
|
+
print(f"horizontal_idx: {horizontal_idx}")
|
|
108
|
+
print(f"vertical_idx: {vertical_idx}")
|
|
109
|
+
arr = np.zeros((height - 1, width - 1), dtype=object)
|
|
110
|
+
output = {(dx, dy): arr.copy() for dx in [-1, 0, 1] for dy in [-1, 0, 1]}
|
|
111
|
+
hists = {(dx, dy): {} for dx in [-1, 0, 1] for dy in [-1, 0, 1]}
|
|
112
|
+
for j in range(height - 1):
|
|
113
|
+
for i in range(width - 1):
|
|
114
|
+
hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
|
|
115
|
+
vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
|
|
116
|
+
hidx1 = max(0, hidx1 - 2)
|
|
117
|
+
hidx2 = min(src.shape[0], hidx2 + 4)
|
|
118
|
+
vidx1 = max(0, vidx1 - 2)
|
|
119
|
+
vidx2 = min(src.shape[1], vidx2 + 4)
|
|
120
|
+
cell = src[hidx1:hidx2, vidx1:vidx2]
|
|
121
|
+
mid_x = cell.shape[1] // 2
|
|
122
|
+
mid_y = cell.shape[0] // 2
|
|
123
|
+
cell = cv.bitwise_not(cell) # invert colors
|
|
124
|
+
for dx in [-1, 0, 1]:
|
|
125
|
+
for dy in [-1, 0, 1]:
|
|
126
|
+
mx = mid_x + dx*mid_x
|
|
127
|
+
my = mid_y + dy*mid_y
|
|
128
|
+
mx0 = max(0, mx - 5)
|
|
129
|
+
mx1 = min(cell.shape[1], mx + 5)
|
|
130
|
+
my0 = max(0, my - 5)
|
|
131
|
+
my1 = min(cell.shape[0], my + 5)
|
|
132
|
+
cell_part = cell[my0:my1, mx0:mx1]
|
|
133
|
+
hists[(dx, dy)][j, i] = np.sum(cell_part)
|
|
134
|
+
# top = cell[0:10, mid_y-5:mid_y+5]
|
|
135
|
+
# hists['top'][j, i] = np.sum(top)
|
|
136
|
+
# left = cell[mid_x-5:mid_x+5, 0:10]
|
|
137
|
+
# hists['left'][j, i] = np.sum(left)
|
|
138
|
+
# right = cell[mid_x-5:mid_x+5, -10:]
|
|
139
|
+
# hists['right'][j, i] = np.sum(right)
|
|
140
|
+
# bottom = cell[-10:, mid_y-5:mid_y+5]
|
|
141
|
+
# hists['bottom'][j, i] = np.sum(bottom)
|
|
142
|
+
# print(f"cell_{i}_{j}, ", [hists[(dx, dy)][j, i] for dx in [-1, 0, 1] for dy in [-1, 0, 1]])
|
|
143
|
+
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
144
|
+
|
|
145
|
+
fig, axs = plt.subplots(3, 3)
|
|
146
|
+
target = 100
|
|
147
|
+
for dx in [-1, 0, 1]:
|
|
148
|
+
for dy in [-1, 0, 1]:
|
|
149
|
+
axs[dx+1, dy+1].hist(list(hists[(dx, dy)].values()), bins=100)
|
|
150
|
+
axs[dx+1, dy+1].set_title(f'{dx},{dy}')
|
|
151
|
+
# target = np.mean(list(hists[(dx, dy)].values()))
|
|
152
|
+
axs[dx+1, dy+1].axvline(target, color='red')
|
|
153
|
+
# plt.show()
|
|
154
|
+
# 1/0
|
|
155
|
+
for j in range(height - 1):
|
|
156
|
+
for i in range(width - 1):
|
|
157
|
+
sums_str = ''
|
|
158
|
+
out_str = ''
|
|
159
|
+
for dx in [-1, 0, 1]:
|
|
160
|
+
out_xpart = 'L' if dx == -1 else 'C' if dx == 0 else 'R'
|
|
161
|
+
for dy in [-1, 0, 1]:
|
|
162
|
+
out_ypart = 'T' if dy == -1 else 'C' if dy == 0 else 'B'
|
|
163
|
+
sums_str += str(hists[(dx, dy)][j, i]) + ' '
|
|
164
|
+
if hists[(dx, dy)][j, i] < target:
|
|
165
|
+
out_str += (out_xpart + out_ypart + ' ')
|
|
166
|
+
output[(dx, dy)][j, i] = 1
|
|
167
|
+
print(f"cell_{j}_{i}", end=': ')
|
|
168
|
+
print(out_str)
|
|
169
|
+
print(' Sums: ', sums_str)
|
|
170
|
+
|
|
171
|
+
out = np.full_like(output[(0, 0)], ' ', dtype='U2')
|
|
172
|
+
counter = 0
|
|
173
|
+
for j in range(out.shape[0]):
|
|
174
|
+
for i in range(out.shape[1]):
|
|
175
|
+
for dx in [-1, 0, 1]:
|
|
176
|
+
for dy in [-1, 0, 1]:
|
|
177
|
+
if output[(dx, dy)][j, i] == 1:
|
|
178
|
+
# out[j, i] = dxdy_to_char[(dx, dy)]
|
|
179
|
+
if dx == 0 and dy == 0: # single point
|
|
180
|
+
out[j, i] = str(counter).zfill(2)
|
|
181
|
+
counter += 1
|
|
182
|
+
elif dx == 0 and dy == 1: # vertical
|
|
183
|
+
out[j, i] = str(counter).zfill(2)
|
|
184
|
+
out[j+1, i] = str(counter).zfill(2)
|
|
185
|
+
counter += 1
|
|
186
|
+
elif dx == 1 and dy == 0: # horizontal
|
|
187
|
+
out[j, i] = str(counter).zfill(2)
|
|
188
|
+
out[j, i+1] = str(counter).zfill(2)
|
|
189
|
+
counter += 1
|
|
190
|
+
elif dx == 1 and dy == 1: # 2 by 2
|
|
191
|
+
out[j, i] = str(counter).zfill(2)
|
|
192
|
+
out[j+1, i] = str(counter).zfill(2)
|
|
193
|
+
out[j, i+1] = str(counter).zfill(2)
|
|
194
|
+
out[j+1, i+1] = str(counter).zfill(2)
|
|
195
|
+
counter += 1
|
|
196
|
+
|
|
197
|
+
# print(out)
|
|
198
|
+
with open(output_path, 'w') as f:
|
|
199
|
+
f.write('[\n')
|
|
200
|
+
for i, row in enumerate(out):
|
|
201
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
202
|
+
if i != len(out) - 1:
|
|
203
|
+
f.write(',')
|
|
204
|
+
f.write('\n')
|
|
205
|
+
f.write(']')
|
|
206
|
+
print('output json: ', output_path)
|
|
207
|
+
|
|
208
|
+
if __name__ == '__main__':
|
|
209
|
+
# to run this script and visualize the output, in the root run:
|
|
210
|
+
# python .\src\puzzle_solver\puzzles\galaxies\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
211
|
+
# main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
|
|
212
|
+
# main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
|
|
213
|
+
# main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
|
|
214
|
+
# main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
|
|
215
|
+
# main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
|
|
216
|
+
main(Path(__file__).parent / 'input_output' / 'eofodowmumgzzdkopzlpzkzaezrhefoezejvdtxrzmpgozzemxjdcigcqzrk.png')
|
|
@@ -342,7 +342,9 @@ def solve_optimal_walk(
|
|
|
342
342
|
for attempt in range(attempts):
|
|
343
343
|
cluster_orders = shuffled_cluster_orders()
|
|
344
344
|
for meta in meta_list:
|
|
345
|
+
# print('solve once')
|
|
345
346
|
rep_idxs, _, _, _ = solve_once(cluster_orders, meta)
|
|
347
|
+
# print('solve once done')
|
|
346
348
|
if rep_idxs is None:
|
|
347
349
|
continue
|
|
348
350
|
|
|
@@ -370,6 +372,7 @@ def solve_optimal_walk(
|
|
|
370
372
|
new_nodes, new_cost = reps_to_nodes_and_cost(new_reps)
|
|
371
373
|
if new_cost < cost:
|
|
372
374
|
reps = new_reps
|
|
375
|
+
# print('2-opt improved cost from', cost, 'to', new_cost)
|
|
373
376
|
nodes_seq, cost = new_nodes, new_cost
|
|
374
377
|
improved = True
|
|
375
378
|
break
|
|
@@ -383,7 +386,7 @@ def solve_optimal_walk(
|
|
|
383
386
|
|
|
384
387
|
if best_nodes is None:
|
|
385
388
|
raise RuntimeError("No solution found.")
|
|
386
|
-
|
|
389
|
+
# print('final check')
|
|
387
390
|
# Final checks and edge list
|
|
388
391
|
edge_walk: List[Tuple[Pos, Pos]] = [(best_nodes[i], best_nodes[i+1]) for i in range(len(best_nodes)-1)]
|
|
389
392
|
assert all(e in edges for e in edge_walk), "Output contains an edge not in the input directed edges."
|
|
@@ -6,7 +6,7 @@ from typing import Optional, Union
|
|
|
6
6
|
from ortools.sat.python import cp_model
|
|
7
7
|
import numpy as np
|
|
8
8
|
|
|
9
|
-
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, in_bounds, Direction, get_next_pos
|
|
9
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, in_bounds, Direction, get_next_pos, polyominoes_with_shape_id
|
|
10
10
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
11
11
|
|
|
12
12
|
|
|
@@ -14,79 +14,6 @@ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution,
|
|
|
14
14
|
Shape = frozenset[Pos]
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def polyominoes(N):
|
|
18
|
-
"""Generate all polyominoes of size N. Every rotation and reflection is considered different and included in the result.
|
|
19
|
-
Translation is not considered different and is removed from the result (otherwise the result would be infinite).
|
|
20
|
-
|
|
21
|
-
Below is the number of unique polyominoes of size N (not including rotations and reflections) and the lenth of the returned result (which includes all rotations and reflections)
|
|
22
|
-
N name #shapes #results
|
|
23
|
-
1 monomino 1 1
|
|
24
|
-
2 domino 1 2
|
|
25
|
-
3 tromino 2 6
|
|
26
|
-
4 tetromino 5 19
|
|
27
|
-
5 pentomino 12 63
|
|
28
|
-
6 hexomino 35 216
|
|
29
|
-
7 heptomino 108 760
|
|
30
|
-
8 octomino 369 2,725
|
|
31
|
-
9 nonomino 1,285 9,910
|
|
32
|
-
10 decomino 4,655 36,446
|
|
33
|
-
11 undecomino 17,073 135,268
|
|
34
|
-
12 dodecomino 63,600 505,861
|
|
35
|
-
Source: https://en.wikipedia.org/wiki/Polyomino
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
N (int): The size of the polyominoes to generate.
|
|
39
|
-
|
|
40
|
-
Returns:
|
|
41
|
-
set[(frozenset[Pos], int)]: A set of all polyominoes of size N (rotated and reflected up to D4 symmetry) along with a unique ID for each polyomino.
|
|
42
|
-
"""
|
|
43
|
-
assert N >= 1, 'N cannot be less than 1'
|
|
44
|
-
# need a frozenset because regular sets are not hashable
|
|
45
|
-
shapes: set[Shape] = {frozenset({Pos(0, 0)})}
|
|
46
|
-
for i in range(1, N):
|
|
47
|
-
next_shapes: set[Shape] = set()
|
|
48
|
-
for s in shapes:
|
|
49
|
-
# frontier: all 4-neighbors of existing cells not already in the shape
|
|
50
|
-
frontier = {get_next_pos(pos, direction)
|
|
51
|
-
for pos in s
|
|
52
|
-
for direction in Direction
|
|
53
|
-
if get_next_pos(pos, direction) not in s}
|
|
54
|
-
for cell in frontier:
|
|
55
|
-
t = s | {cell}
|
|
56
|
-
# normalize by translation only: shift so min x,y is (0,0). This removes translational symmetries.
|
|
57
|
-
minx = min(pos.x for pos in t)
|
|
58
|
-
miny = min(pos.y for pos in t)
|
|
59
|
-
t0 = frozenset(Pos(x=pos.x - minx, y=pos.y - miny) for pos in t)
|
|
60
|
-
next_shapes.add(t0)
|
|
61
|
-
shapes = next_shapes
|
|
62
|
-
# shapes is now complete, now classify up to D4 symmetry (rotations/reflections), translations ignored
|
|
63
|
-
mats = (
|
|
64
|
-
( 1, 0, 0, 1), # regular
|
|
65
|
-
(-1, 0, 0, 1), # reflect about x
|
|
66
|
-
( 1, 0, 0,-1), # reflect about y
|
|
67
|
-
(-1, 0, 0,-1), # reflect about x and y
|
|
68
|
-
# trnaspose then all 4 above
|
|
69
|
-
( 0, 1, 1, 0), ( 0, 1, -1, 0), ( 0,-1, 1, 0), ( 0,-1, -1, 0),
|
|
70
|
-
)
|
|
71
|
-
# compute canonical representative for each shape (lexicographically smallest normalized transform)
|
|
72
|
-
shape_to_canon: dict[Shape, tuple[Pos, ...]] = {}
|
|
73
|
-
for s in shapes:
|
|
74
|
-
reps: list[tuple[Pos, ...]] = []
|
|
75
|
-
for a, b, c, d in mats:
|
|
76
|
-
pts = {Pos(x=a*p.x + b*p.y, y=c*p.x + d*p.y) for p in s}
|
|
77
|
-
minx = min(p.x for p in pts)
|
|
78
|
-
miny = min(p.y for p in pts)
|
|
79
|
-
rep = tuple(sorted(Pos(x=p.x - minx, y=p.y - miny) for p in pts))
|
|
80
|
-
reps.append(rep)
|
|
81
|
-
canon = min(reps)
|
|
82
|
-
shape_to_canon[s] = canon
|
|
83
|
-
|
|
84
|
-
canon_set = set(shape_to_canon.values())
|
|
85
|
-
canon_to_id = {canon: i for i, canon in enumerate(sorted(canon_set))}
|
|
86
|
-
result = {(s, canon_to_id[shape_to_canon[s]]) for s in shapes}
|
|
87
|
-
return result
|
|
88
|
-
|
|
89
|
-
|
|
90
17
|
@dataclass(frozen=True)
|
|
91
18
|
class SingleSolution:
|
|
92
19
|
assignment: dict[Pos, Union[str, int]]
|
|
@@ -117,7 +44,7 @@ class Board:
|
|
|
117
44
|
assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
|
|
118
45
|
self.board = board
|
|
119
46
|
self.polyomino_degrees = polyomino_degrees
|
|
120
|
-
self.polyominoes =
|
|
47
|
+
self.polyominoes = polyominoes_with_shape_id(self.polyomino_degrees)
|
|
121
48
|
|
|
122
49
|
self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
|
|
123
50
|
self.blocks = {i: set() for i in self.block_numbers}
|
|
@@ -233,23 +160,3 @@ class Board:
|
|
|
233
160
|
print('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
|
|
234
161
|
pass
|
|
235
162
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
|
|
236
|
-
|
|
237
|
-
def solve_then_constrain(self, verbose: bool = True):
|
|
238
|
-
tic = time.time()
|
|
239
|
-
all_solutions = []
|
|
240
|
-
while True:
|
|
241
|
-
solutions = self.solve_and_print(verbose=False, verbose_callback=verbose, max_solutions=1)
|
|
242
|
-
if len(solutions) == 0:
|
|
243
|
-
break
|
|
244
|
-
all_solutions.extend(solutions)
|
|
245
|
-
assignment = solutions[0].assignment
|
|
246
|
-
# constrain the board to not return the same solution again
|
|
247
|
-
lits = [self.model_vars[p].Not() if assignment[p] == 1 else self.model_vars[p] for p in assignment.keys()]
|
|
248
|
-
self.model.AddBoolOr(lits)
|
|
249
|
-
self.model.ClearHints()
|
|
250
|
-
for k, v in solutions[0].all_other_variables['fc'].items():
|
|
251
|
-
self.model.AddHint(self.fc[k], v)
|
|
252
|
-
print(f'Solutions found: {len(all_solutions)}')
|
|
253
|
-
toc = time.time()
|
|
254
|
-
print(f'Time taken: {toc - tic:.2f} seconds')
|
|
255
|
-
return all_solutions
|
|
@@ -5,7 +5,7 @@ from ortools.sat.python import cp_model
|
|
|
5
5
|
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
6
|
|
|
7
7
|
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, in_bounds, Direction, get_next_pos, get_char, get_opposite_direction
|
|
8
|
-
from puzzle_solver.core.utils_ortools import and_constraint,
|
|
8
|
+
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class Board:
|
|
@@ -18,7 +18,6 @@ class Board:
|
|
|
18
18
|
self.model = cp_model.CpModel()
|
|
19
19
|
self.cell_active: dict[Pos, cp_model.IntVar] = {}
|
|
20
20
|
self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
|
|
21
|
-
self.reach_layers: list[dict[Pos, cp_model.IntVar]] = [] # R_t[p] booleans, t = 0..T
|
|
22
21
|
|
|
23
22
|
self.create_vars()
|
|
24
23
|
self.add_all_constraints()
|
|
@@ -28,18 +27,11 @@ class Board:
|
|
|
28
27
|
self.cell_active[pos] = self.model.NewBoolVar(f"a[{pos}]")
|
|
29
28
|
for direction in Direction:
|
|
30
29
|
self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f"b[{pos}]->({direction.name})")
|
|
31
|
-
# Percolation layers R_t (monotone flood fill)
|
|
32
|
-
T = self.V * self.H # large enough to cover whole board
|
|
33
|
-
for t in range(T + 1):
|
|
34
|
-
Rt: dict[Pos, cp_model.IntVar] = {}
|
|
35
|
-
for pos in get_all_pos(self.V, self.H):
|
|
36
|
-
Rt[pos] = self.model.NewBoolVar(f"R[{t}][{pos}]")
|
|
37
|
-
self.reach_layers.append(Rt)
|
|
38
30
|
|
|
39
31
|
def add_all_constraints(self):
|
|
40
32
|
self.force_direction_constraints()
|
|
41
33
|
self.force_wb_constraints()
|
|
42
|
-
self.
|
|
34
|
+
self.force_connected_component()
|
|
43
35
|
|
|
44
36
|
def force_wb_constraints(self):
|
|
45
37
|
for pos in get_all_pos(self.V, self.H):
|
|
@@ -91,40 +83,16 @@ class Board:
|
|
|
91
83
|
else:
|
|
92
84
|
self.model.Add(self.cell_direction[(pos, direction)] == 0)
|
|
93
85
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if i == 0:
|
|
105
|
-
self.model.Add(self.reach_layers[0][pos] == 1) # first cell is root
|
|
106
|
-
else:
|
|
107
|
-
self.model.Add(self.reach_layers[0][pos] == 0)
|
|
108
|
-
|
|
109
|
-
for t in range(1, len(self.reach_layers)):
|
|
110
|
-
Rt_prev = self.reach_layers[t - 1]
|
|
111
|
-
Rt = self.reach_layers[t]
|
|
112
|
-
for p in get_all_pos(self.V, self.H):
|
|
113
|
-
# Rt[p] = Rt_prev[p] | (white[p] & Rt_prev[neighbour #1]) | (white[p] & Rt_prev[neighbour #2]) | ...
|
|
114
|
-
# Create helper (white[p] & Rt_prev[neighbour #X]) for each neighbor q
|
|
115
|
-
neigh_helpers: list[cp_model.IntVar] = []
|
|
116
|
-
for direction in Direction:
|
|
117
|
-
q = get_next_pos(p, direction)
|
|
118
|
-
if not in_bounds(q, self.V, self.H):
|
|
119
|
-
continue
|
|
120
|
-
a = self.model.NewBoolVar(f"A[{t}][{p}]<-({q})")
|
|
121
|
-
and_constraint(self.model, target=a, cs=[Rt_prev[q], self.cell_direction[(q, get_opposite_direction(direction))]])
|
|
122
|
-
neigh_helpers.append(a)
|
|
123
|
-
or_constraint(self.model, target=Rt[p], cs=[Rt_prev[p]] + neigh_helpers)
|
|
124
|
-
|
|
125
|
-
# every pearl must be reached by the final layer
|
|
126
|
-
for p in get_all_pos(self.V, self.H):
|
|
127
|
-
self.model.Add(self.reach_layers[-1][p] == 1).OnlyEnforceIf(self.cell_active[p])
|
|
86
|
+
def force_connected_component(self):
|
|
87
|
+
def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
|
|
88
|
+
p1, d1 = pd1
|
|
89
|
+
p2, d2 = pd2
|
|
90
|
+
if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
|
|
91
|
+
return True
|
|
92
|
+
if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
|
|
128
96
|
|
|
129
97
|
|
|
130
98
|
def solve_and_print(self, verbose: bool = True):
|
|
@@ -2,7 +2,7 @@ import numpy as np
|
|
|
2
2
|
from ortools.sat.python import cp_model
|
|
3
3
|
|
|
4
4
|
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_neighbors4, in_bounds, Direction, get_next_pos, get_char
|
|
5
|
-
from puzzle_solver.core.utils_ortools import and_constraint, or_constraint, generic_solve_all, SingleSolution
|
|
5
|
+
from puzzle_solver.core.utils_ortools import and_constraint, or_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
@@ -27,9 +27,6 @@ class Board:
|
|
|
27
27
|
# Core vars
|
|
28
28
|
self.b: dict[Pos, cp_model.IntVar] = {} # 1=black, 0=white
|
|
29
29
|
self.w: dict[Pos, cp_model.IntVar] = {} # 1=white, 0=black
|
|
30
|
-
# Connectivity helpers
|
|
31
|
-
self.root: dict[Pos, cp_model.IntVar] = {} # exactly one root; root <= w
|
|
32
|
-
self.reach_layers: list[dict[Pos, cp_model.IntVar]] = [] # R_t[p] booleans, t = 0..T
|
|
33
30
|
|
|
34
31
|
self.create_vars()
|
|
35
32
|
self.add_all_constraints()
|
|
@@ -41,18 +38,6 @@ class Board:
|
|
|
41
38
|
self.w[pos] = self.model.NewBoolVar(f"w[{pos}]")
|
|
42
39
|
self.model.AddExactlyOne([self.b[pos], self.w[pos]])
|
|
43
40
|
|
|
44
|
-
# Root
|
|
45
|
-
for pos in get_all_pos(self.V, self.H):
|
|
46
|
-
self.root[pos] = self.model.NewBoolVar(f"root[{pos}]")
|
|
47
|
-
|
|
48
|
-
# Percolation layers R_t (monotone flood fill)
|
|
49
|
-
T = self.V * self.H # large enough to cover whole board
|
|
50
|
-
for t in range(T + 1):
|
|
51
|
-
Rt: dict[Pos, cp_model.IntVar] = {}
|
|
52
|
-
for pos in get_all_pos(self.V, self.H):
|
|
53
|
-
Rt[pos] = self.model.NewBoolVar(f"R[{t}][{pos}]")
|
|
54
|
-
self.reach_layers.append(Rt)
|
|
55
|
-
|
|
56
41
|
def add_all_constraints(self):
|
|
57
42
|
self.no_adjacent_blacks()
|
|
58
43
|
self.white_connectivity_percolation()
|
|
@@ -69,41 +54,7 @@ class Board:
|
|
|
69
54
|
|
|
70
55
|
|
|
71
56
|
def white_connectivity_percolation(self):
|
|
72
|
-
|
|
73
|
-
Layered percolation:
|
|
74
|
-
- root is exactly the first white cell
|
|
75
|
-
- R_t is monotone nondecreasing in t (R_t+1 >= R_t)
|
|
76
|
-
- A cell can 'turn on' at layer t+1 iff it's white and has a neighbor on at layer t (or is root)
|
|
77
|
-
- Final layer is equal to the white mask: R_T[p] == w[p] => all whites are connected to the unique root
|
|
78
|
-
"""
|
|
79
|
-
# to find unique solutions easily, we make only 1 possible root allowed; root is exactly the first white cell
|
|
80
|
-
prev_cells_black: list[cp_model.IntVar] = []
|
|
81
|
-
for pos in get_all_pos(self.V, self.H):
|
|
82
|
-
and_constraint(self.model, target=self.root[pos], cs=[self.w[pos]] + prev_cells_black)
|
|
83
|
-
prev_cells_black.append(self.b[pos])
|
|
84
|
-
|
|
85
|
-
# Seed: R0 = root
|
|
86
|
-
for pos in get_all_pos(self.V, self.H):
|
|
87
|
-
self.model.Add(self.reach_layers[0][pos] == self.root[pos])
|
|
88
|
-
|
|
89
|
-
T = len(self.reach_layers)
|
|
90
|
-
for t in range(1, T):
|
|
91
|
-
Rt_prev = self.reach_layers[t - 1]
|
|
92
|
-
Rt = self.reach_layers[t]
|
|
93
|
-
for p in get_all_pos(self.V, self.H):
|
|
94
|
-
# Rt[p] = Rt_prev[p] | (white[p] & Rt_prev[neighbour #1]) | (white[p] & Rt_prev[neighbour #2]) | ...
|
|
95
|
-
# Create helper (white[p] & Rt_prev[neighbour #X]) for each neighbor q
|
|
96
|
-
neigh_helpers: list[cp_model.IntVar] = []
|
|
97
|
-
for q in get_neighbors4(p, self.V, self.H):
|
|
98
|
-
a = self.model.NewBoolVar(f"A[{t}][{p}]<-({q})")
|
|
99
|
-
and_constraint(self.model, target=a, cs=[self.w[p], Rt_prev[q]])
|
|
100
|
-
neigh_helpers.append(a)
|
|
101
|
-
or_constraint(self.model, target=Rt[p], cs=[Rt_prev[p]] + neigh_helpers)
|
|
102
|
-
|
|
103
|
-
# All whites must be reached by the final layer
|
|
104
|
-
RT = self.reach_layers[T - 1]
|
|
105
|
-
for p in get_all_pos(self.V, self.H):
|
|
106
|
-
self.model.Add(RT[p] == self.w[p])
|
|
57
|
+
force_connected_component(self.model, self.w)
|
|
107
58
|
|
|
108
59
|
def range_clues(self):
|
|
109
60
|
# For each numbered cell c with value k:
|