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
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
from collections import defaultdict
|
|
2
|
-
from typing import Iterable, Union
|
|
3
|
-
|
|
4
|
-
import numpy as np
|
|
5
|
-
from ortools.sat.python import cp_model
|
|
6
|
-
|
|
7
|
-
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, Direction, get_next_pos, in_bounds, get_opposite_direction, get_pos
|
|
8
|
-
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
assert
|
|
29
|
-
assert all(
|
|
30
|
-
|
|
31
|
-
self.
|
|
32
|
-
self.
|
|
33
|
-
self.
|
|
34
|
-
self.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
self.
|
|
38
|
-
self.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
self.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
assert galaxy_idx not in self.pos_to_galaxy[
|
|
74
|
-
self.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
self.model.
|
|
78
|
-
self.
|
|
79
|
-
self.pos_to_galaxy[
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
self.
|
|
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
|
-
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Iterable, Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, Direction, get_next_pos, in_bounds, get_opposite_direction, get_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_numpy(galaxies: np.ndarray) -> list[tuple[Pos, ...]]:
|
|
13
|
+
result = defaultdict(list)
|
|
14
|
+
for pos, arr_id in np.ndenumerate(galaxies):
|
|
15
|
+
if not arr_id.strip():
|
|
16
|
+
continue
|
|
17
|
+
result[arr_id].append(get_pos(x=pos[1], y=pos[0]))
|
|
18
|
+
return [positions for _, positions in sorted(result.items(), key=lambda x: x[0])]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Board:
|
|
22
|
+
def __init__(self, galaxies: Union[list[tuple[Pos, ...]], np.ndarray], V: int = None, H: int = None):
|
|
23
|
+
if isinstance(galaxies, np.ndarray):
|
|
24
|
+
V, H = galaxies.shape
|
|
25
|
+
galaxies = parse_numpy(galaxies)
|
|
26
|
+
else:
|
|
27
|
+
assert V is not None and H is not None, 'V and H must be provided if galaxies is not a numpy array'
|
|
28
|
+
assert V >= 1 and H >= 1, 'V and H must be at least 1'
|
|
29
|
+
assert all(isinstance(galaxy, Iterable) for galaxy in galaxies), 'galaxies must be a list of Iterables'
|
|
30
|
+
assert all(len(galaxy) in [1, 2, 4] for galaxy in galaxies), 'each galaxy must be exactly 1, 2, or 4 positions'
|
|
31
|
+
self.V = V
|
|
32
|
+
self.H = H
|
|
33
|
+
self.n_galaxies = len(galaxies)
|
|
34
|
+
self.galaxies = galaxies
|
|
35
|
+
self.prelocated_positions: set[Pos] = {pos: i for i, galaxy in enumerate(galaxies) for pos in galaxy}
|
|
36
|
+
|
|
37
|
+
self.model = cp_model.CpModel()
|
|
38
|
+
self.pos_to_galaxy: dict[Pos, dict[int, cp_model.IntVar]] = {p: {} for p in get_all_pos(V, H)} # each position can be part of exactly one out of many possible galaxies
|
|
39
|
+
self.allocated_pairs: set[tuple[Pos, Pos]] = set() # each pair is allocated to exactly one galaxy
|
|
40
|
+
|
|
41
|
+
self.create_vars()
|
|
42
|
+
self.add_all_constraints()
|
|
43
|
+
|
|
44
|
+
def create_vars(self):
|
|
45
|
+
for i in range(self.n_galaxies):
|
|
46
|
+
galaxy = self.galaxies[i]
|
|
47
|
+
if len(galaxy) == 1:
|
|
48
|
+
p1, p2 = galaxy[0], galaxy[0]
|
|
49
|
+
elif len(galaxy) == 2:
|
|
50
|
+
p1, p2 = galaxy[0], galaxy[1]
|
|
51
|
+
elif len(galaxy) == 4:
|
|
52
|
+
p1, p2 = galaxy[0], galaxy[3] # [1] and [2] will be linked with symmetry
|
|
53
|
+
self.expand_galaxy(p1, p2, i)
|
|
54
|
+
|
|
55
|
+
def expand_galaxy(self, p1: Pos, p2: Pos, galaxy_idx: int):
|
|
56
|
+
if (p1, p2) in self.allocated_pairs or (p2, p1) in self.allocated_pairs:
|
|
57
|
+
return
|
|
58
|
+
if p1 in self.prelocated_positions and self.prelocated_positions[p1] != galaxy_idx:
|
|
59
|
+
return
|
|
60
|
+
if p2 in self.prelocated_positions and self.prelocated_positions[p2] != galaxy_idx:
|
|
61
|
+
return
|
|
62
|
+
if not in_bounds(p1, self.V, self.H) or not in_bounds(p2, self.V, self.H):
|
|
63
|
+
return
|
|
64
|
+
self.bind_pair(p1, p2, galaxy_idx)
|
|
65
|
+
# symmetrically expand the galaxy until illegal position is hit
|
|
66
|
+
for direction in [Direction.RIGHT, Direction.UP, Direction.DOWN, Direction.LEFT]:
|
|
67
|
+
symmetrical_direction = get_opposite_direction(direction)
|
|
68
|
+
new_p1 = get_next_pos(p1, direction)
|
|
69
|
+
new_p2 = get_next_pos(p2, symmetrical_direction)
|
|
70
|
+
self.expand_galaxy(new_p1, new_p2, galaxy_idx)
|
|
71
|
+
|
|
72
|
+
def bind_pair(self, p1: Pos, p2: Pos, galaxy_idx: int):
|
|
73
|
+
assert galaxy_idx not in self.pos_to_galaxy[p1], f'p1={p1} already has galaxy idx={galaxy_idx}'
|
|
74
|
+
assert galaxy_idx not in self.pos_to_galaxy[p2], f'p2={p2} already has galaxy idx={galaxy_idx}'
|
|
75
|
+
self.allocated_pairs.add((p1, p2))
|
|
76
|
+
v1 = self.model.NewBoolVar(f'{p1}:{galaxy_idx}')
|
|
77
|
+
v2 = self.model.NewBoolVar(f'{p2}:{galaxy_idx}')
|
|
78
|
+
self.model.Add(v1 == v2)
|
|
79
|
+
self.pos_to_galaxy[p1][galaxy_idx] = v1
|
|
80
|
+
self.pos_to_galaxy[p2][galaxy_idx] = v2
|
|
81
|
+
|
|
82
|
+
def add_all_constraints(self):
|
|
83
|
+
galaxy_vars = {}
|
|
84
|
+
for pos in get_all_pos(self.V, self.H):
|
|
85
|
+
pos_vars = list(self.pos_to_galaxy[pos].values())
|
|
86
|
+
self.model.AddExactlyOne(pos_vars)
|
|
87
|
+
for galaxy_idx, v in self.pos_to_galaxy[pos].items():
|
|
88
|
+
galaxy_vars.setdefault(galaxy_idx, {})[pos] = v
|
|
89
|
+
for pos_vars in galaxy_vars.values():
|
|
90
|
+
force_connected_component(self.model, pos_vars)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def solve_and_print(self, verbose: bool = True):
|
|
94
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
95
|
+
assignment: dict[Pos, int] = {}
|
|
96
|
+
for pos, galaxy_vars in board.pos_to_galaxy.items():
|
|
97
|
+
for galaxy_idx, var in galaxy_vars.items(): # every pos is part of exactly one galaxy
|
|
98
|
+
if solver.Value(var) == 1:
|
|
99
|
+
assignment[pos] = galaxy_idx
|
|
100
|
+
break
|
|
101
|
+
return SingleSolution(assignment=assignment)
|
|
102
|
+
def callback(single_res: SingleSolution):
|
|
103
|
+
print("Solution found")
|
|
104
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
105
|
+
for pos in get_all_pos(self.V, self.H):
|
|
106
|
+
set_char(res, pos, single_res.assignment[pos])
|
|
107
|
+
print(combined_function(self.V, self.H,
|
|
108
|
+
cell_flags=id_board_to_wall_fn(res),
|
|
109
|
+
center_char=lambda r, c: '.' if (Pos(x=c, y=r) in self.prelocated_positions) else ' '))
|
|
110
|
+
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_neighbors4, get_pos, get_char
|
|
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
|
def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
|
|
@@ -90,5 +90,9 @@ class Board:
|
|
|
90
90
|
# c = 'B' if single_res.assignment[pos] == 1 else ' '
|
|
91
91
|
# set_char(res, pos, c)
|
|
92
92
|
# print(res)
|
|
93
|
-
print(
|
|
93
|
+
print(combined_function(self.V, self.H,
|
|
94
|
+
is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
|
|
95
|
+
center_char=lambda r, c: self.region_to_clue.get(int(self.board[r, c]), ''),
|
|
96
|
+
text_on_shaded_cells=False
|
|
97
|
+
))
|
|
94
98
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=1)
|
|
@@ -1,22 +1,21 @@
|
|
|
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
|
|
5
5
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class Board:
|
|
9
10
|
def __init__(self, side: np.array, bottom: np.array):
|
|
10
11
|
assert side.ndim == 1, f'side must be 1d, got {side.ndim}'
|
|
11
|
-
self.V = side.shape[0]
|
|
12
12
|
assert bottom.ndim == 1, f'bottom must be 1d, got {bottom.ndim}'
|
|
13
|
+
self.V = side.shape[0]
|
|
13
14
|
self.H = bottom.shape[0]
|
|
14
15
|
self.side = side
|
|
15
16
|
self.bottom = bottom
|
|
16
|
-
|
|
17
17
|
self.model = cp_model.CpModel()
|
|
18
18
|
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
19
|
-
|
|
20
19
|
self.create_vars()
|
|
21
20
|
self.add_all_constraints()
|
|
22
21
|
|
|
@@ -32,15 +31,8 @@ class Board:
|
|
|
32
31
|
|
|
33
32
|
def solve_and_print(self, verbose: bool = True):
|
|
34
33
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
35
|
-
assignment:
|
|
36
|
-
for pos, var in board.model_vars.items():
|
|
37
|
-
assignment[pos] = solver.value(var)
|
|
38
|
-
return SingleSolution(assignment=assignment)
|
|
34
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
39
35
|
def callback(single_res: SingleSolution):
|
|
40
36
|
print("Solution found")
|
|
41
|
-
|
|
42
|
-
for pos in get_all_pos(self.V, self.H):
|
|
43
|
-
c = 'X' if single_res.assignment[pos] else ' '
|
|
44
|
-
set_char(res, pos, c)
|
|
45
|
-
print(res)
|
|
37
|
+
print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
|
|
46
38
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -6,7 +6,7 @@ from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
|
6
6
|
|
|
7
7
|
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, get_char
|
|
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
|
class Board:
|
|
@@ -73,5 +73,9 @@ class Board:
|
|
|
73
73
|
return SingleSolution(assignment=assignment)
|
|
74
74
|
def callback(single_res: SingleSolution):
|
|
75
75
|
print("Solution found")
|
|
76
|
-
print(
|
|
76
|
+
print(combined_function(self.V, self.H,
|
|
77
|
+
is_shaded=lambda r, c: self.board[r, c] == '#',
|
|
78
|
+
center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]),
|
|
79
|
+
text_on_shaded_cells=False
|
|
80
|
+
))
|
|
77
81
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -6,6 +6,7 @@ import numpy as np
|
|
|
6
6
|
|
|
7
7
|
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, in_bounds, Direction, get_next_pos, polyominoes_with_shape_id
|
|
8
8
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
# a shape on the 2d board is just a set of positions
|
|
@@ -128,9 +129,10 @@ class Board:
|
|
|
128
129
|
return SingleSolution(assignment=assignment)
|
|
129
130
|
def callback(single_res: SingleSolution):
|
|
130
131
|
print("Solution found")
|
|
131
|
-
res = np.full((self.V, self.H), ' ', dtype=
|
|
132
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
132
133
|
for pos, val in single_res.assignment.items():
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
set_char(res, pos, '▒▒▒' if val == 1 else ' ')
|
|
135
|
+
print(combined_function(self.V, self.H,
|
|
136
|
+
cell_flags=id_board_to_wall_fn(self.board),
|
|
137
|
+
center_char=lambda r, c: res[r][c]))
|
|
136
138
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
|
|
@@ -2,47 +2,37 @@ import numpy as np
|
|
|
2
2
|
from ortools.sat.python import cp_model
|
|
3
3
|
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
4
|
|
|
5
|
-
from puzzle_solver.core.utils import Pos, get_all_pos,
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors8, get_pos
|
|
6
6
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class Board:
|
|
10
11
|
def __init__(self, board: np.array):
|
|
11
12
|
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
13
|
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
14
14
|
self.board = board
|
|
15
|
-
self.
|
|
15
|
+
self.V, self.H = board.shape
|
|
16
16
|
self.model = cp_model.CpModel()
|
|
17
17
|
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
18
|
-
|
|
19
18
|
self.create_vars()
|
|
20
19
|
self.add_all_constraints()
|
|
21
20
|
|
|
22
21
|
def create_vars(self):
|
|
23
|
-
for pos in get_all_pos(self.
|
|
22
|
+
for pos in get_all_pos(self.V, self.H):
|
|
24
23
|
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
25
24
|
|
|
26
25
|
def add_all_constraints(self):
|
|
27
|
-
for pos in get_all_pos(self.
|
|
26
|
+
for pos in get_all_pos(self.V, self.H):
|
|
28
27
|
c = get_char(self.board, pos)
|
|
29
28
|
if not str(c).isdecimal():
|
|
30
29
|
continue
|
|
31
|
-
|
|
32
|
-
self.model.Add(lxp.sum(neighbour_vars) == int(c))
|
|
30
|
+
self.model.Add(lxp.Sum([self.model_vars[n] for n in get_neighbors8(pos, self.V, self.H, include_self=True)]) == int(c))
|
|
33
31
|
|
|
34
32
|
def solve_and_print(self, verbose: bool = True):
|
|
35
33
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
36
|
-
assignment:
|
|
37
|
-
for pos, var in board.model_vars.items():
|
|
38
|
-
assignment[pos] = solver.Value(var)
|
|
39
|
-
return SingleSolution(assignment=assignment)
|
|
34
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
40
35
|
def callback(single_res: SingleSolution):
|
|
41
36
|
print("Solution found")
|
|
42
|
-
|
|
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)
|
|
37
|
+
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: str(self.board[r, c]), text_on_shaded_cells=False))
|
|
48
38
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -1,8 +1,82 @@
|
|
|
1
|
-
import numpy as np
|
|
2
1
|
from ortools.sat.python import cp_model
|
|
3
2
|
|
|
4
|
-
from puzzle_solver.core.utils import Pos, get_all_pos,
|
|
3
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_row_pos, get_col_pos
|
|
5
4
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
5
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def constrain_nonogram_sequence(model: cp_model.CpModel, clues: list[int], current_sequence: list[cp_model.IntVar], ns: str):
|
|
9
|
+
"""
|
|
10
|
+
Constrain a binary sequence (current_sequence) to match the nonogram clues in clues.
|
|
11
|
+
|
|
12
|
+
clues: e.g., [3,1] means: a run of 3 ones, >=1 zero, then a run of 1 one.
|
|
13
|
+
current_sequence: list of IntVar in {0,1}.
|
|
14
|
+
extra_vars: dict for storing helper vars safely across multiple calls.
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- Create start position s_i for each run i.
|
|
18
|
+
- Enforce order and >=1 separation between runs.
|
|
19
|
+
- Link each cell j to exactly one run interval (or none) via coverage booleans.
|
|
20
|
+
- Force sum of ones to equal sum(clues).
|
|
21
|
+
"""
|
|
22
|
+
L = len(current_sequence)
|
|
23
|
+
|
|
24
|
+
# not needed but useful for debugging: any clue longer than the line ⇒ unsat.
|
|
25
|
+
if sum(clues) + len(clues) - 1 > L:
|
|
26
|
+
print(f"Infeasible: clue {clues} longer than line length {L} for {ns}")
|
|
27
|
+
model.Add(0 == 1)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
result = {}
|
|
31
|
+
# Start variables for each run. This is the most critical variable for the problem.
|
|
32
|
+
starts = []
|
|
33
|
+
result[f"{ns}_starts"] = starts
|
|
34
|
+
for i in range(len(clues)):
|
|
35
|
+
s = model.NewIntVar(0, L, f"{ns}_s[{i}]")
|
|
36
|
+
starts.append(s)
|
|
37
|
+
# Enforce order and >=1 blank between consecutive runs.
|
|
38
|
+
for i in range(len(clues) - 1):
|
|
39
|
+
model.Add(starts[i + 1] >= starts[i] + clues[i] + 1)
|
|
40
|
+
# enforce that every run is fully contained in the board
|
|
41
|
+
for i in range(len(clues)):
|
|
42
|
+
model.Add(starts[i] + clues[i] <= L)
|
|
43
|
+
|
|
44
|
+
# For each cell j, create booleans cover[i][j] that indicate
|
|
45
|
+
# whether run i covers cell j: (starts[i] <= j) AND (j < starts[i] + clues[i])
|
|
46
|
+
cover = [[None] * L for _ in range(len(clues))]
|
|
47
|
+
list_b_le = [[None] * L for _ in range(len(clues))]
|
|
48
|
+
list_b_lt_end = [[None] * L for _ in range(len(clues))]
|
|
49
|
+
result[f"{ns}_cover"] = cover
|
|
50
|
+
result[f"{ns}_list_b_le"] = list_b_le
|
|
51
|
+
result[f"{ns}_list_b_lt_end"] = list_b_lt_end
|
|
52
|
+
|
|
53
|
+
for i, c in enumerate(clues):
|
|
54
|
+
s_i = starts[i]
|
|
55
|
+
for j in range(L):
|
|
56
|
+
# b_le: s_i <= j [is start[i] <= j]
|
|
57
|
+
b_le = model.NewBoolVar(f"{ns}_le[{i},{j}]")
|
|
58
|
+
model.Add(s_i <= j).OnlyEnforceIf(b_le)
|
|
59
|
+
model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
|
|
60
|
+
|
|
61
|
+
# b_lt_end: j < s_i + c ⇔ s_i + c - 1 >= j [is start[i] + clues[i] - 1 (aka end[i]) >= j]
|
|
62
|
+
b_lt_end = model.NewBoolVar(f"{ns}_lt_end[{i},{j}]")
|
|
63
|
+
end_expr = s_i + c - 1
|
|
64
|
+
model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
|
|
65
|
+
model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not()) # (s_i + c - 1) < j
|
|
66
|
+
|
|
67
|
+
b_cov = model.NewBoolVar(f"{ns}_cov[{i},{j}]")
|
|
68
|
+
# If covered ⇒ both comparisons true
|
|
69
|
+
model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
|
|
70
|
+
# If both comparisons true ⇒ covered
|
|
71
|
+
model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
|
|
72
|
+
cover[i][j] = b_cov
|
|
73
|
+
list_b_le[i][j] = b_le
|
|
74
|
+
list_b_lt_end[i][j] = b_lt_end
|
|
75
|
+
|
|
76
|
+
# Each cell j is 1 iff it is covered by exactly one run.
|
|
77
|
+
# (Because runs are separated by >=1 zero, these coverage intervals cannot overlap,
|
|
78
|
+
for j in range(L):
|
|
79
|
+
model.Add(sum(cover[i][j] for i in range(len(clues))) == current_sequence[j])
|
|
6
80
|
|
|
7
81
|
|
|
8
82
|
class Board:
|
|
@@ -30,97 +104,18 @@ class Board:
|
|
|
30
104
|
if ground_sequence == -1:
|
|
31
105
|
continue
|
|
32
106
|
current_sequence = [self.model_vars[pos] for pos in get_row_pos(i, self.H)]
|
|
33
|
-
self.
|
|
107
|
+
constrain_nonogram_sequence(self.model, ground_sequence, current_sequence, f'ngm_side_{i}')
|
|
34
108
|
for i in range(self.H):
|
|
35
109
|
ground_sequence = self.top[i]
|
|
36
110
|
if ground_sequence == -1:
|
|
37
111
|
continue
|
|
38
112
|
current_sequence = [self.model_vars[pos] for pos in get_col_pos(i, self.V)]
|
|
39
|
-
self.
|
|
40
|
-
|
|
41
|
-
def constrain_nonogram_sequence(self, clues: list[int], current_sequence: list[cp_model.IntVar], ns: str):
|
|
42
|
-
"""
|
|
43
|
-
Constrain a binary sequence (current_sequence) to match the nonogram clues in clues.
|
|
44
|
-
|
|
45
|
-
clues: e.g., [3,1] means: a run of 3 ones, >=1 zero, then a run of 1 one.
|
|
46
|
-
current_sequence: list of IntVar in {0,1}.
|
|
47
|
-
extra_vars: dict for storing helper vars safely across multiple calls.
|
|
48
|
-
|
|
49
|
-
steps:
|
|
50
|
-
- Create start position s_i for each run i.
|
|
51
|
-
- Enforce order and >=1 separation between runs.
|
|
52
|
-
- Link each cell j to exactly one run interval (or none) via coverage booleans.
|
|
53
|
-
- Force sum of ones to equal sum(clues).
|
|
54
|
-
"""
|
|
55
|
-
L = len(current_sequence)
|
|
56
|
-
|
|
57
|
-
# not needed but useful for debugging: any clue longer than the line ⇒ unsat.
|
|
58
|
-
if sum(clues) + len(clues) - 1 > L:
|
|
59
|
-
print(f"Infeasible: clue {clues} longer than line length {L} for {ns}")
|
|
60
|
-
self.model.Add(0 == 1)
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
# Start variables for each run. This is the most critical variable for the problem.
|
|
64
|
-
starts = []
|
|
65
|
-
self.extra_vars[f"{ns}_starts"] = starts
|
|
66
|
-
for i in range(len(clues)):
|
|
67
|
-
s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
|
|
68
|
-
starts.append(s)
|
|
69
|
-
# Enforce order and >=1 blank between consecutive runs.
|
|
70
|
-
for i in range(len(clues) - 1):
|
|
71
|
-
self.model.Add(starts[i + 1] >= starts[i] + clues[i] + 1)
|
|
72
|
-
# enforce that every run is fully contained in the board
|
|
73
|
-
for i in range(len(clues)):
|
|
74
|
-
self.model.Add(starts[i] + clues[i] <= L)
|
|
75
|
-
|
|
76
|
-
# For each cell j, create booleans cover[i][j] that indicate
|
|
77
|
-
# whether run i covers cell j: (starts[i] <= j) AND (j < starts[i] + clues[i])
|
|
78
|
-
cover = [[None] * L for _ in range(len(clues))]
|
|
79
|
-
list_b_le = [[None] * L for _ in range(len(clues))]
|
|
80
|
-
list_b_lt_end = [[None] * L for _ in range(len(clues))]
|
|
81
|
-
self.extra_vars[f"{ns}_cover"] = cover
|
|
82
|
-
self.extra_vars[f"{ns}_list_b_le"] = list_b_le
|
|
83
|
-
self.extra_vars[f"{ns}_list_b_lt_end"] = list_b_lt_end
|
|
84
|
-
|
|
85
|
-
for i, c in enumerate(clues):
|
|
86
|
-
s_i = starts[i]
|
|
87
|
-
for j in range(L):
|
|
88
|
-
# b_le: s_i <= j [is start[i] <= j]
|
|
89
|
-
b_le = self.model.NewBoolVar(f"{ns}_le[{i},{j}]")
|
|
90
|
-
self.model.Add(s_i <= j).OnlyEnforceIf(b_le)
|
|
91
|
-
self.model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
|
|
92
|
-
|
|
93
|
-
# b_lt_end: j < s_i + c ⇔ s_i + c - 1 >= j [is start[i] + clues[i] - 1 (aka end[i]) >= j]
|
|
94
|
-
b_lt_end = self.model.NewBoolVar(f"{ns}_lt_end[{i},{j}]")
|
|
95
|
-
end_expr = s_i + c - 1
|
|
96
|
-
self.model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
|
|
97
|
-
self.model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not()) # (s_i + c - 1) < j
|
|
98
|
-
|
|
99
|
-
b_cov = self.model.NewBoolVar(f"{ns}_cov[{i},{j}]")
|
|
100
|
-
# If covered ⇒ both comparisons true
|
|
101
|
-
self.model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
|
|
102
|
-
# If both comparisons true ⇒ covered
|
|
103
|
-
self.model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
|
|
104
|
-
cover[i][j] = b_cov
|
|
105
|
-
list_b_le[i][j] = b_le
|
|
106
|
-
list_b_lt_end[i][j] = b_lt_end
|
|
107
|
-
|
|
108
|
-
# Each cell j is 1 iff it is covered by exactly one run.
|
|
109
|
-
# (Because runs are separated by >=1 zero, these coverage intervals cannot overlap,
|
|
110
|
-
for j in range(L):
|
|
111
|
-
self.model.Add(sum(cover[i][j] for i in range(len(clues))) == current_sequence[j])
|
|
113
|
+
constrain_nonogram_sequence(self.model, ground_sequence, current_sequence, f'ngm_top_{i}')
|
|
112
114
|
|
|
113
115
|
def solve_and_print(self, verbose: bool = True):
|
|
114
116
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
115
|
-
assignment:
|
|
116
|
-
for pos, var in board.model_vars.items():
|
|
117
|
-
assignment[pos] = solver.value(var)
|
|
118
|
-
return SingleSolution(assignment=assignment)
|
|
117
|
+
return SingleSolution(assignment={pos: solver.value(var) for pos, var in board.model_vars.items()})
|
|
119
118
|
def callback(single_res: SingleSolution):
|
|
120
119
|
print("Solution found")
|
|
121
|
-
|
|
122
|
-
for pos in get_all_pos(self.V, self.H):
|
|
123
|
-
c = 'B' if single_res.assignment[pos] == 1 else ' '
|
|
124
|
-
set_char(res, pos, c)
|
|
125
|
-
print(res)
|
|
120
|
+
print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
|
|
126
121
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|