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.

Files changed (43) hide show
  1. {multi_puzzle_solver-1.0.3.dist-info → multi_puzzle_solver-1.0.6.dist-info}/METADATA +1024 -387
  2. multi_puzzle_solver-1.0.6.dist-info/RECORD +73 -0
  3. puzzle_solver/__init__.py +7 -1
  4. puzzle_solver/core/utils.py +17 -1
  5. puzzle_solver/core/utils_visualizer.py +257 -201
  6. puzzle_solver/puzzles/aquarium/aquarium.py +8 -23
  7. puzzle_solver/puzzles/battleships/battleships.py +39 -53
  8. puzzle_solver/puzzles/binairo/binairo.py +2 -2
  9. puzzle_solver/puzzles/black_box/black_box.py +6 -70
  10. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  11. puzzle_solver/puzzles/filling/filling.py +11 -34
  12. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +0 -1
  13. puzzle_solver/puzzles/galaxies/galaxies.py +110 -110
  14. puzzle_solver/puzzles/heyawake/heyawake.py +6 -2
  15. puzzle_solver/puzzles/kakurasu/kakurasu.py +5 -13
  16. puzzle_solver/puzzles/kakuro/kakuro.py +6 -2
  17. puzzle_solver/puzzles/lits/lits.py +6 -4
  18. puzzle_solver/puzzles/mosaic/mosaic.py +8 -18
  19. puzzle_solver/puzzles/nonograms/nonograms.py +80 -85
  20. puzzle_solver/puzzles/nonograms/nonograms_colored.py +221 -0
  21. puzzle_solver/puzzles/norinori/norinori.py +5 -10
  22. puzzle_solver/puzzles/nurikabe/nurikabe.py +6 -2
  23. puzzle_solver/puzzles/palisade/palisade.py +4 -3
  24. puzzle_solver/puzzles/pearl/pearl.py +15 -27
  25. puzzle_solver/puzzles/pipes/pipes.py +2 -1
  26. puzzle_solver/puzzles/range/range.py +19 -55
  27. puzzle_solver/puzzles/rectangles/rectangles.py +4 -2
  28. puzzle_solver/puzzles/shingoki/shingoki.py +2 -2
  29. puzzle_solver/puzzles/singles/singles.py +6 -2
  30. puzzle_solver/puzzles/slant/slant.py +13 -19
  31. puzzle_solver/puzzles/slitherlink/slitherlink.py +2 -2
  32. puzzle_solver/puzzles/star_battle/star_battle.py +13 -7
  33. puzzle_solver/puzzles/stitches/stitches.py +8 -21
  34. puzzle_solver/puzzles/sudoku/sudoku.py +5 -11
  35. puzzle_solver/puzzles/tapa/tapa.py +6 -2
  36. puzzle_solver/puzzles/tents/tents.py +50 -80
  37. puzzle_solver/puzzles/tracks/tracks.py +19 -66
  38. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  39. puzzle_solver/puzzles/unruly/unruly.py +17 -49
  40. puzzle_solver/puzzles/yin_yang/yin_yang.py +3 -10
  41. multi_puzzle_solver-1.0.3.dist-info/RECORD +0 -70
  42. {multi_puzzle_solver-1.0.3.dist-info → multi_puzzle_solver-1.0.6.dist-info}/WHEEL +0 -0
  43. {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
- def parse_numpy(galaxies: np.ndarray) -> list[tuple[Pos, ...]]:
12
- result = defaultdict(list)
13
- for pos, arr_id in np.ndenumerate(galaxies):
14
- if not arr_id.strip():
15
- continue
16
- result[arr_id].append(get_pos(x=pos[1], y=pos[0]))
17
- return [positions for _, positions in sorted(result.items(), key=lambda x: x[0])]
18
-
19
-
20
- class Board:
21
- def __init__(self, galaxies: Union[list[tuple[Pos, ...]], np.ndarray], V: int = None, H: int = None):
22
- if isinstance(galaxies, np.ndarray):
23
- V, H = galaxies.shape
24
- galaxies = parse_numpy(galaxies)
25
- else:
26
- assert V is not None and H is not None, 'V and H must be provided if galaxies is not a numpy array'
27
- assert V >= 1 and H >= 1, 'V and H must be at least 1'
28
- assert all(isinstance(galaxy, Iterable) for galaxy in galaxies), 'galaxies must be a list of Iterables'
29
- assert all(len(galaxy) in [1, 2, 4] for galaxy in galaxies), 'each galaxy must be exactly 1, 2, or 4 positions'
30
- self.V = V
31
- self.H = H
32
- self.n_galaxies = len(galaxies)
33
- self.galaxies = galaxies
34
- self.prelocated_positions: set[Pos] = {pos: i for i, galaxy in enumerate(galaxies) for pos in galaxy}
35
-
36
- self.model = cp_model.CpModel()
37
- self.pos_to_galaxy: dict[Pos, dict[int, cp_model.IntVar]] = {p: {} for p in get_all_pos(V, H)} # each position can be part of exactly one out of many possible galaxies
38
- self.allocated_pairs: set[tuple[Pos, Pos]] = set() # each pair is allocated to exactly one galaxy
39
-
40
- self.create_vars()
41
- self.add_all_constraints()
42
-
43
- def create_vars(self):
44
- for i in range(self.n_galaxies):
45
- galaxy = self.galaxies[i]
46
- if len(galaxy) == 1:
47
- p1, p2 = galaxy[0], galaxy[0]
48
- elif len(galaxy) == 2:
49
- p1, p2 = galaxy[0], galaxy[1]
50
- elif len(galaxy) == 4:
51
- p1, p2 = galaxy[0], galaxy[3] # [1] and [2] will be linked with symmetry
52
- self.expand_galaxy(p1, p2, i)
53
-
54
- def expand_galaxy(self, p1: Pos, p2: Pos, galaxy_idx: int):
55
- if (p1, p2) in self.allocated_pairs or (p2, p1) in self.allocated_pairs:
56
- return
57
- if p1 in self.prelocated_positions and self.prelocated_positions[p1] != galaxy_idx:
58
- return
59
- if p2 in self.prelocated_positions and self.prelocated_positions[p2] != galaxy_idx:
60
- return
61
- if not in_bounds(p1, self.V, self.H) or not in_bounds(p2, self.V, self.H):
62
- return
63
- self.bind_pair(p1, p2, galaxy_idx)
64
- # symmetrically expand the galaxy until illegal position is hit
65
- for direction in [Direction.RIGHT, Direction.UP, Direction.DOWN, Direction.LEFT]:
66
- symmetrical_direction = get_opposite_direction(direction)
67
- new_p1 = get_next_pos(p1, direction)
68
- new_p2 = get_next_pos(p2, symmetrical_direction)
69
- self.expand_galaxy(new_p1, new_p2, galaxy_idx)
70
-
71
- def bind_pair(self, p1: Pos, p2: Pos, galaxy_idx: int):
72
- assert galaxy_idx not in self.pos_to_galaxy[p1], f'p1={p1} already has galaxy idx={galaxy_idx}'
73
- assert galaxy_idx not in self.pos_to_galaxy[p2], f'p2={p2} already has galaxy idx={galaxy_idx}'
74
- self.allocated_pairs.add((p1, p2))
75
- v1 = self.model.NewBoolVar(f'{p1}:{galaxy_idx}')
76
- v2 = self.model.NewBoolVar(f'{p2}:{galaxy_idx}')
77
- self.model.Add(v1 == v2)
78
- self.pos_to_galaxy[p1][galaxy_idx] = v1
79
- self.pos_to_galaxy[p2][galaxy_idx] = v2
80
-
81
- def add_all_constraints(self):
82
- galaxy_vars = {}
83
- for pos in get_all_pos(self.V, self.H):
84
- pos_vars = list(self.pos_to_galaxy[pos].values())
85
- self.model.AddExactlyOne(pos_vars)
86
- for galaxy_idx, v in self.pos_to_galaxy[pos].items():
87
- galaxy_vars.setdefault(galaxy_idx, {})[pos] = v
88
- for pos_vars in galaxy_vars.values():
89
- force_connected_component(self.model, pos_vars)
90
-
91
-
92
- def solve_and_print(self, verbose: bool = True):
93
- def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
94
- assignment: dict[Pos, int] = {}
95
- for pos, galaxy_vars in board.pos_to_galaxy.items():
96
- for galaxy_idx, var in galaxy_vars.items(): # every pos is part of exactly one galaxy
97
- if solver.Value(var) == 1:
98
- assignment[pos] = galaxy_idx
99
- break
100
- return SingleSolution(assignment=assignment)
101
- def callback(single_res: SingleSolution):
102
- print("Solution found")
103
- res = np.full((self.V, self.H), ' ', dtype=object)
104
- for pos in get_all_pos(self.V, self.H):
105
- set_char(res, pos, str(single_res.assignment[pos]).zfill(2))
106
- print('[')
107
- for row in range(self.V):
108
- print(' ', res[row].tolist(), end=',\n')
109
- print(']')
110
- return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
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 render_shaded_grid
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(render_shaded_grid(self.V, self.H, lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, empty_text=lambda r, c: self.region_to_clue.get(int(self.board[r, c]), ' ')))
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, set_char, get_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: dict[Pos, int] = {}
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
- res = np.full((self.V, self.H), ' ', dtype=object)
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 render_shaded_grid
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(render_shaded_grid(self.V, self.H, is_shaded=lambda r, c: self.board[r, c] == '#', empty_text=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)])))
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=str)
132
+ res = np.full((self.V, self.H), ' ', dtype=object)
132
133
  for pos, val in single_res.assignment.items():
133
- c = 'X' if val == 1 else ' '
134
- set_char(res, pos, c)
135
- print('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
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, set_char, get_char, get_neighbors8
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.N = board.shape[0]
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.N):
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.N):
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
- neighbour_vars = [self.model_vars[p] for p in get_neighbors8(pos, self.N, include_self=True)]
32
- self.model.Add(lxp.sum(neighbour_vars) == int(c))
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: dict[Pos, int] = {}
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
- res = np.full((self.N, self.N), ' ', dtype=object)
43
- for pos in get_all_pos(self.N):
44
- c = get_char(self.board, pos)
45
- c = 'B' if single_res.assignment[pos] == 1 else ' '
46
- set_char(res, pos, c)
47
- print(res)
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, set_char, get_row_pos, get_col_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.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_side_{i}')
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.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_top_{i}')
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: dict[Pos, int] = {}
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
- res = np.full((self.V, self.H), ' ', dtype=object)
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)