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,313 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from ortools.sat.python import cp_model
|
|
7
|
+
|
|
8
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_row_pos, get_col_pos, in_bounds, get_opposite_direction, get_next_pos, Direction
|
|
9
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class SingleSolution:
|
|
14
|
+
assignment: dict[Pos, Union[str, int]]
|
|
15
|
+
beam_assignments: dict[Pos, list[tuple[int, Pos, Direction]]]
|
|
16
|
+
|
|
17
|
+
def get_hashable_solution(self) -> str:
|
|
18
|
+
result = []
|
|
19
|
+
for pos, v in self.assignment.items():
|
|
20
|
+
result.append((pos.x, pos.y, v))
|
|
21
|
+
return json.dumps(result, sort_keys=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Board:
|
|
25
|
+
def __init__(self, top: list, right: list, bottom: list, left: list, ball_count: Optional[tuple[int, int]] = None, max_travel_steps: Optional[int] = None):
|
|
26
|
+
assert len(top) == len(bottom), 'top and bottom must be the same length'
|
|
27
|
+
assert len(left) == len(right), 'left and right must be the same length'
|
|
28
|
+
self.K = len(top) + len(right) + len(bottom) + len(left) # side count
|
|
29
|
+
self.H = len(top)
|
|
30
|
+
self.V = len(left)
|
|
31
|
+
if max_travel_steps is None:
|
|
32
|
+
self.T = self.V * self.H # maximum travel steps for a beam that bounces an undefined number of times
|
|
33
|
+
else:
|
|
34
|
+
self.T = max_travel_steps
|
|
35
|
+
self.ball_count = ball_count
|
|
36
|
+
|
|
37
|
+
# top and bottom entry cells are at -1 and V
|
|
38
|
+
self.top_cells = set(get_row_pos(row_idx=-1, H=self.H))
|
|
39
|
+
self.bottom_cells = set(get_row_pos(row_idx=self.V, H=self.H))
|
|
40
|
+
# left and right entry cells are at -1 and H
|
|
41
|
+
self.left_cells = set(get_col_pos(col_idx=-1, V=self.V))
|
|
42
|
+
self.right_cells = set(get_col_pos(col_idx=self.H, V=self.V))
|
|
43
|
+
# self.top_cells = set([Pos(x=1, y=-1)])
|
|
44
|
+
# self.bottom_cells = set()
|
|
45
|
+
# self.left_cells = set()
|
|
46
|
+
# self.right_cells = set()
|
|
47
|
+
# print(self.top_cells)
|
|
48
|
+
|
|
49
|
+
self.top_values = top
|
|
50
|
+
self.right_values = right
|
|
51
|
+
self.bottom_values = bottom
|
|
52
|
+
self.left_values = left
|
|
53
|
+
|
|
54
|
+
self.model = cp_model.CpModel()
|
|
55
|
+
self.ball_states: dict[Pos, cp_model.IntVar] = {}
|
|
56
|
+
# (entry_pos, T, cell_pos, direction) -> True if the beam that entered from the board at "entry_pos" is present in "cell_pos" and is going in the direction "direction" at time T
|
|
57
|
+
self.beam_states: dict[tuple[Pos, int, Pos, Direction], cp_model.IntVar] = {}
|
|
58
|
+
# self.beam_states_ending_at: dict[Pos, cp_model.IntVar] = {}
|
|
59
|
+
self.beam_states_at_t: dict[int, dict[Pos, dict[tuple[Pos, Direction], cp_model.IntVar]]] = {}
|
|
60
|
+
self.create_vars()
|
|
61
|
+
print('Total number of variables:', len(self.ball_states), len(self.beam_states))
|
|
62
|
+
self.add_all_constraints()
|
|
63
|
+
print('Solving...')
|
|
64
|
+
|
|
65
|
+
def get_outside_border(self):
|
|
66
|
+
top_border = tuple(get_row_pos(row_idx=-1, H=self.H))
|
|
67
|
+
bottom_border = tuple(get_row_pos(row_idx=self.V, H=self.H))
|
|
68
|
+
left_border = tuple(get_col_pos(col_idx=-1, V=self.V))
|
|
69
|
+
right_border = tuple(get_col_pos(col_idx=self.H, V=self.V))
|
|
70
|
+
return (*top_border, *bottom_border, *left_border, *right_border)
|
|
71
|
+
|
|
72
|
+
def get_all_pos_extended(self):
|
|
73
|
+
return (*self.get_outside_border(), *get_all_pos(self.V, self.H))
|
|
74
|
+
|
|
75
|
+
def create_vars(self):
|
|
76
|
+
for pos in get_all_pos(self.V, self.H):
|
|
77
|
+
self.ball_states[pos] = self.model.NewBoolVar(f'ball_at:{pos}')
|
|
78
|
+
for pos in self.get_all_pos_extended(): # NxN board + 4 edges
|
|
79
|
+
if pos not in self.ball_states: # pos is not in the board -> its on the edge
|
|
80
|
+
self.ball_states[pos] = None # balls cant be on the edge
|
|
81
|
+
|
|
82
|
+
for entry_pos in (self.top_cells | self.right_cells | self.bottom_cells | self.left_cells):
|
|
83
|
+
|
|
84
|
+
for t in range(self.T):
|
|
85
|
+
self.beam_states[(entry_pos, t, 'HIT', 'HIT')] = self.model.NewBoolVar(f'beam:{entry_pos}:{t}:HIT:HIT')
|
|
86
|
+
for cell in self.get_all_pos_extended():
|
|
87
|
+
for direction in Direction:
|
|
88
|
+
self.beam_states[(entry_pos, t, cell, direction)] = self.model.NewBoolVar(f'beam:{entry_pos}:{t}:{cell}:{direction}')
|
|
89
|
+
|
|
90
|
+
for (entry_pos, t, cell, direction) in self.beam_states.keys():
|
|
91
|
+
if t not in self.beam_states_at_t:
|
|
92
|
+
self.beam_states_at_t[t] = {}
|
|
93
|
+
if entry_pos not in self.beam_states_at_t[t]:
|
|
94
|
+
self.beam_states_at_t[t][entry_pos] = {}
|
|
95
|
+
self.beam_states_at_t[t][entry_pos][(cell, direction)] = self.beam_states[(entry_pos, t, cell, direction)]
|
|
96
|
+
|
|
97
|
+
def add_all_constraints(self):
|
|
98
|
+
self.init_beams()
|
|
99
|
+
self.constrain_beam_movement()
|
|
100
|
+
self.constrain_final_beam_states()
|
|
101
|
+
if self.ball_count is not None:
|
|
102
|
+
s = sum([b for b in self.ball_states.values() if b is not None])
|
|
103
|
+
b_min, b_max = self.ball_count
|
|
104
|
+
self.model.Add(s >= b_min)
|
|
105
|
+
self.model.Add(s <= b_max)
|
|
106
|
+
|
|
107
|
+
def init_beams(self):
|
|
108
|
+
beam_ids = []
|
|
109
|
+
beam_ids.extend((beam_id, Direction.DOWN) for beam_id in self.top_cells)
|
|
110
|
+
beam_ids.extend((beam_id, Direction.LEFT) for beam_id in self.right_cells)
|
|
111
|
+
beam_ids.extend((beam_id, Direction.UP) for beam_id in self.bottom_cells)
|
|
112
|
+
beam_ids.extend((beam_id, Direction.RIGHT) for beam_id in self.left_cells)
|
|
113
|
+
|
|
114
|
+
for (beam_id, direction) in beam_ids:
|
|
115
|
+
# beam at t=0 is present at beam_id and facing direction
|
|
116
|
+
self.model.Add(self.beam_states[(beam_id, 0, beam_id, direction)] == 1)
|
|
117
|
+
for p in self.get_all_pos_extended():
|
|
118
|
+
# print(f'beam can be at {p}')
|
|
119
|
+
for direction in Direction:
|
|
120
|
+
if (p, direction) != (beam_id, direction):
|
|
121
|
+
self.model.Add(self.beam_states[(beam_id, 0, p, direction)] == 0)
|
|
122
|
+
# for p in self.get_all_pos_extended():
|
|
123
|
+
# print(f'beam can be at {p}')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def constrain_beam_movement(self):
|
|
127
|
+
for t in range(self.T):
|
|
128
|
+
for entry_pos in self.beam_states_at_t[t].keys():
|
|
129
|
+
next_state_dict = self.beam_states_at_t[t][entry_pos]
|
|
130
|
+
self.model.AddExactlyOne(list(next_state_dict.values()))
|
|
131
|
+
# print('add exactly one constraint for beam id', entry_pos, 'at time', t, next_state_dict.keys(), '\n')
|
|
132
|
+
if t == self.T - 1:
|
|
133
|
+
continue
|
|
134
|
+
for (cell, direction), prev_state in next_state_dict.items():
|
|
135
|
+
# print(f'for beam id {entry_pos}, time {t}\nif its at {cell} facing {direction}')
|
|
136
|
+
self.constrain_next_beam_state(entry_pos, t+1, cell, direction, prev_state)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def constrain_next_beam_state(self, entry_pos: Pos, t: int, cur_pos: Pos, direction: Direction, prev_state: cp_model.IntVar):
|
|
140
|
+
# print(f"constraining next beam state for {entry_pos}, {t}, {cur_pos}, {direction}")
|
|
141
|
+
if cur_pos == "HIT": # a beam that was "HIT" stays "HIT"
|
|
142
|
+
# print(f' HIT -> stays HIT')
|
|
143
|
+
self.model.Add(self.beam_states[(entry_pos, t, "HIT", "HIT")] == 1).OnlyEnforceIf(prev_state)
|
|
144
|
+
return
|
|
145
|
+
# if a beam is outside the board AND it is not facing the board -> it maintains its state
|
|
146
|
+
pos_ahead = get_next_pos(cur_pos, direction)
|
|
147
|
+
if not in_bounds(pos_ahead, self.V, self.H) and not in_bounds(cur_pos, self.V, self.H):
|
|
148
|
+
# print(f' OUTSIDE BOARD -> beam stays in the same state')
|
|
149
|
+
self.model.Add(self.beam_states[(entry_pos, t, cur_pos, direction)] == 1).OnlyEnforceIf(prev_state)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# look at the 3 balls ahead of the beam: thus 8 possible scenarios
|
|
153
|
+
# A beam with no balls ahead of it -> moves forward in the same direction (1 scenario)
|
|
154
|
+
# A beam that hits a ball head-on -> beam is "HIT" (4 scenarios)
|
|
155
|
+
# A beam with a ball in its front-left square and no ball ahead of it -> gets deflected 90 degrees to the right (1 scenario)
|
|
156
|
+
# A beam with a ball in its front-right square and no ball ahead of it -> gets similarly deflected to the left (1 scenario)
|
|
157
|
+
# A beam that would in its front-left AND front-right squares -> is reflected (1 scenarios)
|
|
158
|
+
|
|
159
|
+
direction_left = {
|
|
160
|
+
Direction.UP: Direction.LEFT,
|
|
161
|
+
Direction.LEFT: Direction.DOWN,
|
|
162
|
+
Direction.DOWN: Direction.RIGHT,
|
|
163
|
+
Direction.RIGHT: Direction.UP,
|
|
164
|
+
}[direction]
|
|
165
|
+
direction_right = {
|
|
166
|
+
Direction.UP: Direction.RIGHT,
|
|
167
|
+
Direction.RIGHT: Direction.DOWN,
|
|
168
|
+
Direction.DOWN: Direction.LEFT,
|
|
169
|
+
Direction.LEFT: Direction.UP,
|
|
170
|
+
}[direction]
|
|
171
|
+
reflected = get_opposite_direction(direction)
|
|
172
|
+
ball_left_pos = get_next_pos(pos_ahead, direction_left)
|
|
173
|
+
ball_right_pos = get_next_pos(pos_ahead, direction_right)
|
|
174
|
+
if in_bounds(pos_ahead, self.V, self.H):
|
|
175
|
+
ball_ahead = self.ball_states[pos_ahead]
|
|
176
|
+
ball_ahead_not = ball_ahead.Not()
|
|
177
|
+
else:
|
|
178
|
+
ball_ahead = False
|
|
179
|
+
ball_ahead_not = True
|
|
180
|
+
if in_bounds(ball_left_pos, self.V, self.H):
|
|
181
|
+
ball_left = self.ball_states[ball_left_pos]
|
|
182
|
+
ball_left_not = ball_left.Not()
|
|
183
|
+
else:
|
|
184
|
+
ball_left = False
|
|
185
|
+
ball_left_not = True
|
|
186
|
+
if in_bounds(ball_right_pos, self.V, self.H):
|
|
187
|
+
ball_right = self.ball_states[ball_right_pos]
|
|
188
|
+
ball_right_not = ball_right.Not()
|
|
189
|
+
else:
|
|
190
|
+
ball_right = False
|
|
191
|
+
ball_right_not = True
|
|
192
|
+
|
|
193
|
+
pos_left = get_next_pos(cur_pos, direction_left)
|
|
194
|
+
pos_right = get_next_pos(cur_pos, direction_right)
|
|
195
|
+
pos_reflected = get_next_pos(cur_pos, reflected)
|
|
196
|
+
if not in_bounds(pos_left, self.V, self.H):
|
|
197
|
+
pos_left = cur_pos
|
|
198
|
+
if not in_bounds(pos_right, self.V, self.H):
|
|
199
|
+
pos_right = cur_pos
|
|
200
|
+
if not in_bounds(pos_reflected, self.V, self.H):
|
|
201
|
+
pos_reflected = cur_pos
|
|
202
|
+
|
|
203
|
+
# debug_states = {
|
|
204
|
+
# 'if ball head': (entry_pos, t, "HIT", "HIT"),
|
|
205
|
+
# 'if ball in front-left': (entry_pos, t, get_next_pos(cur_pos, direction_right), direction_right),
|
|
206
|
+
# 'if ball in front-right': (entry_pos, t, get_next_pos(cur_pos, direction_left), direction_left),
|
|
207
|
+
# 'if ball in front-left and front-right': (entry_pos, t, get_next_pos(cur_pos, reflected), reflected),
|
|
208
|
+
# 'if no ball ahead': (entry_pos, t, pos_ahead, direction),
|
|
209
|
+
# }
|
|
210
|
+
# for k,v in debug_states.items():
|
|
211
|
+
# print(f' {k} -> {v}')
|
|
212
|
+
|
|
213
|
+
# ball head-on -> beam is "HIT"
|
|
214
|
+
self.model.Add(self.beam_states[(entry_pos, t, "HIT", "HIT")] == 1).OnlyEnforceIf([
|
|
215
|
+
ball_ahead,
|
|
216
|
+
prev_state,
|
|
217
|
+
])
|
|
218
|
+
# ball in front-left -> beam is deflected right
|
|
219
|
+
self.model.Add(self.beam_states[(entry_pos, t, pos_right, direction_right)] == 1).OnlyEnforceIf([
|
|
220
|
+
ball_ahead_not,
|
|
221
|
+
ball_left,
|
|
222
|
+
ball_right_not,
|
|
223
|
+
prev_state,
|
|
224
|
+
])
|
|
225
|
+
# ball in front-right -> beam is deflected left
|
|
226
|
+
self.model.Add(self.beam_states[(entry_pos, t, pos_left, direction_left)] == 1).OnlyEnforceIf([
|
|
227
|
+
ball_ahead_not,
|
|
228
|
+
ball_left_not,
|
|
229
|
+
ball_right,
|
|
230
|
+
prev_state,
|
|
231
|
+
])
|
|
232
|
+
# ball in front-left and front-right -> beam is reflected
|
|
233
|
+
self.model.Add(self.beam_states[(entry_pos, t, pos_reflected, reflected)] == 1).OnlyEnforceIf([
|
|
234
|
+
ball_ahead_not,
|
|
235
|
+
ball_left,
|
|
236
|
+
ball_right,
|
|
237
|
+
prev_state,
|
|
238
|
+
])
|
|
239
|
+
# no ball ahead -> beam moves forward in the same direction
|
|
240
|
+
self.model.Add(self.beam_states[(entry_pos, t, pos_ahead, direction)] == 1).OnlyEnforceIf([
|
|
241
|
+
ball_ahead_not,
|
|
242
|
+
ball_left_not,
|
|
243
|
+
ball_right_not,
|
|
244
|
+
prev_state,
|
|
245
|
+
])
|
|
246
|
+
|
|
247
|
+
def constrain_final_beam_states(self):
|
|
248
|
+
all_values = []
|
|
249
|
+
all_values.extend([(Pos(x=c, y=-1), top_value) for c, top_value in enumerate(self.top_values)])
|
|
250
|
+
all_values.extend([(Pos(x=self.H, y=c), right_value) for c, right_value in enumerate(self.right_values)])
|
|
251
|
+
all_values.extend([(Pos(x=c, y=self.V), bottom_value) for c, bottom_value in enumerate(self.bottom_values)])
|
|
252
|
+
all_values.extend([(Pos(x=-1, y=c), left_value) for c, left_value in enumerate(self.left_values)])
|
|
253
|
+
digits = {}
|
|
254
|
+
hits = []
|
|
255
|
+
reflects = []
|
|
256
|
+
for pos, value in all_values:
|
|
257
|
+
value = str(value)
|
|
258
|
+
if value.isdecimal():
|
|
259
|
+
digits.setdefault(value, []).append(pos)
|
|
260
|
+
elif value == 'H':
|
|
261
|
+
hits.append(pos)
|
|
262
|
+
elif value == 'R':
|
|
263
|
+
reflects.append(pos)
|
|
264
|
+
else:
|
|
265
|
+
raise ValueError(f'Invalid value: {value}')
|
|
266
|
+
for digit, pos_list in digits.items():
|
|
267
|
+
assert len(pos_list) == 2, f'digit {digit} has {len(pos_list)} positions: {pos_list}'
|
|
268
|
+
p1, p2 = pos_list
|
|
269
|
+
self.model.AddExactlyOne([self.beam_states[(p1, self.T-1, p2, direction)] for direction in Direction])
|
|
270
|
+
self.model.AddExactlyOne([self.beam_states[(p2, self.T-1, p1, direction)] for direction in Direction])
|
|
271
|
+
for hit in hits:
|
|
272
|
+
self.model.AddExactlyOne([self.beam_states[(hit, self.T-1, 'HIT', 'HIT')]])
|
|
273
|
+
for reflect in reflects:
|
|
274
|
+
self.model.AddExactlyOne([self.beam_states[(reflect, self.T-1, reflect, direction)] for direction in Direction])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def solve_and_print(self, verbose: bool = True):
|
|
278
|
+
count = 0
|
|
279
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
280
|
+
nonlocal count
|
|
281
|
+
count += 1
|
|
282
|
+
# if count > 99:
|
|
283
|
+
# import sys
|
|
284
|
+
# sys.exit(0)
|
|
285
|
+
assignment = {}
|
|
286
|
+
for pos in get_all_pos(self.V, self.H):
|
|
287
|
+
assignment[pos] = solver.value(self.ball_states[pos])
|
|
288
|
+
beam_assignments = {}
|
|
289
|
+
# for (entry_pos, t, cell, direction), v in self.beam_states.items():
|
|
290
|
+
# if entry_pos not in beam_assignments:
|
|
291
|
+
# beam_assignments[entry_pos] = []
|
|
292
|
+
# if solver.value(v): # for every beam it can only be present in one state at a time
|
|
293
|
+
# beam_assignments[entry_pos].append((t, cell, direction))
|
|
294
|
+
# for k,v in beam_assignments.items():
|
|
295
|
+
# beam_assignments[k] = sorted(v, key=lambda x: x[0])
|
|
296
|
+
# print(k, beam_assignments[k], '\n')
|
|
297
|
+
# print(beam_assignments)
|
|
298
|
+
return SingleSolution(assignment=assignment, beam_assignments=beam_assignments)
|
|
299
|
+
|
|
300
|
+
def callback(single_res: SingleSolution):
|
|
301
|
+
print("Solution found")
|
|
302
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
303
|
+
for pos in get_all_pos(self.V, self.H):
|
|
304
|
+
ball_state = 'O' if single_res.assignment[pos] else ' '
|
|
305
|
+
res[pos.y][pos.x] = ball_state
|
|
306
|
+
print(res)
|
|
307
|
+
r = generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
308
|
+
# print('non unique count:', count)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
|
|
@@ -1,192 +1,117 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
from
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
for pos
|
|
48
|
-
self.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
self.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
for k in range(self.N):
|
|
119
|
-
root_pos = self.pos_of[k]
|
|
120
|
-
|
|
121
|
-
for p in get_all_pos(self.V, self.H):
|
|
122
|
-
dp = m.NewIntVar(0, self.INF, f'dist[{self.idx_of[p]},{k}]')
|
|
123
|
-
self.dist[(p, k)] = dp
|
|
124
|
-
|
|
125
|
-
# If p not in region k -> dist = INF
|
|
126
|
-
m.Add(dp == self.INF).OnlyEnforceIf(self.in_region[(p, k)].Not())
|
|
127
|
-
|
|
128
|
-
# Root distance: if k is active at its own position -> dist[root,k] = 0
|
|
129
|
-
if p == root_pos:
|
|
130
|
-
m.Add(dp == 0).OnlyEnforceIf(self.is_root[root_pos])
|
|
131
|
-
# If root_pos isn't the root for k, membership is 0 and above rule sets INF.
|
|
132
|
-
|
|
133
|
-
# For non-root members p of region k: dist[p,k] = 1 + min masked neighbor distances
|
|
134
|
-
for p in get_all_pos(self.V, self.H):
|
|
135
|
-
if p == root_pos:
|
|
136
|
-
continue # handled above
|
|
137
|
-
|
|
138
|
-
# Build masked neighbor candidates: INF if neighbor not in region k; else dist[n,k] + 1
|
|
139
|
-
cand_vars = []
|
|
140
|
-
for n in get_neighbors4(p, self.V, self.H):
|
|
141
|
-
cn = m.NewIntVar(0, self.INF, f'canddist[{self.idx_of[p]},{k}->{self.idx_of[n]}]')
|
|
142
|
-
cand_vars.append(cn)
|
|
143
|
-
|
|
144
|
-
both_in_region_k = m.NewBoolVar(f'both_in_region_k[{self.idx_of[p]} in {k} and {self.idx_of[n]} in {k}]')
|
|
145
|
-
and_constraint(m, both_in_region_k, [self.in_region[(p, k)], self.in_region[(n, k)]])
|
|
146
|
-
|
|
147
|
-
# Reified equality:
|
|
148
|
-
# in_region[n,k] => cn == dist[n,k] + 1
|
|
149
|
-
m.Add(cn == self.dist[(n, k)] + 1).OnlyEnforceIf(both_in_region_k)
|
|
150
|
-
# not in_region[n,k] => cn == INF
|
|
151
|
-
m.Add(cn == self.INF).OnlyEnforceIf(both_in_region_k.Not())
|
|
152
|
-
|
|
153
|
-
# Only enforce the min equation when p is actually in region k (and not the root position).
|
|
154
|
-
# If p ∉ region k, dp is already INF via the earlier rule.
|
|
155
|
-
if cand_vars:
|
|
156
|
-
m.AddMinEquality(self.dist[(p, k)], cand_vars)
|
|
157
|
-
for p in get_all_pos(self.V, self.H):
|
|
158
|
-
# every cell must have 1 dist != INF (lets just do at least 1 dist != INF)
|
|
159
|
-
not_infs = []
|
|
160
|
-
for k in range(self.N):
|
|
161
|
-
not_inf = m.NewBoolVar(f'not_inf[{self.idx_of[p]},{k}]')
|
|
162
|
-
m.Add(self.dist[(p, k)] != self.INF).OnlyEnforceIf(not_inf)
|
|
163
|
-
m.Add(self.dist[(p, k)] == self.INF).OnlyEnforceIf(not_inf.Not())
|
|
164
|
-
not_infs.append(not_inf)
|
|
165
|
-
m.AddBoolOr(not_infs)
|
|
166
|
-
|
|
167
|
-
# Region sizes
|
|
168
|
-
for k in range(self.N):
|
|
169
|
-
root_pos = self.pos_of[k]
|
|
170
|
-
members = [self.in_region[(p, k)] for p in get_all_pos(self.V, self.H)]
|
|
171
|
-
size_k = m.NewIntVar(0, self.N, f'size[{k}]')
|
|
172
|
-
m.Add(size_k == sum(members))
|
|
173
|
-
# Active root -> size equals its value
|
|
174
|
-
m.Add(size_k == self.val[root_pos]).OnlyEnforceIf(self.is_root[root_pos])
|
|
175
|
-
# Inactive root id -> size 0
|
|
176
|
-
m.Add(size_k == 0).OnlyEnforceIf(self.is_root[root_pos].Not())
|
|
177
|
-
|
|
178
|
-
def solve_and_print(self, verbose: bool = True):
|
|
179
|
-
def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
180
|
-
assignment: dict[Pos, int] = {}
|
|
181
|
-
for p in get_all_pos(board.V, board.H):
|
|
182
|
-
assignment[p] = solver.Value(board.val[p])
|
|
183
|
-
return SingleSolution(assignment=assignment)
|
|
184
|
-
def callback(single_res: SingleSolution):
|
|
185
|
-
print("Solution found")
|
|
186
|
-
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
187
|
-
for pos in get_all_pos(self.V, self.H):
|
|
188
|
-
c = get_char(self.board, pos)
|
|
189
|
-
c = single_res.assignment[pos]
|
|
190
|
-
set_char(res, pos, c)
|
|
191
|
-
print(res)
|
|
192
|
-
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, polyominoes, in_bounds, get_next_pos, Direction
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ShapeOnBoard:
|
|
12
|
+
is_active: cp_model.IntVar
|
|
13
|
+
N: int
|
|
14
|
+
body: set[Pos]
|
|
15
|
+
disallow_same_shape: set[Pos]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Board:
|
|
19
|
+
def __init__(self, board: np.ndarray, digits = (1, 2, 3, 4, 5, 6, 7, 8, 9)):
|
|
20
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
21
|
+
self.board = board
|
|
22
|
+
self.V, self.H = board.shape
|
|
23
|
+
assert all((c == ' ') or (str(c).isdecimal() and 0 <= int(c) <= 9) for c in np.nditer(board)), "board must contain space or digits 0..9"
|
|
24
|
+
self.digits = digits
|
|
25
|
+
self.polyominoes = {d: polyominoes(d) for d in self.digits}
|
|
26
|
+
# len_shapes = sum(len(shapes) for shapes in self.polyominoes.values())
|
|
27
|
+
# print(f'total number of shapes: {len_shapes}')
|
|
28
|
+
|
|
29
|
+
self.model = cp_model.CpModel()
|
|
30
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
31
|
+
self.digit_to_shapes = {d: [] for d in self.digits}
|
|
32
|
+
self.body_loc_to_shape = {(d,p): [] for d in self.digits for p in get_all_pos(self.V, self.H)}
|
|
33
|
+
self.forced_pos: dict[Pos, int] = {}
|
|
34
|
+
|
|
35
|
+
self.create_vars()
|
|
36
|
+
self.constrain_numbers_on_board()
|
|
37
|
+
self.init_polyominoes_on_board()
|
|
38
|
+
self.add_all_constraints()
|
|
39
|
+
|
|
40
|
+
def create_vars(self):
|
|
41
|
+
for pos in get_all_pos(self.V, self.H):
|
|
42
|
+
for d in self.digits:
|
|
43
|
+
self.model_vars[(d,pos)] = self.model.NewBoolVar(f'{d}:{pos}')
|
|
44
|
+
|
|
45
|
+
def constrain_numbers_on_board(self):
|
|
46
|
+
# force numbers already on the board
|
|
47
|
+
for pos in get_all_pos(self.V, self.H):
|
|
48
|
+
c = get_char(self.board, pos)
|
|
49
|
+
if c.isdecimal():
|
|
50
|
+
self.model.Add(self.model_vars[(int(c),pos)] == 1)
|
|
51
|
+
self.forced_pos[pos] = int(c)
|
|
52
|
+
|
|
53
|
+
def init_polyominoes_on_board(self):
|
|
54
|
+
# total_count = 0
|
|
55
|
+
for d in self.digits: # all digits
|
|
56
|
+
digit_count = 0
|
|
57
|
+
for pos in get_all_pos(self.V, self.H): # translate by shape
|
|
58
|
+
for shape in self.polyominoes[d]: # all shapes of d digits
|
|
59
|
+
body = {pos + p for p in shape}
|
|
60
|
+
if any(not in_bounds(p, self.V, self.H) for p in body):
|
|
61
|
+
continue
|
|
62
|
+
if any(p in self.forced_pos and self.forced_pos[p] != d for p in body): # part of this shape's body is already forced to a different digit, skip
|
|
63
|
+
continue
|
|
64
|
+
disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
|
|
65
|
+
disallow_same_shape = {p for p in disallow_same_shape if p not in body and in_bounds(p, self.V, self.H)}
|
|
66
|
+
shape_on_board = ShapeOnBoard(
|
|
67
|
+
is_active=self.model.NewBoolVar(f'd{d}:{digit_count}:{pos}:is_active'),
|
|
68
|
+
N=d,
|
|
69
|
+
body=body,
|
|
70
|
+
disallow_same_shape=disallow_same_shape,
|
|
71
|
+
)
|
|
72
|
+
self.digit_to_shapes[d].append(shape_on_board)
|
|
73
|
+
for p in body:
|
|
74
|
+
self.body_loc_to_shape[(d,p)].append(shape_on_board)
|
|
75
|
+
digit_count += 1
|
|
76
|
+
# total_count += 1
|
|
77
|
+
# if total_count % 1000 == 0:
|
|
78
|
+
# print(f'{total_count} shapes on board')
|
|
79
|
+
# print(f'total number of shapes on board: {total_count}')
|
|
80
|
+
|
|
81
|
+
def add_all_constraints(self):
|
|
82
|
+
for pos in get_all_pos(self.V, self.H):
|
|
83
|
+
# exactly one digit is active at every position
|
|
84
|
+
self.model.AddExactlyOne(self.model_vars[(d,pos)] for d in self.digits)
|
|
85
|
+
# exactly one shape is active at that position
|
|
86
|
+
self.model.AddExactlyOne(s.is_active for d in self.digits for s in self.body_loc_to_shape[(d,pos)])
|
|
87
|
+
# if a shape is active then all its body is active
|
|
88
|
+
|
|
89
|
+
for s_list in self.body_loc_to_shape.values():
|
|
90
|
+
for s in s_list:
|
|
91
|
+
for p in s.body:
|
|
92
|
+
self.model.Add(self.model_vars[(s.N,p)] == 1).OnlyEnforceIf(s.is_active)
|
|
93
|
+
|
|
94
|
+
# same shape cannot touch
|
|
95
|
+
for d, s_list in self.digit_to_shapes.items():
|
|
96
|
+
for s in s_list:
|
|
97
|
+
for disallow_pos in s.disallow_same_shape:
|
|
98
|
+
self.model.Add(self.model_vars[(d,disallow_pos)] == 0).OnlyEnforceIf(s.is_active)
|
|
99
|
+
|
|
100
|
+
def solve_and_print(self, verbose: bool = True):
|
|
101
|
+
def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
102
|
+
assignment: dict[Pos, int] = {}
|
|
103
|
+
for pos in get_all_pos(self.V, self.H):
|
|
104
|
+
for d in self.digits:
|
|
105
|
+
if solver.Value(self.model_vars[(d,pos)]) == 1:
|
|
106
|
+
assignment[pos] = d
|
|
107
|
+
break
|
|
108
|
+
return SingleSolution(assignment=assignment)
|
|
109
|
+
def callback(single_res: SingleSolution):
|
|
110
|
+
print("Solution found")
|
|
111
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
112
|
+
for pos in get_all_pos(self.V, self.H):
|
|
113
|
+
c = get_char(self.board, pos)
|
|
114
|
+
c = single_res.assignment[pos]
|
|
115
|
+
set_char(res, pos, c)
|
|
116
|
+
print(res)
|
|
117
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|