multi-puzzle-solver 0.9.22__py3-none-any.whl → 0.9.25__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.22.dist-info → multi_puzzle_solver-0.9.25.dist-info}/METADATA +323 -4
- {multi_puzzle_solver-0.9.22.dist-info → multi_puzzle_solver-0.9.25.dist-info}/RECORD +14 -10
- puzzle_solver/__init__.py +5 -1
- puzzle_solver/core/utils.py +157 -2
- puzzle_solver/core/utils_ortools.py +6 -10
- puzzle_solver/puzzles/binairo/binairo.py +98 -0
- puzzle_solver/puzzles/flip/flip.py +48 -0
- puzzle_solver/puzzles/lits/lits.py +2 -28
- puzzle_solver/puzzles/palisade/palisade.py +104 -0
- puzzle_solver/puzzles/rectangles/rectangles.py +130 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +12 -131
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +4 -1
- {multi_puzzle_solver-0.9.22.dist-info → multi_puzzle_solver-0.9.25.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.22.dist-info → multi_puzzle_solver-0.9.25.dist-info}/top_level.txt +0 -0
puzzle_solver/core/utils.py
CHANGED
|
@@ -133,7 +133,7 @@ def get_deltas(direction: Union[Direction, Direction8]) -> Tuple[int, int]:
|
|
|
133
133
|
raise ValueError(f'invalid direction: {direction}')
|
|
134
134
|
|
|
135
135
|
|
|
136
|
-
def polyominoes(N):
|
|
136
|
+
def polyominoes(N) -> set[Shape]:
|
|
137
137
|
"""Generate all polyominoes of size N. Every rotation and reflection is considered different and included in the result.
|
|
138
138
|
Translation is not considered different and is removed from the result (otherwise the result would be infinite).
|
|
139
139
|
|
|
@@ -165,7 +165,7 @@ def polyominoes(N):
|
|
|
165
165
|
shapes: set[FastShape] = {frozenset({(0, 0)})}
|
|
166
166
|
for i in range(1, N):
|
|
167
167
|
next_shapes: set[FastShape] = set()
|
|
168
|
-
directions = ((1,0),(-1,0),(0,1)) if i > 1 else (((1,0),(0,1))) # cannot take left on first step, if confused read: https://louridas.github.io/rwa/assignments/polyominoes/
|
|
168
|
+
directions = ((1,0),(-1,0),(0,1),(0,-1)) if i > 1 else (((1,0),(0,1))) # cannot take left on first step, if confused read: https://louridas.github.io/rwa/assignments/polyominoes/
|
|
169
169
|
for s in shapes:
|
|
170
170
|
# frontier of a single shape: all 4-neighbors of existing cells not already in the shape
|
|
171
171
|
frontier = set()
|
|
@@ -188,6 +188,7 @@ def polyominoes(N):
|
|
|
188
188
|
shapes = {frozenset(Pos(x, y) for x, y in s) for s in shapes} # regular class, not the dirty-fast one
|
|
189
189
|
return shapes
|
|
190
190
|
|
|
191
|
+
|
|
191
192
|
def polyominoes_with_shape_id(N):
|
|
192
193
|
"""Refer to polyominoes() for more details. This function returns a set of all polyominoes of size N (rotated and reflected up to D4 symmetry) along with a unique ID for each polyomino that is unique up to D4 symmetry.
|
|
193
194
|
Args:
|
|
@@ -226,3 +227,157 @@ def polyominoes_with_shape_id(N):
|
|
|
226
227
|
result = {(s, canon_to_id[shape_to_canon[s]]) for s in shapes}
|
|
227
228
|
result = {(frozenset(Pos(x, y) for x, y in s), _id) for s, _id in result}
|
|
228
229
|
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def render_grid(cell_flags: np.ndarray,
|
|
233
|
+
center_char: Union[np.ndarray, str, None] = None,
|
|
234
|
+
show_axes: bool = True,
|
|
235
|
+
scale_x: int = 2) -> str:
|
|
236
|
+
"""
|
|
237
|
+
most of this function was AI generated then modified by me, I don't currently care about the details of rendering to the terminal this looked good enough during my testing.
|
|
238
|
+
cell_flags: np.ndarray of shape (N, N) with characters 'U', 'D', 'L', 'R' to represent the edges of the cells.
|
|
239
|
+
center_char: np.ndarray of shape (N, N) with the center of the cells, or a string to use for all cells, or None to not show centers.
|
|
240
|
+
scale_x: horizontal stretch factor (>=1). Try 2 or 3 for squarer cells.
|
|
241
|
+
"""
|
|
242
|
+
assert cell_flags is not None and cell_flags.ndim == 2
|
|
243
|
+
R, C = cell_flags.shape
|
|
244
|
+
|
|
245
|
+
# Edge presence arrays (note the rectangular shapes)
|
|
246
|
+
H = np.zeros((R+1, C), dtype=bool) # horizontal edges between rows
|
|
247
|
+
V = np.zeros((R, C+1), dtype=bool) # vertical edges between cols
|
|
248
|
+
for r in range(R):
|
|
249
|
+
for c in range(C):
|
|
250
|
+
s = cell_flags[r, c]
|
|
251
|
+
if 'U' in s: H[r, c] = True
|
|
252
|
+
if 'D' in s: H[r+1, c] = True
|
|
253
|
+
if 'L' in s: V[r, c] = True
|
|
254
|
+
if 'R' in s: V[r, c+1] = True
|
|
255
|
+
|
|
256
|
+
# Bitmask for corner connections
|
|
257
|
+
U, Rb, D, Lb = 1, 2, 4, 8
|
|
258
|
+
JUNCTION = {
|
|
259
|
+
0: ' ',
|
|
260
|
+
U: '│', D: '│', U|D: '│',
|
|
261
|
+
Lb: '─', Rb: '─', Lb|Rb: '─',
|
|
262
|
+
U|Rb: '└', Rb|D: '┌', D|Lb: '┐', Lb|U: '┘',
|
|
263
|
+
U|D|Lb: '┤', U|D|Rb: '├', Lb|Rb|U: '┴', Lb|Rb|D: '┬',
|
|
264
|
+
U|Rb|D|Lb: '┼',
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
assert scale_x >= 1
|
|
268
|
+
assert H.shape == (R+1, C) and V.shape == (R, C+1)
|
|
269
|
+
|
|
270
|
+
rows = 2*R + 1
|
|
271
|
+
cols = 2*C*scale_x + 1
|
|
272
|
+
canvas = [[' ']*cols for _ in range(rows)]
|
|
273
|
+
|
|
274
|
+
def x_corner(c): # x of corner column c (0..C)
|
|
275
|
+
return (2*c) * scale_x
|
|
276
|
+
def x_between(c,k): # kth in-between col (1..2*scale_x-1) between corners c and c+1
|
|
277
|
+
return (2*c) * scale_x + k
|
|
278
|
+
|
|
279
|
+
# horizontal edges: fill the stretched band between corners with '─'
|
|
280
|
+
for r in range(R+1):
|
|
281
|
+
rr = 2*r
|
|
282
|
+
for c in range(C):
|
|
283
|
+
if H[r, c]:
|
|
284
|
+
for k in range(1, scale_x*2): # 1..(2*scale_x-1)
|
|
285
|
+
canvas[rr][x_between(c, k)] = '─'
|
|
286
|
+
|
|
287
|
+
# vertical edges: at the corner columns
|
|
288
|
+
for r in range(R):
|
|
289
|
+
rr = 2*r + 1
|
|
290
|
+
for c in range(C+1):
|
|
291
|
+
if V[r, c]:
|
|
292
|
+
canvas[rr][x_corner(c)] = '│'
|
|
293
|
+
|
|
294
|
+
# junctions at every corner grid point
|
|
295
|
+
for r in range(R+1):
|
|
296
|
+
rr = 2*r
|
|
297
|
+
for c in range(C+1):
|
|
298
|
+
m = 0
|
|
299
|
+
if r > 0 and V[r-1, c]: m |= U
|
|
300
|
+
if c < C and H[r, c]: m |= Rb
|
|
301
|
+
if r < R and V[r, c]: m |= D
|
|
302
|
+
if c > 0 and H[r, c-1]: m |= Lb
|
|
303
|
+
canvas[rr][x_corner(c)] = JUNCTION[m]
|
|
304
|
+
|
|
305
|
+
# centers (safe for multi-character strings)
|
|
306
|
+
def put_center_text(rr: int, c: int, text: str):
|
|
307
|
+
left = x_corner(c) + 1
|
|
308
|
+
right = x_corner(c+1) - 1
|
|
309
|
+
if right < left:
|
|
310
|
+
return
|
|
311
|
+
span_width = right - left + 1
|
|
312
|
+
s = str(text)
|
|
313
|
+
if len(s) > span_width:
|
|
314
|
+
s = s[:span_width] # truncate to protect borders
|
|
315
|
+
start = left + (span_width - len(s)) // 2
|
|
316
|
+
for i, ch in enumerate(s):
|
|
317
|
+
canvas[rr][start + i] = ch
|
|
318
|
+
|
|
319
|
+
if center_char is not None:
|
|
320
|
+
for r in range(R):
|
|
321
|
+
rr = 2*r + 1
|
|
322
|
+
for c in range(C):
|
|
323
|
+
val = center_char if isinstance(center_char, str) else center_char[r, c]
|
|
324
|
+
put_center_text(rr, c, '' if val is None else str(val))
|
|
325
|
+
|
|
326
|
+
# rows -> strings
|
|
327
|
+
art_rows = [''.join(row) for row in canvas]
|
|
328
|
+
if not show_axes:
|
|
329
|
+
return '\n'.join(art_rows)
|
|
330
|
+
|
|
331
|
+
# Axes labels: row indices on the left, column indices on top (handle C, not R)
|
|
332
|
+
gut = max(2, len(str(R-1))) # gutter width based on row index width
|
|
333
|
+
gutter = ' ' * gut
|
|
334
|
+
top_tens = list(gutter + ' ' * cols)
|
|
335
|
+
top_ones = list(gutter + ' ' * cols)
|
|
336
|
+
|
|
337
|
+
for c in range(C):
|
|
338
|
+
xc_center = x_corner(c) + scale_x
|
|
339
|
+
if C >= 10:
|
|
340
|
+
top_tens[gut + xc_center] = str((c // 10) % 10)
|
|
341
|
+
top_ones[gut + xc_center] = str(c % 10)
|
|
342
|
+
|
|
343
|
+
if gut >= 2:
|
|
344
|
+
top_tens[gut-2:gut] = list(' ')
|
|
345
|
+
top_ones[gut-2:gut] = list(' ')
|
|
346
|
+
|
|
347
|
+
labeled = []
|
|
348
|
+
for r, line in enumerate(art_rows):
|
|
349
|
+
if r % 2 == 1: # cell-center row
|
|
350
|
+
label = str(r//2).rjust(gut)
|
|
351
|
+
else:
|
|
352
|
+
label = ' ' * gut
|
|
353
|
+
labeled.append(label + line)
|
|
354
|
+
|
|
355
|
+
return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
|
|
356
|
+
|
|
357
|
+
def id_board_to_wall_board(id_board: np.array, border_is_wall = True) -> np.array:
|
|
358
|
+
"""In many instances, we have a 2d array where cell values are arbitrary ids
|
|
359
|
+
and we want to convert it to a 2d array where cell values are walls "U", "D", "L", "R" to represent the edges that separate me from my neighbors that have different ids.
|
|
360
|
+
Args:
|
|
361
|
+
id_board: np.array of shape (N, N) with arbitrary ids.
|
|
362
|
+
border_is_wall: if True, the edges of the board are considered to be walls.
|
|
363
|
+
Returns:
|
|
364
|
+
np.array of shape (N, N) with walls "U", "D", "L", "R".
|
|
365
|
+
"""
|
|
366
|
+
res = np.full((id_board.shape[0], id_board.shape[1]), '', dtype=object)
|
|
367
|
+
V, H = id_board.shape
|
|
368
|
+
def append_char(pos: Pos, s: str):
|
|
369
|
+
set_char(res, pos, get_char(res, pos) + s)
|
|
370
|
+
def handle_pos_direction(pos: Pos, direction: Direction, s: str):
|
|
371
|
+
pos2 = get_next_pos(pos, direction)
|
|
372
|
+
if in_bounds(pos2, V, H):
|
|
373
|
+
if get_char(id_board, pos2) != get_char(id_board, pos):
|
|
374
|
+
append_char(pos, s)
|
|
375
|
+
else:
|
|
376
|
+
if border_is_wall:
|
|
377
|
+
append_char(pos, s)
|
|
378
|
+
for pos in get_all_pos(V, H):
|
|
379
|
+
handle_pos_direction(pos, Direction.LEFT, 'L')
|
|
380
|
+
handle_pos_direction(pos, Direction.RIGHT, 'R')
|
|
381
|
+
handle_pos_direction(pos, Direction.UP, 'U')
|
|
382
|
+
handle_pos_direction(pos, Direction.DOWN, 'D')
|
|
383
|
+
return res
|
|
@@ -146,16 +146,12 @@ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any,
|
|
|
146
146
|
for p in keys_in_order:
|
|
147
147
|
model.Add(node_height[p] > 0).OnlyEnforceIf(vs[p])
|
|
148
148
|
|
|
149
|
-
all_new_vars
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
all_new_vars[f"{prefix_name}node_height[{k}]"] = v
|
|
156
|
-
for k, v in max_neighbor_height.items():
|
|
157
|
-
all_new_vars[f"{prefix_name}max_neighbor_height[{k}]"] = v
|
|
158
|
-
|
|
149
|
+
all_new_vars = {
|
|
150
|
+
"is_root": is_root,
|
|
151
|
+
"prefix_zero": prefix_zero,
|
|
152
|
+
"node_height": node_height,
|
|
153
|
+
"max_neighbor_height": max_neighbor_height,
|
|
154
|
+
}
|
|
159
155
|
return all_new_vars
|
|
160
156
|
|
|
161
157
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, get_neighbors8, get_row_pos, get_col_pos
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Board:
|
|
10
|
+
def __init__(self, board: np.array):
|
|
11
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
12
|
+
assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space or B'
|
|
13
|
+
self.board = board
|
|
14
|
+
self.V, self.H = board.shape
|
|
15
|
+
self.model = cp_model.CpModel()
|
|
16
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
17
|
+
|
|
18
|
+
self.create_vars()
|
|
19
|
+
self.add_all_constraints()
|
|
20
|
+
|
|
21
|
+
def create_vars(self):
|
|
22
|
+
for pos in get_all_pos(self.V, self.H):
|
|
23
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
24
|
+
|
|
25
|
+
def add_all_constraints(self):
|
|
26
|
+
for pos in get_all_pos(self.V, self.H): # force clues
|
|
27
|
+
c = get_char(self.board, pos)
|
|
28
|
+
if c == 'B':
|
|
29
|
+
self.model.Add(self.model_vars[pos] == 1)
|
|
30
|
+
elif c == 'W':
|
|
31
|
+
self.model.Add(self.model_vars[pos] == 0)
|
|
32
|
+
# 1. Each row and each column must contain an equal number of white and black circles.
|
|
33
|
+
for row in range(self.V):
|
|
34
|
+
row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.H)]
|
|
35
|
+
self.model.Add(lxp.sum(row_vars) == len(row_vars) // 2)
|
|
36
|
+
for col in range(self.H):
|
|
37
|
+
col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.V)]
|
|
38
|
+
self.model.Add(lxp.sum(col_vars) == len(col_vars) // 2)
|
|
39
|
+
# 2. More than two circles of the same color can't be adjacent.
|
|
40
|
+
for pos in get_all_pos(self.V, self.H):
|
|
41
|
+
self.disallow_three_in_a_row(pos, Direction.RIGHT)
|
|
42
|
+
self.disallow_three_in_a_row(pos, Direction.DOWN)
|
|
43
|
+
# 3. Each row and column is unique.
|
|
44
|
+
# a list per row
|
|
45
|
+
self.force_unique([[self.model_vars[pos] for pos in get_row_pos(row, self.H)] for row in range(self.V)])
|
|
46
|
+
# a list per column
|
|
47
|
+
self.force_unique([[self.model_vars[pos] for pos in get_col_pos(col, self.V)] for col in range(self.H)])
|
|
48
|
+
|
|
49
|
+
def disallow_three_in_a_row(self, p1: Pos, direction: Direction):
|
|
50
|
+
p2 = get_next_pos(p1, direction)
|
|
51
|
+
p3 = get_next_pos(p2, direction)
|
|
52
|
+
if any(not in_bounds(p, self.V, self.H) for p in [p1, p2, p3]):
|
|
53
|
+
return
|
|
54
|
+
self.model.AddBoolOr([
|
|
55
|
+
self.model_vars[p1],
|
|
56
|
+
self.model_vars[p2],
|
|
57
|
+
self.model_vars[p3],
|
|
58
|
+
])
|
|
59
|
+
self.model.AddBoolOr([
|
|
60
|
+
self.model_vars[p1].Not(),
|
|
61
|
+
self.model_vars[p2].Not(),
|
|
62
|
+
self.model_vars[p3].Not(),
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
def force_unique(self, model_vars: list[list[cp_model.IntVar]]):
|
|
66
|
+
if not model_vars or len(model_vars) < 2:
|
|
67
|
+
return
|
|
68
|
+
m = len(model_vars[0])
|
|
69
|
+
assert m <= 61, f"Too many cells for binary encoding in int64: m={m}, model_vars={model_vars}"
|
|
70
|
+
|
|
71
|
+
codes = []
|
|
72
|
+
pow2 = [1 << k for k in range(m)] # weights for bit positions (LSB at index 0)
|
|
73
|
+
for i, l in enumerate(model_vars):
|
|
74
|
+
code = self.model.NewIntVar(0, (1 << m) - 1, f"code_{i}")
|
|
75
|
+
# Sum 2^k * r[k] == code
|
|
76
|
+
self.model.Add(code == sum(pow2[k] * l[k] for k in range(m)))
|
|
77
|
+
codes.append(code)
|
|
78
|
+
|
|
79
|
+
self.model.AddAllDifferent(codes)
|
|
80
|
+
|
|
81
|
+
def solve_and_print(self, verbose: bool = True):
|
|
82
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
83
|
+
assignment: dict[Pos, int] = {}
|
|
84
|
+
for pos, var in board.model_vars.items():
|
|
85
|
+
assignment[pos] = solver.Value(var)
|
|
86
|
+
return SingleSolution(assignment=assignment)
|
|
87
|
+
def callback(single_res: SingleSolution):
|
|
88
|
+
print("Solution found")
|
|
89
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
90
|
+
for pos in get_all_pos(self.V, self.H):
|
|
91
|
+
c = get_char(self.board, pos)
|
|
92
|
+
c = 'B' if single_res.assignment[pos] == 1 else 'W'
|
|
93
|
+
set_char(res, pos, c)
|
|
94
|
+
print('[')
|
|
95
|
+
for row in res:
|
|
96
|
+
print(" [ '" + "', '".join(row.tolist()) + "' ],")
|
|
97
|
+
print(']')
|
|
98
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_neighbors8
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Board:
|
|
10
|
+
def __init__(self, board: np.array):
|
|
11
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
12
|
+
assert board.shape[0] == board.shape[1], 'board must be square'
|
|
13
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
14
|
+
self.board = board
|
|
15
|
+
self.N = board.shape[0]
|
|
16
|
+
self.model = cp_model.CpModel()
|
|
17
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
18
|
+
|
|
19
|
+
self.create_vars()
|
|
20
|
+
self.add_all_constraints()
|
|
21
|
+
|
|
22
|
+
def create_vars(self):
|
|
23
|
+
for pos in get_all_pos(self.N):
|
|
24
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
25
|
+
|
|
26
|
+
def add_all_constraints(self):
|
|
27
|
+
for pos in get_all_pos(self.N):
|
|
28
|
+
c = get_char(self.board, pos)
|
|
29
|
+
if not str(c).isdecimal():
|
|
30
|
+
continue
|
|
31
|
+
neighbour_vars = [self.model_vars[p] for p in get_neighbors8(pos, self.N, include_self=True)]
|
|
32
|
+
self.model.Add(lxp.sum(neighbour_vars) == int(c))
|
|
33
|
+
|
|
34
|
+
def solve_and_print(self, verbose: bool = True):
|
|
35
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
36
|
+
assignment: dict[Pos, int] = {}
|
|
37
|
+
for pos, var in board.model_vars.items():
|
|
38
|
+
assignment[pos] = solver.Value(var)
|
|
39
|
+
return SingleSolution(assignment=assignment)
|
|
40
|
+
def callback(single_res: SingleSolution):
|
|
41
|
+
print("Solution found")
|
|
42
|
+
res = np.full((self.N, self.N), ' ', dtype=object)
|
|
43
|
+
for pos in get_all_pos(self.N):
|
|
44
|
+
c = get_char(self.board, pos)
|
|
45
|
+
c = 'B' if single_res.assignment[pos] == 1 else ' '
|
|
46
|
+
set_char(res, pos, c)
|
|
47
|
+
print(res)
|
|
48
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import time
|
|
3
1
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Optional
|
|
2
|
+
from typing import Optional
|
|
5
3
|
|
|
6
4
|
from ortools.sat.python import cp_model
|
|
7
5
|
import numpy as np
|
|
@@ -14,19 +12,6 @@ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution,
|
|
|
14
12
|
Shape = frozenset[Pos]
|
|
15
13
|
|
|
16
14
|
|
|
17
|
-
@dataclass(frozen=True)
|
|
18
|
-
class SingleSolution:
|
|
19
|
-
assignment: dict[Pos, Union[str, int]]
|
|
20
|
-
all_other_variables: dict
|
|
21
|
-
|
|
22
|
-
def get_hashable_solution(self) -> str:
|
|
23
|
-
result = []
|
|
24
|
-
for pos, v in self.assignment.items():
|
|
25
|
-
result.append((pos.x, pos.y, v))
|
|
26
|
-
return json.dumps(result, sort_keys=True)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
15
|
@dataclass
|
|
31
16
|
class ShapeOnBoard:
|
|
32
17
|
is_active: cp_model.IntVar
|
|
@@ -63,7 +48,6 @@ class Board:
|
|
|
63
48
|
def create_vars(self):
|
|
64
49
|
for pos in get_all_pos(self.V, self.H):
|
|
65
50
|
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
66
|
-
# print('base vars:', len(self.model_vars))
|
|
67
51
|
|
|
68
52
|
def init_shapes_on_board(self):
|
|
69
53
|
for idx, (shape, shape_id) in enumerate(self.polyominoes):
|
|
@@ -84,7 +68,6 @@ class Board:
|
|
|
84
68
|
body=body,
|
|
85
69
|
disallow_same_shape=disallow_same_shape,
|
|
86
70
|
))
|
|
87
|
-
# print('shapes on board:', len(self.shapes_on_board))
|
|
88
71
|
|
|
89
72
|
def add_all_constraints(self):
|
|
90
73
|
# RULES:
|
|
@@ -99,11 +82,9 @@ class Board:
|
|
|
99
82
|
self.force_one_shape_per_block() # Rule #1
|
|
100
83
|
self.disallow_same_shape_touching() # Rule #2
|
|
101
84
|
self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
|
|
102
|
-
# print('force connected vars:', len(fc))
|
|
103
85
|
shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
|
|
104
86
|
self.disallow_shape(shape_2_by_2) # Rule #4
|
|
105
87
|
|
|
106
|
-
|
|
107
88
|
def only_allow_shapes_on_board(self):
|
|
108
89
|
for shape_on_board in self.shapes_on_board:
|
|
109
90
|
# if shape is active then all its body cells must be active
|
|
@@ -118,7 +99,6 @@ class Board:
|
|
|
118
99
|
for block_i in self.block_numbers:
|
|
119
100
|
shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
|
|
120
101
|
assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
|
|
121
|
-
# print(f'shapes on block {block_i} has {len(shapes_on_block)} shapes')
|
|
122
102
|
self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
|
|
123
103
|
|
|
124
104
|
def disallow_same_shape_touching(self):
|
|
@@ -138,8 +118,6 @@ class Board:
|
|
|
138
118
|
self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
|
|
139
119
|
|
|
140
120
|
|
|
141
|
-
|
|
142
|
-
|
|
143
121
|
def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
|
|
144
122
|
if verbose_callback is None:
|
|
145
123
|
verbose_callback = verbose
|
|
@@ -147,10 +125,7 @@ class Board:
|
|
|
147
125
|
assignment: dict[Pos, int] = {}
|
|
148
126
|
for pos, var in board.model_vars.items():
|
|
149
127
|
assignment[pos] = solver.Value(var)
|
|
150
|
-
|
|
151
|
-
'fc': {k: solver.Value(v) for k, v in board.fc.items()}
|
|
152
|
-
}
|
|
153
|
-
return SingleSolution(assignment=assignment, all_other_variables=all_other_variables)
|
|
128
|
+
return SingleSolution(assignment=assignment)
|
|
154
129
|
def callback(single_res: SingleSolution):
|
|
155
130
|
print("Solution found")
|
|
156
131
|
res = np.full((self.V, self.H), ' ', dtype=str)
|
|
@@ -158,5 +133,4 @@ class Board:
|
|
|
158
133
|
c = 'X' if val == 1 else ' '
|
|
159
134
|
set_char(res, pos, c)
|
|
160
135
|
print('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
|
|
161
|
-
pass
|
|
162
136
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from collections import defaultdict
|
|
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_pos, id_board_to_wall_board, render_grid, set_char, in_bounds, get_next_pos, Direction, polyominoes
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# a shape on the 2d board is just a set of positions
|
|
13
|
+
Shape = frozenset[Pos]
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ShapeOnBoard:
|
|
17
|
+
is_active: cp_model.IntVar
|
|
18
|
+
shape: Shape
|
|
19
|
+
shape_id: int
|
|
20
|
+
body: set[Pos]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
|
|
24
|
+
# give a shape and a board, return all valid translations of the shape that are fully contained in the board AND consistent with the clues on the board
|
|
25
|
+
shape_list = list(shape)
|
|
26
|
+
shape_borders = [] # will contain the number of borders for each pos in the shape; this has to be consistent with the clues on the board
|
|
27
|
+
for pos in shape_list:
|
|
28
|
+
v = 0
|
|
29
|
+
for direction in Direction:
|
|
30
|
+
next_pos = get_next_pos(pos, direction)
|
|
31
|
+
if not in_bounds(next_pos, board.shape[0], board.shape[1]) or next_pos not in shape:
|
|
32
|
+
v += 1
|
|
33
|
+
shape_borders.append(v)
|
|
34
|
+
shape_list = [(p.x, p.y) for p in shape_list]
|
|
35
|
+
# min x/y is always 0
|
|
36
|
+
max_x = max(p[0] for p in shape_list)
|
|
37
|
+
max_y = max(p[1] for p in shape_list)
|
|
38
|
+
|
|
39
|
+
for dy in range(0, board.shape[0] - max_y):
|
|
40
|
+
for dx in range(0, board.shape[1] - max_x):
|
|
41
|
+
body = tuple((p[0] + dx, p[1] + dy) for p in shape_list)
|
|
42
|
+
for i, p in enumerate(body):
|
|
43
|
+
c = board[p[1], p[0]]
|
|
44
|
+
if c != ' ' and c != str(shape_borders[i]): # there is a clue and it doesn't match my translated shape, skip
|
|
45
|
+
break
|
|
46
|
+
else:
|
|
47
|
+
yield tuple(get_pos(x=p[0], y=p[1]) for p in body)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Board:
|
|
52
|
+
def __init__(self, board: np.array, region_size: int):
|
|
53
|
+
assert region_size >= 1 and isinstance(region_size, int), 'region_size must be an integer greater than or equal to 1'
|
|
54
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
55
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
56
|
+
self.board = board
|
|
57
|
+
self.V, self.H = board.shape
|
|
58
|
+
self.region_size = region_size
|
|
59
|
+
self.region_count = (self.V * self.H) // self.region_size
|
|
60
|
+
assert self.region_count * self.region_size == self.V * self.H, f'region_size must be a factor of the board size, got {self.region_size} and {self.region_count}'
|
|
61
|
+
|
|
62
|
+
self.polyominoes = polyominoes(self.region_size)
|
|
63
|
+
|
|
64
|
+
self.model = cp_model.CpModel()
|
|
65
|
+
self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
|
|
66
|
+
self.pos_to_shapes: dict[Pos, set[ShapeOnBoard]] = defaultdict(set)
|
|
67
|
+
self.create_vars()
|
|
68
|
+
self.add_all_constraints()
|
|
69
|
+
|
|
70
|
+
def create_vars(self):
|
|
71
|
+
for shape in self.polyominoes:
|
|
72
|
+
for body in get_valid_translations(shape, self.board):
|
|
73
|
+
uid = len(self.shapes_on_board)
|
|
74
|
+
shape_on_board = ShapeOnBoard(
|
|
75
|
+
is_active=self.model.NewBoolVar(f'{uid}:is_active'),
|
|
76
|
+
shape=shape,
|
|
77
|
+
shape_id=uid,
|
|
78
|
+
body=body,
|
|
79
|
+
)
|
|
80
|
+
self.shapes_on_board.append(shape_on_board)
|
|
81
|
+
for pos in body:
|
|
82
|
+
self.pos_to_shapes[pos].add(shape_on_board)
|
|
83
|
+
|
|
84
|
+
def add_all_constraints(self):
|
|
85
|
+
for pos in get_all_pos(self.V, self.H): # each position has exactly one shape active
|
|
86
|
+
self.model.AddExactlyOne(shape.is_active for shape in self.pos_to_shapes[pos])
|
|
87
|
+
|
|
88
|
+
def solve_and_print(self, verbose: bool = True):
|
|
89
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
90
|
+
assignment: dict[Pos, int] = {}
|
|
91
|
+
for shape in board.shapes_on_board:
|
|
92
|
+
if solver.Value(shape.is_active) == 1:
|
|
93
|
+
for pos in shape.body:
|
|
94
|
+
assignment[pos] = shape.shape_id
|
|
95
|
+
return SingleSolution(assignment=assignment)
|
|
96
|
+
def callback(single_res: SingleSolution):
|
|
97
|
+
print("Solution found")
|
|
98
|
+
id_board = np.full((self.V, self.H), ' ', dtype=object)
|
|
99
|
+
for pos in get_all_pos(self.V, self.H):
|
|
100
|
+
region_idx = single_res.assignment[pos]
|
|
101
|
+
set_char(id_board, pos, region_idx)
|
|
102
|
+
board = np.where(self.board == ' ', '·', self.board)
|
|
103
|
+
print(render_grid(id_board_to_wall_board(id_board), center_char=board))
|
|
104
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
7
|
+
|
|
8
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, get_neighbors8, Direction, get_next_pos, render_grid
|
|
9
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
|
|
13
|
+
"""Return all unique pairs (a, b) such that a * b == N, with a, b <= upper_limit."""
|
|
14
|
+
if N <= 0 or upper_limit_i <= 0 or upper_limit_j <= 0:
|
|
15
|
+
return []
|
|
16
|
+
|
|
17
|
+
pairs = []
|
|
18
|
+
i = 1
|
|
19
|
+
while i * i <= N:
|
|
20
|
+
if N % i == 0:
|
|
21
|
+
j = N // i
|
|
22
|
+
if i <= upper_limit_i and j <= upper_limit_j:
|
|
23
|
+
pairs.append((i, j))
|
|
24
|
+
if i != j and j <= upper_limit_i and i <= upper_limit_j:
|
|
25
|
+
pairs.append((j, i))
|
|
26
|
+
i += 1
|
|
27
|
+
return pairs
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Rectangle:
|
|
32
|
+
active: cp_model.IntVar
|
|
33
|
+
N: int
|
|
34
|
+
clue_id: int
|
|
35
|
+
width: int
|
|
36
|
+
height: int
|
|
37
|
+
body: set[Pos]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Board:
|
|
41
|
+
def __init__(self, board: np.array):
|
|
42
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
43
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
44
|
+
self.board = board
|
|
45
|
+
self.V, self.H = board.shape
|
|
46
|
+
self.clue_pos: list[Pos] = [pos for pos in get_all_pos(self.V, self.H) if str(get_char(self.board, pos)).isdecimal()]
|
|
47
|
+
self.clue_pos_to_id: dict[Pos, int] = {pos: i for i, pos in enumerate(self.clue_pos)}
|
|
48
|
+
self.clue_pos_to_value: dict[Pos, int] = {pos: int(get_char(self.board, pos)) for pos in self.clue_pos}
|
|
49
|
+
|
|
50
|
+
self.model = cp_model.CpModel()
|
|
51
|
+
self.model_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
|
|
52
|
+
self.rectangles: list[Rectangle] = []
|
|
53
|
+
|
|
54
|
+
self.create_vars()
|
|
55
|
+
self.add_all_constraints()
|
|
56
|
+
|
|
57
|
+
def create_vars(self):
|
|
58
|
+
self.init_rectangles()
|
|
59
|
+
# for each position it belongs to exactly 1 clue
|
|
60
|
+
# instead of iterating over all clues, we only look at the clues that are possible for this position (by looking at the rectangles that contain this position)
|
|
61
|
+
for pos in get_all_pos(self.V, self.H):
|
|
62
|
+
possible_clue_here = {rectangle.clue_id for rectangle in self.rectangles if pos in rectangle.body} # get the clue position for any rectangle that contains this position
|
|
63
|
+
for possible_clue in possible_clue_here:
|
|
64
|
+
self.model_vars[(pos, possible_clue)] = self.model.NewBoolVar(f'{pos}:{possible_clue}')
|
|
65
|
+
|
|
66
|
+
def init_rectangles(self) -> list[Rectangle]:
|
|
67
|
+
self.fixed_pos: set[Pos] = set(self.clue_pos)
|
|
68
|
+
for pos in self.clue_pos: # for each clue on the board
|
|
69
|
+
clue_id = self.clue_pos_to_id[pos]
|
|
70
|
+
clue_num = self.clue_pos_to_value[pos]
|
|
71
|
+
other_fixed_pos = self.fixed_pos - {pos}
|
|
72
|
+
for width, height in factor_pairs(clue_num, self.V, self.H): # for each possible width x height rectangle that can fit the clue
|
|
73
|
+
# if the digit is at pos and we have a width x height rectangle then we can translate the rectangle "0 to width" to the left and "0 to height" to the top
|
|
74
|
+
for dx in range(width):
|
|
75
|
+
for dy in range(height):
|
|
76
|
+
body = {Pos(x=pos.x - dx + i, y=pos.y - dy + j) for i in range(width) for j in range(height)}
|
|
77
|
+
if any(not in_bounds(p, self.V, self.H) for p in body): # a rectangle cannot be out of bounds
|
|
78
|
+
continue
|
|
79
|
+
if any(p in other_fixed_pos for p in body): # a rectangle cannot contain a different clue; each clue is 1 rectangle only
|
|
80
|
+
continue
|
|
81
|
+
rectangle = Rectangle(active=self.model.NewBoolVar(f'{clue_id}'), N=clue_num, clue_id=clue_id, width=width, height=height, body=body)
|
|
82
|
+
self.rectangles.append(rectangle)
|
|
83
|
+
|
|
84
|
+
def add_all_constraints(self):
|
|
85
|
+
# each pos has only 1 rectangle active
|
|
86
|
+
for pos in get_all_pos(self.V, self.H):
|
|
87
|
+
self.model.AddExactlyOne(rectangle.active for rectangle in self.rectangles if pos in rectangle.body)
|
|
88
|
+
# each pos has only 1 clue active
|
|
89
|
+
for pos in get_all_pos(self.V, self.H):
|
|
90
|
+
self.model.AddExactlyOne(self.model_vars[(pos, clue_id)] for clue_id in self.clue_pos_to_id.values() if (pos, clue_id) in self.model_vars)
|
|
91
|
+
# a rectangle being active means all its body ponts to the clue
|
|
92
|
+
for rectangle in self.rectangles:
|
|
93
|
+
is_active = rectangle.active
|
|
94
|
+
for pos in rectangle.body:
|
|
95
|
+
self.model.Add(self.model_vars[(pos, rectangle.clue_id)] == 1).OnlyEnforceIf(is_active)
|
|
96
|
+
|
|
97
|
+
def solve_and_print(self, verbose: bool = True):
|
|
98
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
99
|
+
assignment: dict[Pos, int] = {}
|
|
100
|
+
for (i, rectangle) in enumerate(self.rectangles):
|
|
101
|
+
if solver.Value(rectangle.active) == 1:
|
|
102
|
+
for pos in rectangle.body:
|
|
103
|
+
assignment[pos] = f'id{rectangle.clue_id}:N={rectangle.N}:{rectangle.height}x{rectangle.width}'
|
|
104
|
+
return SingleSolution(assignment=assignment)
|
|
105
|
+
def callback(single_res: SingleSolution):
|
|
106
|
+
print("Solution found")
|
|
107
|
+
res = np.full((self.V, self.H), '', dtype=object)
|
|
108
|
+
id_board = np.full((self.V, self.H), '', dtype=object)
|
|
109
|
+
for pos in get_all_pos(self.V, self.H):
|
|
110
|
+
cur = single_res.assignment[pos]
|
|
111
|
+
set_char(id_board, pos, cur)
|
|
112
|
+
left_pos = get_next_pos(pos, Direction.LEFT)
|
|
113
|
+
right_pos = get_next_pos(pos, Direction.RIGHT)
|
|
114
|
+
top_pos = get_next_pos(pos, Direction.UP)
|
|
115
|
+
bottom_pos = get_next_pos(pos, Direction.DOWN)
|
|
116
|
+
if left_pos not in single_res.assignment or single_res.assignment[left_pos] != cur:
|
|
117
|
+
set_char(res, pos, get_char(res, pos) + 'L')
|
|
118
|
+
if right_pos not in single_res.assignment or single_res.assignment[right_pos] != cur:
|
|
119
|
+
set_char(res, pos, get_char(res, pos) + 'R')
|
|
120
|
+
if top_pos not in single_res.assignment or single_res.assignment[top_pos] != cur:
|
|
121
|
+
set_char(res, pos, get_char(res, pos) + 'U')
|
|
122
|
+
if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
|
|
123
|
+
set_char(res, pos, get_char(res, pos) + 'D')
|
|
124
|
+
# print('[')
|
|
125
|
+
# for row in id_board:
|
|
126
|
+
# print(' ', row.tolist(), end=',\n')
|
|
127
|
+
# print(' ])')
|
|
128
|
+
print(render_grid(res, center_char=self.board))
|
|
129
|
+
|
|
130
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|