multi-puzzle-solver 1.0.3__py3-none-any.whl → 1.0.6__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-1.0.3.dist-info → multi_puzzle_solver-1.0.6.dist-info}/METADATA +1024 -387
- multi_puzzle_solver-1.0.6.dist-info/RECORD +73 -0
- puzzle_solver/__init__.py +7 -1
- puzzle_solver/core/utils.py +17 -1
- puzzle_solver/core/utils_visualizer.py +257 -201
- puzzle_solver/puzzles/aquarium/aquarium.py +8 -23
- puzzle_solver/puzzles/battleships/battleships.py +39 -53
- puzzle_solver/puzzles/binairo/binairo.py +2 -2
- puzzle_solver/puzzles/black_box/black_box.py +6 -70
- puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
- puzzle_solver/puzzles/filling/filling.py +11 -34
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +0 -1
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -110
- puzzle_solver/puzzles/heyawake/heyawake.py +6 -2
- puzzle_solver/puzzles/kakurasu/kakurasu.py +5 -13
- puzzle_solver/puzzles/kakuro/kakuro.py +6 -2
- puzzle_solver/puzzles/lits/lits.py +6 -4
- puzzle_solver/puzzles/mosaic/mosaic.py +8 -18
- puzzle_solver/puzzles/nonograms/nonograms.py +80 -85
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +221 -0
- puzzle_solver/puzzles/norinori/norinori.py +5 -10
- puzzle_solver/puzzles/nurikabe/nurikabe.py +6 -2
- puzzle_solver/puzzles/palisade/palisade.py +4 -3
- puzzle_solver/puzzles/pearl/pearl.py +15 -27
- puzzle_solver/puzzles/pipes/pipes.py +2 -1
- puzzle_solver/puzzles/range/range.py +19 -55
- puzzle_solver/puzzles/rectangles/rectangles.py +4 -2
- puzzle_solver/puzzles/shingoki/shingoki.py +2 -2
- puzzle_solver/puzzles/singles/singles.py +6 -2
- puzzle_solver/puzzles/slant/slant.py +13 -19
- puzzle_solver/puzzles/slitherlink/slitherlink.py +2 -2
- puzzle_solver/puzzles/star_battle/star_battle.py +13 -7
- puzzle_solver/puzzles/stitches/stitches.py +8 -21
- puzzle_solver/puzzles/sudoku/sudoku.py +5 -11
- puzzle_solver/puzzles/tapa/tapa.py +6 -2
- puzzle_solver/puzzles/tents/tents.py +50 -80
- puzzle_solver/puzzles/tracks/tracks.py +19 -66
- puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
- puzzle_solver/puzzles/unruly/unruly.py +17 -49
- puzzle_solver/puzzles/yin_yang/yin_yang.py +3 -10
- multi_puzzle_solver-1.0.3.dist-info/RECORD +0 -70
- {multi_puzzle_solver-1.0.3.dist-info → multi_puzzle_solver-1.0.6.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-1.0.3.dist-info → multi_puzzle_solver-1.0.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
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, get_pos, set_char, get_row_pos, get_col_pos
|
|
9
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
10
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def assert_input(lines: list[list[tuple[int, str]]]):
|
|
14
|
+
for line in lines:
|
|
15
|
+
for i,c in enumerate(line):
|
|
16
|
+
if c == -1:
|
|
17
|
+
continue
|
|
18
|
+
elif isinstance(c, str):
|
|
19
|
+
assert c[:-1].isdigit(), f'strings must begin with a digit, got {c}'
|
|
20
|
+
line[i] = (int(c[:-1]), c[-1])
|
|
21
|
+
elif isinstance(c, tuple):
|
|
22
|
+
assert len(c) == 2 and isinstance(c[0], int) and isinstance(c[1], str), f'tuples must be (int, str), got {c}'
|
|
23
|
+
else:
|
|
24
|
+
raise ValueError(f'invalid cell value: {c}')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Board:
|
|
28
|
+
def __init__(self, top: list[list[tuple[int, str]]], side: list[list[tuple[int, str]]]):
|
|
29
|
+
assert_input(top)
|
|
30
|
+
assert_input(side)
|
|
31
|
+
self.top = top
|
|
32
|
+
self.side = side
|
|
33
|
+
self.V = len(side)
|
|
34
|
+
self.H = len(top)
|
|
35
|
+
self.unique_colors = list(set([i[1] for line in top for i in line if i != -1] + [i[1] for line in side for i in line if i != -1]))
|
|
36
|
+
self.model = cp_model.CpModel()
|
|
37
|
+
self.model_vars: dict[Pos, dict[str, cp_model.IntVar]] = defaultdict(dict)
|
|
38
|
+
self.extra_vars = {}
|
|
39
|
+
|
|
40
|
+
self.create_vars()
|
|
41
|
+
self.add_all_constraints()
|
|
42
|
+
|
|
43
|
+
def create_vars(self):
|
|
44
|
+
for pos in get_all_pos(self.V, self.H):
|
|
45
|
+
for color in self.unique_colors:
|
|
46
|
+
self.model_vars[pos][color] = self.model.NewBoolVar(f'{pos}:{color}')
|
|
47
|
+
|
|
48
|
+
def add_all_constraints(self):
|
|
49
|
+
for pos in get_all_pos(self.V, self.H):
|
|
50
|
+
self.model.Add(lxp.sum(list(self.model_vars[pos].values())) <= 1)
|
|
51
|
+
for i in range(self.V):
|
|
52
|
+
ground_sequence = self.side[i]
|
|
53
|
+
if tuple(ground_sequence) == (-1,):
|
|
54
|
+
continue
|
|
55
|
+
current_sequence = [self.model_vars[pos] for pos in get_row_pos(i, self.H)]
|
|
56
|
+
self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_side_{i}')
|
|
57
|
+
for i in range(self.H):
|
|
58
|
+
ground_sequence = self.top[i]
|
|
59
|
+
if tuple(ground_sequence) == (-1,):
|
|
60
|
+
continue
|
|
61
|
+
current_sequence = [self.model_vars[pos] for pos in get_col_pos(i, self.V)]
|
|
62
|
+
self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_top_{i}')
|
|
63
|
+
|
|
64
|
+
def constrain_nonogram_sequence(self, clues: list[tuple[int, str]], current_sequence: list[dict[str, cp_model.IntVar]], ns: str):
|
|
65
|
+
"""
|
|
66
|
+
Constrain a colored sequence (current_sequence) to match the nonogram clues in clues.
|
|
67
|
+
|
|
68
|
+
clues: e.g., [(3, 'R'), (1, 'G')] means: a run of 3 red ones, then a run of 1 green one. If two clues are next to each other and have the same color, they must be separated by at least one blank.
|
|
69
|
+
current_sequence: list of dicts of IntVar in {0,1} for each color.
|
|
70
|
+
|
|
71
|
+
steps:
|
|
72
|
+
- Create start position s_i for each run i.
|
|
73
|
+
- Enforce order and >=1 separation between runs.
|
|
74
|
+
- Link each cell j to exactly one run interval (or none) via coverage booleans.
|
|
75
|
+
- Force sum of ones to equal sum(clues).
|
|
76
|
+
"""
|
|
77
|
+
L = len(current_sequence)
|
|
78
|
+
R = len(clues)
|
|
79
|
+
|
|
80
|
+
# Early infeasibility check:
|
|
81
|
+
# Minimum required blanks equals number of adjacent pairs with same color.
|
|
82
|
+
same_color_separators = sum(1 for (len_i, col_i), (len_j, col_j) in zip(clues, clues[1:]) if col_i == col_j)
|
|
83
|
+
min_needed = sum(len_i for len_i, _ in clues) + same_color_separators
|
|
84
|
+
if min_needed > L:
|
|
85
|
+
print(f"Infeasible: clues {clues} need {min_needed} cells but line length is {L} for {ns}")
|
|
86
|
+
self.model.Add(0 == 1)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
# Collect the color set present in clues and in the line vars
|
|
90
|
+
clue_colors = {c for _, c in clues}
|
|
91
|
+
seq_colors = set()
|
|
92
|
+
for j in range(L):
|
|
93
|
+
seq_colors.update(current_sequence[j].keys())
|
|
94
|
+
colors = sorted(clue_colors | seq_colors)
|
|
95
|
+
|
|
96
|
+
# Start vars per run
|
|
97
|
+
starts: list[cp_model.IntVar] = []
|
|
98
|
+
self.extra_vars[f"{ns}_starts"] = starts
|
|
99
|
+
for i in range(len(clues)):
|
|
100
|
+
# s_i in [0, L] but we will bound by containment constraint below
|
|
101
|
+
s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
|
|
102
|
+
starts.append(s)
|
|
103
|
+
|
|
104
|
+
# Ordering + separation:
|
|
105
|
+
# If same color: s[i+1] >= s[i] + len[i] + 1
|
|
106
|
+
# If different color: s[i+1] >= s[i] + len[i]
|
|
107
|
+
for i in range(R - 1):
|
|
108
|
+
len_i, col_i = clues[i]
|
|
109
|
+
_, col_next = clues[i + 1]
|
|
110
|
+
gap = 1 if col_i == col_next else 0
|
|
111
|
+
self.model.Add(starts[i + 1] >= starts[i] + len_i + gap)
|
|
112
|
+
|
|
113
|
+
# Containment: s[i] + len[i] <= L
|
|
114
|
+
for i, (run_len, _) in enumerate(clues):
|
|
115
|
+
self.model.Add(starts[i] + run_len <= L)
|
|
116
|
+
|
|
117
|
+
# Coverage booleans: cover[i][j] <=> (starts[i] <= j) AND (j < starts[i] + run_len)
|
|
118
|
+
cover = [[None] * L for _ in range(R)]
|
|
119
|
+
list_b_le = [[None] * L for _ in range(R)]
|
|
120
|
+
list_b_lt_end = [[None] * L for _ in range(R)]
|
|
121
|
+
self.extra_vars[f"{ns}_cover"] = cover
|
|
122
|
+
self.extra_vars[f"{ns}_list_b_le"] = list_b_le
|
|
123
|
+
self.extra_vars[f"{ns}_list_b_lt_end"] = list_b_lt_end
|
|
124
|
+
|
|
125
|
+
for i, (run_len, _) in enumerate(clues):
|
|
126
|
+
s_i = starts[i]
|
|
127
|
+
for j in range(L):
|
|
128
|
+
b_le = self.model.NewBoolVar(f"{ns}_le[{i},{j}]") # s_i <= j
|
|
129
|
+
self.model.Add(s_i <= j).OnlyEnforceIf(b_le)
|
|
130
|
+
self.model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
|
|
131
|
+
|
|
132
|
+
b_lt_end = self.model.NewBoolVar(f"{ns}_lt_end[{i},{j}]") # j < s_i + run_len <=> s_i + run_len - 1 >= j
|
|
133
|
+
end_expr = s_i + run_len - 1
|
|
134
|
+
self.model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
|
|
135
|
+
self.model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not())
|
|
136
|
+
|
|
137
|
+
b_cov = self.model.NewBoolVar(f"{ns}_cov[{i},{j}]")
|
|
138
|
+
self.model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
|
|
139
|
+
self.model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
|
|
140
|
+
|
|
141
|
+
cover[i][j] = b_cov
|
|
142
|
+
list_b_le[i][j] = b_le
|
|
143
|
+
list_b_lt_end[i][j] = b_lt_end
|
|
144
|
+
|
|
145
|
+
# Link coverage to per-cell, per-color variables.
|
|
146
|
+
# For each color k and cell j:
|
|
147
|
+
# sum_{i: color_i == k} cover[i][j] == current_sequence[j][k]
|
|
148
|
+
# Also tie the total cover at j to the sum over all colors at j:
|
|
149
|
+
# sum_i cover[i][j] == sum_k current_sequence[j][k]
|
|
150
|
+
# This enforces that at most one color is active per cell (since the LHS is in {0,1} due to non-overlap).
|
|
151
|
+
# If a color var is missing in current_sequence[j], assume it’s an implicit 0 by creating a fixed zero var.
|
|
152
|
+
# (Alternatively, require the caller to provide all colors per cell.)
|
|
153
|
+
zero_cache = {}
|
|
154
|
+
def get_zero(name: str):
|
|
155
|
+
if name not in zero_cache:
|
|
156
|
+
z = self.model.NewConstant(0)
|
|
157
|
+
zero_cache[name] = z
|
|
158
|
+
return zero_cache[name]
|
|
159
|
+
|
|
160
|
+
# Pre-index runs by color for efficiency
|
|
161
|
+
runs_by_color = {k: [] for k in colors}
|
|
162
|
+
for i, (_, k) in enumerate(clues):
|
|
163
|
+
runs_by_color[k].append(i)
|
|
164
|
+
|
|
165
|
+
for j in range(L):
|
|
166
|
+
# Total coverage at cell j
|
|
167
|
+
total_cov_j = sum(cover[i][j] for i in range(R)) if R > 0 else 0
|
|
168
|
+
|
|
169
|
+
# Sum of color vars at cell j
|
|
170
|
+
color_vars_j = []
|
|
171
|
+
for k in colors:
|
|
172
|
+
v = current_sequence[j].get(k, None)
|
|
173
|
+
if v is None:
|
|
174
|
+
v = get_zero(f"{ns}_zero_{k}")
|
|
175
|
+
color_vars_j.append(v)
|
|
176
|
+
|
|
177
|
+
# Per-color coverage equality
|
|
178
|
+
if runs_by_color[k]:
|
|
179
|
+
self.model.Add(sum(cover[i][j] for i in runs_by_color[k]) == v)
|
|
180
|
+
else:
|
|
181
|
+
# No runs of this color -> force cell color var to 0
|
|
182
|
+
self.model.Add(v == 0)
|
|
183
|
+
|
|
184
|
+
# Tie total coverage to sum of color vars (blank vs exactly-one color)
|
|
185
|
+
if R > 0:
|
|
186
|
+
self.model.Add(total_cov_j == sum(color_vars_j))
|
|
187
|
+
else:
|
|
188
|
+
# No runs at all: all cells must be blank across all colors
|
|
189
|
+
for v in color_vars_j:
|
|
190
|
+
self.model.Add(v == 0)
|
|
191
|
+
|
|
192
|
+
# Optional but strong propagation: per-color totals must match total clue lengths of that color
|
|
193
|
+
total_len_by_color = {k: 0 for k in colors}
|
|
194
|
+
for length, k in clues:
|
|
195
|
+
total_len_by_color[k] += length
|
|
196
|
+
|
|
197
|
+
for k in colors:
|
|
198
|
+
total_cells_k = sum(current_sequence[j].get(k, get_zero(f"{ns}_zero_{k}")) for j in range(L))
|
|
199
|
+
self.model.Add(total_cells_k == total_len_by_color[k])
|
|
200
|
+
|
|
201
|
+
def solve_and_print(self, verbose: bool = True, visualize_colors: Optional[dict[str, str]] = None):
|
|
202
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
203
|
+
return SingleSolution(assignment={pos: color for pos, d in board.model_vars.items() for color, var in d.items() if solver.value(var) == 1})
|
|
204
|
+
def callback(single_res: SingleSolution):
|
|
205
|
+
print("Solution found")
|
|
206
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ' ')))
|
|
207
|
+
if visualize_colors is not None:
|
|
208
|
+
from matplotlib import pyplot as plt
|
|
209
|
+
from matplotlib.colors import ListedColormap
|
|
210
|
+
visualize_colors[' '] = 'black'
|
|
211
|
+
visualize_colors_keys = list(visualize_colors.keys())
|
|
212
|
+
char_to_int = {c: i for i, c in enumerate(visualize_colors_keys)}
|
|
213
|
+
nums = [[char_to_int[single_res.assignment.get(get_pos(x=c, y=r), ' ')] for c in range(self.H)] for r in range(self.V)]
|
|
214
|
+
plt.imshow(nums,
|
|
215
|
+
aspect='equal',
|
|
216
|
+
cmap=ListedColormap([visualize_colors[c] for c in visualize_colors_keys]),
|
|
217
|
+
extent=[0, self.H, self.V, 0])
|
|
218
|
+
plt.colorbar()
|
|
219
|
+
# plt.grid(True)
|
|
220
|
+
plt.show()
|
|
221
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -3,8 +3,9 @@ from dataclasses import dataclass
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
|
|
6
|
-
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char,
|
|
6
|
+
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, in_bounds, get_next_pos, Direction
|
|
7
7
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
@dataclass
|
|
@@ -89,13 +90,7 @@ class Board:
|
|
|
89
90
|
return SingleSolution(assignment=assignment)
|
|
90
91
|
def callback(single_res: SingleSolution):
|
|
91
92
|
print("Solution found")
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
c =
|
|
95
|
-
c = 'X' if pos in single_res.assignment else ' '
|
|
96
|
-
set_char(res, pos, c)
|
|
97
|
-
print('[')
|
|
98
|
-
for row in res:
|
|
99
|
-
print(" [ '" + "', '".join(row.tolist()) + "' ],")
|
|
100
|
-
print(']')
|
|
93
|
+
print(combined_function(self.V, self.H,
|
|
94
|
+
cell_flags=id_board_to_wall_fn(self.board),
|
|
95
|
+
is_shaded=lambda r, c: Pos(x=c, y=r) in single_res.assignment))
|
|
101
96
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -5,7 +5,7 @@ from ortools.sat.python import cp_model
|
|
|
5
5
|
|
|
6
6
|
from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, in_bounds, get_char, polyominoes, Shape, Direction, get_next_pos
|
|
7
7
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
8
|
-
from puzzle_solver.core.utils_visualizer import
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
@@ -122,5 +122,9 @@ class Board:
|
|
|
122
122
|
return SingleSolution(assignment=assignment)
|
|
123
123
|
def callback(single_res: SingleSolution):
|
|
124
124
|
print("Solution found")
|
|
125
|
-
print(
|
|
125
|
+
print(combined_function(self.V, self.H,
|
|
126
|
+
is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
|
|
127
|
+
center_char=lambda r, c: str(self.board[r, c]),
|
|
128
|
+
text_on_shaded_cells=False
|
|
129
|
+
))
|
|
126
130
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -6,7 +6,7 @@ from ortools.sat.python import cp_model
|
|
|
6
6
|
|
|
7
7
|
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, set_char, in_bounds, get_next_pos, Direction, polyominoes
|
|
8
8
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
-
from puzzle_solver.core.utils_visualizer import
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
|
|
@@ -100,6 +100,7 @@ class Board:
|
|
|
100
100
|
for pos in get_all_pos(self.V, self.H):
|
|
101
101
|
region_idx = single_res.assignment[pos]
|
|
102
102
|
set_char(id_board, pos, region_idx)
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
print(combined_function(self.V, self.H,
|
|
104
|
+
cell_flags=id_board_to_wall_fn(id_board),
|
|
105
|
+
center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else '·'))
|
|
105
106
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -4,16 +4,16 @@ import numpy as np
|
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
6
|
|
|
7
|
-
from puzzle_solver.core.utils import Pos, get_all_pos,
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, Direction, get_next_pos, get_char, get_opposite_direction, get_pos, set_char
|
|
8
8
|
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class Board:
|
|
12
13
|
def __init__(self, board: np.ndarray):
|
|
13
14
|
assert board.ndim == 2 and board.shape[0] > 0 and board.shape[1] > 0, f'board must be 2d, got {board.ndim}'
|
|
14
|
-
assert all(
|
|
15
|
-
self.V = board.shape
|
|
16
|
-
self.H = board.shape[1]
|
|
15
|
+
assert all(c.item().strip() in ['', 'B', 'W'] for c in np.nditer(board)), f'board must be space, B, or W, got {list(np.nditer(board))}'
|
|
16
|
+
self.V, self.H = board.shape
|
|
17
17
|
self.board = board
|
|
18
18
|
self.model = cp_model.CpModel()
|
|
19
19
|
self.cell_active: dict[Pos, cp_model.IntVar] = {}
|
|
@@ -36,11 +36,10 @@ class Board:
|
|
|
36
36
|
def force_wb_constraints(self):
|
|
37
37
|
for pos in get_all_pos(self.V, self.H):
|
|
38
38
|
c = get_char(self.board, pos)
|
|
39
|
-
if c
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
# must be a corner
|
|
39
|
+
if not c.strip():
|
|
40
|
+
continue
|
|
41
|
+
self.model.Add(self.cell_active[pos] == 1) # cell must be active
|
|
42
|
+
if c == 'B': # black circle must be a corner not connected directly to another corner
|
|
44
43
|
self.model.Add(self.cell_direction[(pos, Direction.UP)] != self.cell_direction[(pos, Direction.DOWN)])
|
|
45
44
|
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] != self.cell_direction[(pos, Direction.RIGHT)])
|
|
46
45
|
# must not be connected directly to another corner
|
|
@@ -49,11 +48,7 @@ class Board:
|
|
|
49
48
|
if not in_bounds(q, self.V, self.H):
|
|
50
49
|
continue
|
|
51
50
|
self.model.AddImplication(self.cell_direction[(pos, direction)], self.cell_direction[(q, direction)])
|
|
52
|
-
elif c == 'W':
|
|
53
|
-
# must be active
|
|
54
|
-
self.model.Add(self.cell_active[pos] == 1)
|
|
55
|
-
# white circle must be a straight which is connected to at least one corner
|
|
56
|
-
# must be straight
|
|
51
|
+
elif c == 'W': # white circle must be a straight which is connected to at least one corner
|
|
57
52
|
self.model.Add(self.cell_direction[(pos, Direction.UP)] == self.cell_direction[(pos, Direction.DOWN)])
|
|
58
53
|
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == self.cell_direction[(pos, Direction.RIGHT)])
|
|
59
54
|
# must be connected to at least one corner (i.e. UP-RIGHT or UP-LEFT or DOWN-RIGHT or DOWN-LEFT or RIGHT-UP or RIGHT-DOWN or LEFT-UP or LEFT-DOWN)
|
|
@@ -94,26 +89,19 @@ class Board:
|
|
|
94
89
|
return False
|
|
95
90
|
force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
|
|
96
91
|
|
|
97
|
-
|
|
98
92
|
def solve_and_print(self, verbose: bool = True):
|
|
99
93
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
100
94
|
assignment: dict[Pos, str] = defaultdict(str)
|
|
101
95
|
for (pos, direction), var in board.cell_direction.items():
|
|
102
96
|
assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
|
|
103
|
-
for pos in get_all_pos(self.V, self.H):
|
|
104
|
-
if len(assignment[pos]) == 0:
|
|
105
|
-
assignment[pos] = ' '
|
|
106
|
-
else:
|
|
107
|
-
assignment[pos] = ''.join(sorted(assignment[pos]))
|
|
108
97
|
return SingleSolution(assignment=assignment)
|
|
109
98
|
def callback(single_res: SingleSolution):
|
|
110
99
|
print("Solution found")
|
|
111
|
-
|
|
112
|
-
pretty_dict = {'DU': '┃ ', 'LR': '━━', 'DL': '━┒', 'DR': '┏━', 'RU': '┗━', 'LU': '━┛', ' ': ' '}
|
|
100
|
+
output_board = np.full((self.V, self.H), '', dtype=str)
|
|
113
101
|
for pos in get_all_pos(self.V, self.H):
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
print(
|
|
102
|
+
if get_char(self.board, pos) in ['B', 'W']: # if the main board has a white or black pearl, put it in the output
|
|
103
|
+
set_char(output_board, pos, get_char(self.board, pos))
|
|
104
|
+
if not single_res.assignment[pos].strip(): # if the cell does not the line through it, put a dot
|
|
105
|
+
set_char(output_board, pos, '.')
|
|
106
|
+
print(combined_function(self.V, self.H, show_grid=False, special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)], center_char=lambda r, c: output_board[r, c]))
|
|
119
107
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=20)
|
|
@@ -4,6 +4,7 @@ from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
|
4
4
|
|
|
5
5
|
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, Direction, get_next_pos, get_opposite_direction
|
|
6
6
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
7
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class Board:
|
|
@@ -77,5 +78,5 @@ class Board:
|
|
|
77
78
|
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
78
79
|
for pos in get_all_pos(self.V, self.H):
|
|
79
80
|
set_char(res, pos, single_res.assignment[pos])
|
|
80
|
-
print(res)
|
|
81
|
+
print(combined_function(self.V, self.H, show_grid=False, show_axes=True, special_content=lambda r, c: res[r, c], center_char=lambda r, c: 'O' if len(res[r, c]) == 1 else '')),
|
|
81
82
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
from ortools.sat.python import cp_model
|
|
3
3
|
|
|
4
|
-
from puzzle_solver.core.utils import Pos, get_all_pos,
|
|
4
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_neighbors4, in_bounds, Direction, get_next_pos, get_char
|
|
5
5
|
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
@@ -18,88 +19,51 @@ def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
|
18
19
|
class Board:
|
|
19
20
|
def __init__(self, clues: np.ndarray):
|
|
20
21
|
assert clues.ndim == 2 and clues.shape[0] > 0 and clues.shape[1] > 0, f'clues must be 2d, got {clues.ndim}'
|
|
21
|
-
assert all(
|
|
22
|
-
self.V = clues.shape
|
|
23
|
-
self.H = clues.shape[1]
|
|
22
|
+
assert all(str(i.item()).strip() == '' or str(i.item()).strip().isdecimal() for i in np.nditer(clues)), f'clues must be empty or a decimal number, got {list(np.nditer(clues))}'
|
|
23
|
+
self.V, self.H = clues.shape
|
|
24
24
|
self.clues = clues
|
|
25
|
-
self.model = cp_model.CpModel()
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
self.b: dict[Pos, cp_model.IntVar] = {}
|
|
29
|
-
self.w: dict[Pos, cp_model.IntVar] = {}
|
|
26
|
+
self.model = cp_model.CpModel()
|
|
27
|
+
self.b: dict[Pos, cp_model.IntVar] = {}
|
|
28
|
+
self.w: dict[Pos, cp_model.IntVar] = {}
|
|
30
29
|
|
|
31
30
|
self.create_vars()
|
|
32
31
|
self.add_all_constraints()
|
|
33
32
|
|
|
34
33
|
def create_vars(self):
|
|
35
|
-
# Cell color vars
|
|
36
34
|
for pos in get_all_pos(self.V, self.H):
|
|
37
35
|
self.b[pos] = self.model.NewBoolVar(f"b[{pos}]")
|
|
38
|
-
self.w[pos] = self.
|
|
39
|
-
self.model.AddExactlyOne([self.b[pos], self.w[pos]])
|
|
36
|
+
self.w[pos] = self.b[pos].Not()
|
|
40
37
|
|
|
41
38
|
def add_all_constraints(self):
|
|
42
39
|
self.no_adjacent_blacks()
|
|
43
|
-
self.white_connectivity_percolation()
|
|
44
40
|
self.range_clues()
|
|
41
|
+
force_connected_component(self.model, self.w)
|
|
45
42
|
|
|
46
43
|
def no_adjacent_blacks(self):
|
|
47
|
-
cache = set()
|
|
48
44
|
for p in get_all_pos(self.V, self.H):
|
|
49
45
|
for q in get_neighbors4(p, self.V, self.H):
|
|
50
|
-
if (p, q) in cache:
|
|
51
|
-
continue
|
|
52
|
-
cache.add((p, q))
|
|
53
46
|
self.model.Add(self.b[p] + self.b[q] <= 1)
|
|
54
47
|
|
|
55
|
-
|
|
56
|
-
def white_connectivity_percolation(self):
|
|
57
|
-
force_connected_component(self.model, self.w)
|
|
58
|
-
|
|
59
48
|
def range_clues(self):
|
|
60
|
-
# For each numbered cell c with value k
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# - Sum of visible whites = 1 (itself) + sum(chains) == k
|
|
64
|
-
for pos in get_all_pos(self.V, self.H):
|
|
65
|
-
k = get_char(self.clues, pos)
|
|
66
|
-
if k == -1:
|
|
49
|
+
for pos in get_all_pos(self.V, self.H): # For each numbered cell c with value k
|
|
50
|
+
k = str(get_char(self.clues, pos)).strip()
|
|
51
|
+
if not k:
|
|
67
52
|
continue
|
|
68
|
-
|
|
69
|
-
self.model.Add(self.b[pos] == 0)
|
|
70
|
-
|
|
71
|
-
# Build visibility chains per direction (exclude self)
|
|
53
|
+
self.model.Add(self.w[pos] == 1) # Force it white
|
|
72
54
|
vis_vars: list[cp_model.IntVar] = []
|
|
73
|
-
for direction in Direction:
|
|
55
|
+
for direction in Direction: # Build visibility chains in four direction
|
|
74
56
|
ray = get_ray(pos, self.V, self.H, direction) # cells outward
|
|
75
|
-
|
|
76
|
-
continue
|
|
77
|
-
# Chain: v0 = w[ray[0]]; vt = w[ray[t]] & vt-1
|
|
78
|
-
prev = None
|
|
79
|
-
for idx, cell in enumerate(ray):
|
|
57
|
+
for idx in range(len(ray)):
|
|
80
58
|
v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
|
|
59
|
+
and_constraint(self.model, target=v, cs=[self.w[p] for p in ray[:idx+1]])
|
|
81
60
|
vis_vars.append(v)
|
|
82
|
-
|
|
83
|
-
# v0 == w[cell]
|
|
84
|
-
self.model.Add(v == self.w[cell])
|
|
85
|
-
else:
|
|
86
|
-
and_constraint(self.model, target=v, cs=[self.w[cell], prev])
|
|
87
|
-
prev = v
|
|
88
|
-
|
|
89
|
-
# 1 (self) + sum(vis_vars) == k
|
|
90
|
-
self.model.Add(1 + sum(vis_vars) == k)
|
|
61
|
+
self.model.Add(1 + sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
|
|
91
62
|
|
|
92
63
|
def solve_and_print(self, verbose: bool = True):
|
|
93
64
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
94
|
-
assignment:
|
|
95
|
-
for pos, var in board.b.items():
|
|
96
|
-
assignment[pos] = solver.Value(var)
|
|
97
|
-
return SingleSolution(assignment=assignment)
|
|
65
|
+
return SingleSolution(assignment={pos: solver.Value(board.b[pos]) for pos in get_all_pos(board.V, board.H)})
|
|
98
66
|
def callback(single_res: SingleSolution):
|
|
99
67
|
print("Solution:")
|
|
100
|
-
|
|
101
|
-
for pos in get_all_pos(self.V, self.H):
|
|
102
|
-
c = 'B' if single_res.assignment[pos] == 1 else ' '
|
|
103
|
-
set_char(res, pos, c)
|
|
104
|
-
print(res)
|
|
68
|
+
print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, center_char=lambda r, c: self.clues[r, c].strip(), text_on_shaded_cells=False))
|
|
105
69
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -6,7 +6,7 @@ from ortools.sat.python import cp_model
|
|
|
6
6
|
|
|
7
7
|
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, Direction, get_next_pos
|
|
8
8
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
-
from puzzle_solver.core.utils_visualizer import
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
|
|
@@ -121,6 +121,8 @@ class Board:
|
|
|
121
121
|
set_char(res, pos, get_char(res, pos) + 'U')
|
|
122
122
|
if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
|
|
123
123
|
set_char(res, pos, get_char(res, pos) + 'D')
|
|
124
|
-
print(
|
|
124
|
+
print(combined_function(self.V, self.H,
|
|
125
|
+
cell_flags=lambda r, c: res[r, c],
|
|
126
|
+
center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else ' '))
|
|
125
127
|
|
|
126
128
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -7,7 +7,7 @@ from ortools.sat.python import cp_model
|
|
|
7
7
|
|
|
8
8
|
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_neighbors4, get_next_pos, get_char, in_bounds
|
|
9
9
|
from puzzle_solver.core.utils_ortools import generic_solve_all, force_connected_component, and_constraint
|
|
10
|
-
from puzzle_solver.core.utils_visualizer import
|
|
10
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@dataclass(frozen=True)
|
|
@@ -154,5 +154,5 @@ class Board:
|
|
|
154
154
|
res[min_y][min_x] += 'L'
|
|
155
155
|
else:
|
|
156
156
|
raise ValueError(f'Invalid position: {pos} and {neighbor}')
|
|
157
|
-
print(
|
|
157
|
+
print(combined_function(self.V - 1, self.H - 1, cell_flags=lambda r, c: res[r, c], center_char=lambda r, c: '.'))
|
|
158
158
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -3,7 +3,7 @@ from ortools.sat.python import cp_model
|
|
|
3
3
|
|
|
4
4
|
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors4, get_all_pos_to_idx_dict, get_row_pos, get_col_pos, get_pos
|
|
5
5
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
6
|
-
from puzzle_solver.core.utils_visualizer import
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class Board:
|
|
@@ -45,5 +45,9 @@ class Board:
|
|
|
45
45
|
return SingleSolution(assignment={pos: 1 if solver.Value(val) == 1 else 0 for pos, val in board.B.items()})
|
|
46
46
|
def callback(single_res: SingleSolution):
|
|
47
47
|
print("Solution found")
|
|
48
|
-
print(
|
|
48
|
+
print(combined_function(self.V, self.H,
|
|
49
|
+
is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
|
|
50
|
+
center_char=lambda r, c: self.board[r, c],
|
|
51
|
+
text_on_shaded_cells=False
|
|
52
|
+
))
|
|
49
53
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -4,8 +4,9 @@ from dataclasses import dataclass
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
from ortools.sat.python import cp_model
|
|
6
6
|
|
|
7
|
-
from puzzle_solver.core.utils import Pos, get_all_pos,
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, get_pos
|
|
8
8
|
from puzzle_solver.core.utils_ortools import force_no_loops, generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@dataclass(frozen=True)
|
|
@@ -13,10 +14,10 @@ class Node:
|
|
|
13
14
|
"""The grid is represented as a graph of cells connected to corners."""
|
|
14
15
|
node_type: Union[Literal["Cell"], Literal["Corner"]]
|
|
15
16
|
pos: Pos
|
|
16
|
-
slant: Union[Literal["
|
|
17
|
+
slant: Union[Literal["/"], Literal["\\"], None]
|
|
17
18
|
|
|
18
19
|
def get_neighbors(self, board_nodes: dict[tuple[str, Pos, Optional[str]], "Node"]) -> list["Node"]:
|
|
19
|
-
if self.node_type == "Cell" and self.slant == "
|
|
20
|
+
if self.node_type == "Cell" and self.slant == "/":
|
|
20
21
|
n1 = board_nodes[("Corner", get_pos(self.pos.x+1, self.pos.y), None)]
|
|
21
22
|
n2 = board_nodes[("Corner", get_pos(self.pos.x, self.pos.y+1), None)]
|
|
22
23
|
return [n1, n2]
|
|
@@ -27,8 +28,8 @@ class Node:
|
|
|
27
28
|
elif self.node_type == "Corner":
|
|
28
29
|
# 4 cells, 2 cells per slant
|
|
29
30
|
n1 = ("Cell", get_pos(self.pos.x-1, self.pos.y-1), "\\")
|
|
30
|
-
n2 = ("Cell", get_pos(self.pos.x, self.pos.y-1), "
|
|
31
|
-
n3 = ("Cell", get_pos(self.pos.x-1, self.pos.y), "
|
|
31
|
+
n2 = ("Cell", get_pos(self.pos.x, self.pos.y-1), "/")
|
|
32
|
+
n3 = ("Cell", get_pos(self.pos.x-1, self.pos.y), "/")
|
|
32
33
|
n4 = ("Cell", get_pos(self.pos.x, self.pos.y), "\\")
|
|
33
34
|
return {board_nodes[n] for n in [n1, n2, n3, n4] if n in board_nodes}
|
|
34
35
|
|
|
@@ -61,9 +62,9 @@ class Board:
|
|
|
61
62
|
|
|
62
63
|
def create_vars(self):
|
|
63
64
|
for pos in get_all_pos(self.V, self.H):
|
|
64
|
-
self.model_vars[(pos, '
|
|
65
|
+
self.model_vars[(pos, '/')] = self.model.NewBoolVar(f'{pos}:/')
|
|
65
66
|
self.model_vars[(pos, '\\')] = self.model.NewBoolVar(f'{pos}:\\')
|
|
66
|
-
self.model.AddExactlyOne([self.model_vars[(pos, '
|
|
67
|
+
self.model.AddExactlyOne([self.model_vars[(pos, '/')], self.model_vars[(pos, '\\')]])
|
|
67
68
|
for (pos, slant), v in self.model_vars.items():
|
|
68
69
|
self.nodes[Node(node_type="Cell", pos=pos, slant=slant)] = v
|
|
69
70
|
for pos in get_all_pos(self.V + 1, self.H + 1):
|
|
@@ -76,8 +77,8 @@ class Board:
|
|
|
76
77
|
# when pos is (xi, yi) then it gets a +1 contribution for each:
|
|
77
78
|
# - cell (xi-1, yi-1) is a "\\"
|
|
78
79
|
# - cell (xi, yi) is a "\\"
|
|
79
|
-
# - cell (xi, yi-1) is a "
|
|
80
|
-
# - cell (xi-1, yi) is a "
|
|
80
|
+
# - cell (xi, yi-1) is a "/"
|
|
81
|
+
# - cell (xi-1, yi) is a "/"
|
|
81
82
|
xi, yi = pos.x, pos.y
|
|
82
83
|
tl_pos = get_pos(xi-1, yi-1)
|
|
83
84
|
br_pos = get_pos(xi, yi)
|
|
@@ -85,8 +86,8 @@ class Board:
|
|
|
85
86
|
bl_pos = get_pos(xi-1, yi)
|
|
86
87
|
tl_var = self.model_vars[(tl_pos, '\\')] if in_bounds(tl_pos, self.V, self.H) else 0
|
|
87
88
|
br_var = self.model_vars[(br_pos, '\\')] if in_bounds(br_pos, self.V, self.H) else 0
|
|
88
|
-
tr_var = self.model_vars[(tr_pos, '
|
|
89
|
-
bl_var = self.model_vars[(bl_pos, '
|
|
89
|
+
tr_var = self.model_vars[(tr_pos, '/')] if in_bounds(tr_pos, self.V, self.H) else 0
|
|
90
|
+
bl_var = self.model_vars[(bl_pos, '/')] if in_bounds(bl_pos, self.V, self.H) else 0
|
|
90
91
|
self.model.Add(sum([tl_var, tr_var, bl_var, br_var]) == number)
|
|
91
92
|
board_nodes = {(node.node_type, node.pos, node.slant): node for node in self.nodes.keys()}
|
|
92
93
|
self.neighbor_dict = {node: node.get_neighbors(board_nodes) for node in self.nodes.keys()}
|
|
@@ -106,12 +107,5 @@ class Board:
|
|
|
106
107
|
return SingleSolution(assignment=assignment)
|
|
107
108
|
def callback(single_res: SingleSolution):
|
|
108
109
|
print("Solution found")
|
|
109
|
-
|
|
110
|
-
for pos in get_all_pos(self.V, self.H):
|
|
111
|
-
set_char(res, pos, '/' if single_res.assignment[pos] == '//' else '\\')
|
|
112
|
-
print('[')
|
|
113
|
-
for row in range(self.V):
|
|
114
|
-
line = ' [ ' + ' '.join(res[row].tolist()) + ' ]'
|
|
115
|
-
print(line)
|
|
116
|
-
print(']')
|
|
110
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
|
|
117
111
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|