multi-puzzle-solver 0.9.31__py3-none-any.whl → 1.0.3__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 (46) hide show
  1. {multi_puzzle_solver-0.9.31.dist-info → multi_puzzle_solver-1.0.3.dist-info}/METADATA +335 -1
  2. multi_puzzle_solver-1.0.3.dist-info/RECORD +70 -0
  3. puzzle_solver/__init__.py +60 -1
  4. puzzle_solver/core/utils_ortools.py +8 -6
  5. puzzle_solver/core/utils_visualizer.py +12 -11
  6. puzzle_solver/puzzles/binairo/binairo.py +4 -4
  7. puzzle_solver/puzzles/black_box/black_box.py +5 -11
  8. puzzle_solver/puzzles/bridges/bridges.py +1 -1
  9. puzzle_solver/puzzles/chess_range/chess_range.py +3 -3
  10. puzzle_solver/puzzles/chess_range/chess_solo.py +1 -1
  11. puzzle_solver/puzzles/filling/filling.py +3 -3
  12. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  13. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +198 -0
  14. puzzle_solver/puzzles/galaxies/galaxies.py +1 -1
  15. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +3 -3
  16. puzzle_solver/puzzles/guess/guess.py +1 -1
  17. puzzle_solver/puzzles/heyawake/heyawake.py +3 -3
  18. puzzle_solver/puzzles/inertia/inertia.py +1 -1
  19. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +13 -10
  20. puzzle_solver/puzzles/inertia/tsp.py +5 -7
  21. puzzle_solver/puzzles/kakuro/kakuro.py +1 -1
  22. puzzle_solver/puzzles/keen/keen.py +2 -2
  23. puzzle_solver/puzzles/minesweeper/minesweeper.py +2 -3
  24. puzzle_solver/puzzles/nonograms/nonograms.py +3 -3
  25. puzzle_solver/puzzles/norinori/norinori.py +2 -2
  26. puzzle_solver/puzzles/nurikabe/nurikabe.py +2 -2
  27. puzzle_solver/puzzles/pipes/pipes.py +81 -0
  28. puzzle_solver/puzzles/range/range.py +1 -1
  29. puzzle_solver/puzzles/rectangles/rectangles.py +2 -6
  30. puzzle_solver/puzzles/shingoki/shingoki.py +1 -1
  31. puzzle_solver/puzzles/signpost/signpost.py +2 -2
  32. puzzle_solver/puzzles/slant/parse_map/parse_map.py +7 -5
  33. puzzle_solver/puzzles/slitherlink/slitherlink.py +1 -1
  34. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +6 -5
  35. puzzle_solver/puzzles/stitches/stitches.py +1 -1
  36. puzzle_solver/puzzles/sudoku/sudoku.py +91 -20
  37. puzzle_solver/puzzles/tents/tents.py +2 -2
  38. puzzle_solver/puzzles/thermometers/thermometers.py +1 -1
  39. puzzle_solver/puzzles/towers/towers.py +1 -1
  40. puzzle_solver/puzzles/undead/undead.py +1 -1
  41. puzzle_solver/puzzles/unruly/unruly.py +1 -1
  42. puzzle_solver/puzzles/yin_yang/yin_yang.py +1 -1
  43. puzzle_solver/utils/visualizer.py +1 -1
  44. multi_puzzle_solver-0.9.31.dist-info/RECORD +0 -67
  45. {multi_puzzle_solver-0.9.31.dist-info → multi_puzzle_solver-1.0.3.dist-info}/WHEEL +0 -0
  46. {multi_puzzle_solver-0.9.31.dist-info → multi_puzzle_solver-1.0.3.dist-info}/top_level.txt +0 -0
@@ -197,7 +197,7 @@ def solve_optimal_walk(
197
197
  changed = False
198
198
  i = 0
199
199
  while i + 3 < len(ns):
200
- u, v, w, z = ns[i], ns[i+1], ns[i+2], ns[i+3]
200
+ u, v, w = ns[i], ns[i+1], ns[i+2]
201
201
  if w == u: # u->v, v->u
202
202
  before_edges = walk_edges(ns[:i+1])
203
203
  removed_edges = [(u, v), (v, u)]
@@ -235,7 +235,7 @@ def solve_optimal_walk(
235
235
  for i in range(N_no_depot):
236
236
  gi = state_group[i]
237
237
  for j in range(N_no_depot):
238
- if i == j:
238
+ if i == j:
239
239
  continue
240
240
  gj = state_group[j]
241
241
  if gi != gj:
@@ -244,9 +244,9 @@ def solve_optimal_walk(
244
244
  # ring + shift
245
245
  INF = 10**12
246
246
  succ_in_cluster: Dict[int, int] = {}
247
- for g, order in cluster_orders.items():
247
+ for order in cluster_orders.values():
248
248
  k = len(order)
249
- if k == 0:
249
+ if k == 0:
250
250
  continue
251
251
  pred = {}
252
252
  for idx, v in enumerate(order):
@@ -327,7 +327,6 @@ def solve_optimal_walk(
327
327
 
328
328
  best_nodes = None
329
329
  best_cost = float('inf')
330
- best_reps = None
331
330
 
332
331
  # initial deterministic order as a baseline
333
332
  def shuffled_cluster_orders():
@@ -339,7 +338,7 @@ def solve_optimal_walk(
339
338
  return orders
340
339
 
341
340
  attempts = max(1, restarts)
342
- for attempt in range(attempts):
341
+ for _ in range(attempts):
343
342
  cluster_orders = shuffled_cluster_orders()
344
343
  for meta in meta_list:
345
344
  # print('solve once')
@@ -382,7 +381,6 @@ def solve_optimal_walk(
382
381
  if cost < best_cost:
383
382
  best_cost = cost
384
383
  best_nodes = nodes_seq
385
- best_reps = reps
386
384
 
387
385
  if best_nodes is None:
388
386
  raise RuntimeError("No solution found.")
@@ -4,7 +4,7 @@ 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 Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, set_char, get_char, get_neighbors8
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
9
  from puzzle_solver.core.utils_visualizer import render_shaded_grid
10
10
 
@@ -75,9 +75,9 @@ class Board:
75
75
  for col in range(self.N):
76
76
  col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.N)]
77
77
  self.model.AddAllDifferent(col_vars)
78
-
78
+
79
79
  def constrain_block_results(self):
80
- # The digits in each block can be combined to form the number stated in the clue, using the arithmetic operation given in the clue. That is:
80
+ # The digits in each block can be combined to form the number stated in the clue, using the arithmetic operation given in the clue. That is:
81
81
  for block, (op, result) in self.block_results.items():
82
82
  block_vars = [self.model_vars[p] for p in self.get_block_pos(block)]
83
83
  add_opcode_constraint(self.model, block_vars, op, result)
@@ -103,7 +103,7 @@ def give_next_guess(board: np.array, mine_count: Optional[int] = None, verbose:
103
103
  print(new_garuneed_mine_positions)
104
104
  print('-'*10)
105
105
  if len(wrong_flag_positions) > 0:
106
- print(f"WARNING | "*4 + "WARNING")
106
+ print("WARNING | "*4 + "WARNING")
107
107
  print(f"Found {len(wrong_flag_positions)} wrong flag positions")
108
108
  print(wrong_flag_positions)
109
109
  print('-'*10)
@@ -120,5 +120,4 @@ def print_board(board: np.array, safe_positions: set[Pos], new_garuneed_mine_pos
120
120
  set_char(res, pos, 'M')
121
121
  elif get_char(board, pos) == 'F' and pos not in wrong_flag_positions:
122
122
  set_char(res, pos, 'F')
123
-
124
- print(res)
123
+ print(res)
@@ -7,8 +7,8 @@ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
7
 
8
8
  class Board:
9
9
  def __init__(self, top: list[list[int]], side: list[list[int]]):
10
- assert all(isinstance(i, int) for l in top for i in l), 'top must be a list of lists of integers'
11
- assert all(isinstance(i, int) for l in side for i in l), 'side must be a list of lists of integers'
10
+ assert all(isinstance(i, int) for line in top for i in line), 'top must be a list of lists of integers'
11
+ assert all(isinstance(i, int) for line in side for i in line), 'side must be a list of lists of integers'
12
12
  self.top = top
13
13
  self.side = side
14
14
  self.V = len(side)
@@ -63,7 +63,7 @@ class Board:
63
63
  # Start variables for each run. This is the most critical variable for the problem.
64
64
  starts = []
65
65
  self.extra_vars[f"{ns}_starts"] = starts
66
- for i, c in enumerate(clues):
66
+ for i in range(len(clues)):
67
67
  s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
68
68
  starts.append(s)
69
69
  # Enforce order and >=1 blank between consecutive runs.
@@ -3,8 +3,8 @@ 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, set_char, polyominoes, in_bounds, get_next_pos, Direction
7
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
6
+ from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, in_bounds, get_next_pos, Direction
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
8
 
9
9
 
10
10
  @dataclass
@@ -3,7 +3,7 @@ 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, get_all_pos, get_neighbors4, get_pos, in_bounds, set_char, get_char, polyominoes, Shape, Direction, get_next_pos
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
8
  from puzzle_solver.core.utils_visualizer import render_shaded_grid
9
9
 
@@ -62,7 +62,7 @@ class Board:
62
62
  assert len(hint_shapes) > 0, f'no shapes found for hint {hint_pos} with value {hint_value}'
63
63
  self.model.AddExactlyOne([s.is_active for s in hint_shapes])
64
64
  self.shapes_on_board.extend(hint_shapes)
65
-
65
+
66
66
  # if no shape is active on the spot then it must be black
67
67
  for pos in self.get_all_legal_pos():
68
68
  shapes_here = [s for s in self.shapes_on_board if pos in s.body]
@@ -0,0 +1,81 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+ from ortools.sat.python.cp_model import LinearExpr as lxp
4
+
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, Direction, get_next_pos, get_opposite_direction
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert all(c.item().strip() in ['1', '2L', '2I', '3', '4'] for c in np.nditer(board)), 'board must contain only 1, 2L, 2I, 3, 4. Found:' + str(set(c.item().strip() for c in np.nditer(board)) - set(['1', '2L', '2I', '3', '4']))
13
+ self.board = board
14
+ self.V, self.H = board.shape
15
+ self.model = cp_model.CpModel()
16
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
17
+
18
+ self.create_vars()
19
+ self.add_all_constraints()
20
+
21
+ def create_vars(self):
22
+ for pos in get_all_pos(self.V, self.H):
23
+ for direction in Direction:
24
+ mirrored = (get_next_pos(pos, direction), get_opposite_direction(direction))
25
+ if mirrored in self.model_vars:
26
+ self.model_vars[(pos, direction)] = self.model_vars[mirrored]
27
+ else:
28
+ self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
29
+
30
+ def add_all_constraints(self):
31
+ for pos in get_all_pos(self.V, self.H):
32
+ self.force_position(pos, get_char(self.board, pos).strip())
33
+ # single connected component
34
+ self.force_connected_component()
35
+
36
+ def force_connected_component(self):
37
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
38
+ p1, d1 = pd1
39
+ p2, d2 = pd2
40
+ if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
41
+ return True
42
+ if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
43
+ return True
44
+ return False
45
+ force_connected_component(self.model, self.model_vars, is_neighbor=is_neighbor)
46
+
47
+ def force_position(self, pos: Pos, value: str):
48
+ # cells with 1 or 3 or 4 neighbors each only have 1 unique state under rotational symmetry
49
+ # cells with 2 neighbors can either be a straight line (2I) or curved line (2L)
50
+ if value == '1':
51
+ self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 1)
52
+ elif value == '2L':
53
+ self.model.Add(self.model_vars[(pos, Direction.LEFT)] != self.model_vars[(pos, Direction.RIGHT)])
54
+ self.model.Add(self.model_vars[(pos, Direction.UP)] != self.model_vars[(pos, Direction.DOWN)])
55
+ elif value == '2I':
56
+ self.model.Add(self.model_vars[(pos, Direction.LEFT)] == self.model_vars[(pos, Direction.RIGHT)])
57
+ self.model.Add(self.model_vars[(pos, Direction.UP)] == self.model_vars[(pos, Direction.DOWN)])
58
+ self.model.Add(self.model_vars[(pos, Direction.UP)] != self.model_vars[(pos, Direction.RIGHT)])
59
+ elif value == '3':
60
+ self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 3)
61
+ elif value == '4':
62
+ self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 4)
63
+ else:
64
+ raise ValueError(f'invalid value: {value}')
65
+
66
+ def solve_and_print(self, verbose: bool = True):
67
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
68
+ assignment = {}
69
+ for pos in get_all_pos(self.V, self.H):
70
+ assignment[pos] = ''
71
+ for direction in Direction:
72
+ if solver.Value(board.model_vars[(pos, direction)]) == 1:
73
+ assignment[pos] += direction.name[0]
74
+ return SingleSolution(assignment=assignment)
75
+ def callback(single_res: SingleSolution):
76
+ print("Solution found")
77
+ res = np.full((self.V, self.H), ' ', dtype=object)
78
+ for pos in get_all_pos(self.V, self.H):
79
+ set_char(res, pos, single_res.assignment[pos])
80
+ print(res)
81
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -2,7 +2,7 @@ import numpy as np
2
2
  from ortools.sat.python import cp_model
3
3
 
4
4
  from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_neighbors4, in_bounds, Direction, get_next_pos, get_char
5
- from puzzle_solver.core.utils_ortools import and_constraint, or_constraint, generic_solve_all, SingleSolution, force_connected_component
5
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
6
6
 
7
7
 
8
8
  def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
@@ -97,7 +97,7 @@ class Board:
97
97
  def solve_and_print(self, verbose: bool = True):
98
98
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
99
99
  assignment: dict[Pos, int] = {}
100
- for (i, rectangle) in enumerate(self.rectangles):
100
+ for rectangle in self.rectangles:
101
101
  if solver.Value(rectangle.active) == 1:
102
102
  for pos in rectangle.body:
103
103
  assignment[pos] = f'id{rectangle.clue_id}:N={rectangle.N}:{rectangle.height}x{rectangle.width}'
@@ -121,10 +121,6 @@ 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('[')
125
- # for row in id_board:
126
- # print(' ', row.tolist(), end=',\n')
127
- # print(' ])')
128
- print(render_grid(res, center_char=self.board))
124
+ print(render_grid(res, center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else ' '))
129
125
 
130
126
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -76,7 +76,7 @@ class Board:
76
76
  continue
77
77
  self.enforce_corner_color(pos, self.board_colors[pos])
78
78
  self.enforce_corner_number(pos, self.board_numbers[pos])
79
-
79
+
80
80
  # enforce single connected component
81
81
  def is_neighbor(edge1: tuple[Pos, Pos], edge2: tuple[Pos, Pos]) -> bool:
82
82
  return any(c1 == c2 for c1 in edge1 for c2 in edge2)
@@ -65,7 +65,7 @@ class Board:
65
65
  continue
66
66
  direction = CHAR_TO_DIRECTION8[c]
67
67
  self.constrain_plus_one(pos, direction)
68
-
68
+
69
69
  def constrain_plus_one(self, pos: Pos, direction: Direction8):
70
70
  beam_res = beam(pos, self.V, self.H, direction)
71
71
  is_eq_list = []
@@ -75,7 +75,7 @@ class Board:
75
75
  self.model.Add(self.model_vars[p] != self.model_vars[pos] + 1).OnlyEnforceIf(aux.Not())
76
76
  is_eq_list.append(aux)
77
77
  self.model.Add(lxp.Sum(is_eq_list) == 1)
78
-
78
+
79
79
  def solve_and_print(self, verbose: bool = True):
80
80
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
81
81
  assignment: dict[Pos, str] = {}
@@ -1,10 +1,10 @@
1
1
  """
2
- This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
2
+ This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
3
3
  Look at the ./input_output/ directory for examples of input images and output json files.
4
4
  The output json is used in the test_solve.py file to test the solver.
5
5
  """
6
6
 
7
- import json, itertools
7
+ import itertools
8
8
  from pathlib import Path
9
9
  import numpy as np
10
10
  cv = None
@@ -37,9 +37,11 @@ def mean_consecutives(arr):
37
37
  sums, counts = [arr[0]], [1]
38
38
  for k in arr[1:]:
39
39
  if k == sums[-1] + counts[-1]:
40
- sums[-1] += k; counts[-1] += 1
40
+ sums[-1] += k
41
+ counts[-1] += 1
41
42
  else:
42
- sums.append(k); counts.append(1)
43
+ sums.append(k)
44
+ counts.append(1)
43
45
  return np.array(sums)//np.array(counts)
44
46
 
45
47
  def main(img_path):
@@ -90,7 +92,7 @@ def main(img_path):
90
92
 
91
93
  # Build KD-like search by grid proximity
92
94
  tol = int(cell*0.5) # max distance from an intersection to accept a circle
93
- for (cx, cy, r) in detected:
95
+ for (cx, cy, _) in detected:
94
96
  # find nearest indices
95
97
  j = int(np.argmin(np.abs(h_idx - cy)))
96
98
  i = int(np.argmin(np.abs(v_idx - cx)))
@@ -56,7 +56,7 @@ class Board:
56
56
  next_pos = get_next_pos(pos, direction)
57
57
  if in_bounds(next_pos, self.V, self.H):
58
58
  self.cell_borders[(next_pos, get_opposite_direction(direction))] = var
59
-
59
+
60
60
  def add_corner_vars(self, cell_border: CellBorder, var: cp_model.IntVar):
61
61
  """
62
62
  An edge always belongs to two corners. Note that the cell xi,yi has the 4 corners (xi,yi), (xi+1,yi), (xi,yi+1), (xi+1,yi+1). (memorize these 4 coordinates or the function wont make sense)
@@ -1,5 +1,5 @@
1
1
  """
2
- This file is a simple helper that parses the images from https://www.puzzle-stitches.com/ and converts them to a json file.
2
+ This file is a simple helper that parses the images from https://www.puzzle-stitches.com/ and converts them to a json file.
3
3
  Look at the ./input_output/ directory for examples of input images and output json files.
4
4
  The output json is used in the test_solve.py file to test the solver.
5
5
  """
@@ -26,7 +26,7 @@ def extract_lines(bw):
26
26
  # location where the horizontal lines are
27
27
  horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
28
28
  # print(f"horizontal_idx: {horizontal_idx}")
29
- height = len(horizontal_idx)
29
+ # height = len(horizontal_idx)
30
30
  # show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
31
31
 
32
32
  rows = vertical.shape[0]
@@ -39,7 +39,7 @@ def extract_lines(bw):
39
39
  vertical_cutoff = np.percentile(vertical_means, 50)
40
40
  vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
41
41
  # print(f"vertical_idx: {vertical_idx}")
42
- width = len(vertical_idx)
42
+ # width = len(vertical_idx)
43
43
  # print(f"height: {height}, width: {width}")
44
44
  # print(f"vertical_means: {vertical_means}")
45
45
  # show_wait_destroy("vertical", vertical) # this has the vertical lines
@@ -126,7 +126,6 @@ def main(image):
126
126
  print(f"vertical_idx: {vertical_idx}")
127
127
  arr = np.zeros((height - 1, width - 1), dtype=object)
128
128
  output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
129
- target = 200_000
130
129
  hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
131
130
  for j in range(height - 1):
132
131
  for i in range(width - 1):
@@ -244,4 +243,6 @@ if __name__ == '__main__':
244
243
  # main(Path(__file__).parent / 'input_output' / 'norinori_501d93110d6b4b818c268378973afbf268f96cfa8d7b4.png')
245
244
  # main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
246
245
  # main(Path(__file__).parent / 'input_output' / 'heyawake_MDoxNiwxNDQ=.png')
247
- main(Path(__file__).parent / 'input_output' / 'heyawake_MTQ6ODQ4LDEzOQ==.png')
246
+ # main(Path(__file__).parent / 'input_output' / 'heyawake_MTQ6ODQ4LDEzOQ==.png')
247
+ main(Path(__file__).parent / 'input_output' / 'sudoku_jigsaw.png')
248
+
@@ -77,7 +77,7 @@ class Board:
77
77
  # print(f'{pos}:{direction} must == {neighbor}:{opposite_direction}')
78
78
 
79
79
  # all blocks connected exactly N times (N usually 1 but can be 2 or 3)
80
- for (block_i, block_j), connections in self.block_neighbors.items():
80
+ for connections in self.block_neighbors.values():
81
81
  is_connected_list = []
82
82
  for pos_a, direction_a, pos_b, direction_b in connections:
83
83
  v = self.model.NewBoolVar(f'{pos_a}:{direction_a}->{pos_b}:{direction_b}')
@@ -1,4 +1,5 @@
1
1
  from typing import Union, Optional
2
+ from collections import defaultdict
2
3
 
3
4
  import numpy as np
4
5
  from ortools.sat.python import cp_model
@@ -35,32 +36,77 @@ def get_block_pos(i: int, Bv: int, Bh: int) -> list[Pos]:
35
36
 
36
37
 
37
38
  class Board:
38
- def __init__(self, board: np.array, block_size: Optional[tuple[int, int]] = None, sandwich: Optional[dict[str, list[int]]] = None, unique_diagonal: bool = False):
39
+ def __init__(self,
40
+ board: np.array,
41
+ constrain_blocks: bool = True,
42
+ block_size: Optional[tuple[int, int]] = None,
43
+ sandwich: Optional[dict[str, list[int]]] = None,
44
+ unique_diagonal: bool = False,
45
+ jigsaw: Optional[np.array] = None,
46
+ killer: Optional[tuple[np.array, dict[str, int]]] = None,
47
+ ):
48
+ """
49
+ board: 2d array of characters
50
+ constrain_blocks: whether to constrain the blocks. If True, each block must contain all numbers from 1 to 9 exactly once.
51
+ block_size: tuple of block size (vertical, horizontal). If not provided, the block size is the square root of the board size.
52
+ sandwich: dictionary of sandwich clues (side, bottom). If provided, the sum of the values between 1 and 9 for each row and column is equal to the clue.
53
+ unique_diagonal: whether to constrain the 2 diagonals to be unique. If True, each diagonal must contain all numbers from 1 to 9 exactly once.
54
+ killer: tuple of (killer board, killer clues). If provided, the killer board must be a 2d array of ids of the killer blocks. The killer clues must be a dictionary of killer block ids to clues.
55
+ Each numbers in a killer block must be unique and sum to the clue.
56
+ """
39
57
  assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
40
58
  assert board.shape[0] == board.shape[1], 'board must be square'
41
59
  assert all(isinstance(i.item(), str) and len(i.item()) == 1 and (i.item().isalnum() or i.item() == ' ') for i in np.nditer(board)), 'board must contain only alphanumeric characters or space'
42
60
  self.board = board
43
61
  self.V, self.H = board.shape
44
- if block_size is None:
45
- B = np.sqrt(self.V) # block size
46
- assert B.is_integer(), 'board size must be a perfect square or provide block_size'
47
- Bv, Bh = int(B), int(B)
62
+ self.L = max(self.V, self.H)
63
+ self.constrain_blocks = constrain_blocks
64
+ self.unique_diagonal = unique_diagonal
65
+ self.sandwich = None
66
+ self.jigsaw_id_to_pos = None
67
+ self.killer = None
68
+
69
+ if self.constrain_blocks:
70
+ if block_size is None:
71
+ B = np.sqrt(self.V) # block size
72
+ assert B.is_integer(), 'board size must be a perfect square or provide block_size'
73
+ Bv, Bh = int(B), int(B)
74
+ else:
75
+ Bv, Bh = block_size
76
+ assert Bv * Bh == self.V, 'block size must be a factor of board size'
77
+ # can be different in 4x3 for example
78
+ self.Bv = Bv
79
+ self.Bh = Bh
80
+ self.B = Bv * Bh # block count
48
81
  else:
49
- Bv, Bh = block_size
50
- assert Bv * Bh == self.V, 'block size must be a factor of board size'
51
- # can be different in 4x3 for example
52
- self.Bv = Bv
53
- self.Bh = Bh
54
- self.B = Bv * Bh # block count
82
+ assert block_size is None, 'cannot set block size if blocks are not constrained'
83
+
84
+ if jigsaw is not None:
85
+ if self.constrain_blocks is not None:
86
+ print('Warning: jigsaw and blocks are both constrained, are you sure you want to do this?')
87
+ assert jigsaw.ndim == 2, f'jigsaw must be 2d, got {jigsaw.ndim}'
88
+ assert jigsaw.shape[0] == self.V and jigsaw.shape[1] == self.H, 'jigsaw must be the same size as the board'
89
+ assert all(isinstance(i.item(), str) and i.item().isdecimal() for i in np.nditer(jigsaw)), 'jigsaw must contain only digits or space'
90
+ self.jigsaw_id_to_pos: dict[int, list[Pos]] = defaultdict(list)
91
+ for pos in get_all_pos(self.V, self.H):
92
+ v = get_char(jigsaw, pos)
93
+ if v.isdecimal():
94
+ self.jigsaw_id_to_pos[int(v)].append(pos)
95
+ assert all(len(pos_list) <= self.L for pos_list in self.jigsaw_id_to_pos.values()), 'jigsaw areas cannot be larger than the number of digits'
96
+
55
97
  if sandwich is not None:
56
98
  assert set(sandwich.keys()) == set(['side', 'bottom']), 'sandwich must contain only side and bottom'
57
99
  assert len(sandwich['side']) == self.H, 'side must be equal to board width'
58
100
  assert len(sandwich['bottom']) == self.V, 'bottom must be equal to board height'
59
101
  self.sandwich = sandwich
60
- else:
61
- self.sandwich = None
62
- self.unique_diagonal = unique_diagonal
63
-
102
+
103
+ if killer is not None:
104
+ assert killer[0].ndim == 2, f'killer board must be 2d, got {killer[0].ndim}'
105
+ assert killer[0].shape[0] == self.V and killer[0].shape[1] == self.H, 'killer board must be the same size as the board'
106
+ assert all(isinstance(i.item(), str) and i.item().isdecimal() for i in np.nditer(killer[0])), 'killer board must contain only digits or space'
107
+ assert set(killer[1].keys()).issubset(set(killer[0].flatten())), f'killer clues must contain all killer block ids, {set(killer[0].flatten()) - set(killer[1].keys())}'
108
+ self.killer = killer
109
+
64
110
  self.model = cp_model.CpModel()
65
111
  self.model_vars: dict[Pos, cp_model.IntVar] = {}
66
112
 
@@ -69,7 +115,7 @@ class Board:
69
115
 
70
116
  def create_vars(self):
71
117
  for pos in get_all_pos(self.V, self.H):
72
- self.model_vars[pos] = self.model.NewIntVar(1, self.B, f'{pos}')
118
+ self.model_vars[pos] = self.model.NewIntVar(1, self.L, f'{pos}')
73
119
 
74
120
  def add_all_constraints(self):
75
121
  # some squares are already filled
@@ -86,16 +132,21 @@ class Board:
86
132
  for col in range(self.H):
87
133
  col_vars = [self.model_vars[pos] for pos in get_col_pos(col, V=self.V)]
88
134
  self.model.AddAllDifferent(col_vars)
89
- # each block
90
- for block_i in range(self.B):
91
- block_vars = [self.model_vars[p] for p in get_block_pos(block_i, Bv=self.Bv, Bh=self.Bh)]
92
- self.model.AddAllDifferent(block_vars)
135
+ if self.constrain_blocks: # each block must contain all numbers from 1 to 9 exactly once
136
+ for block_i in range(self.B):
137
+ block_vars = [self.model_vars[p] for p in get_block_pos(block_i, Bv=self.Bv, Bh=self.Bh)]
138
+ self.model.AddAllDifferent(block_vars)
93
139
  if self.sandwich is not None:
94
140
  self.add_sandwich_constraints()
95
141
  if self.unique_diagonal:
96
142
  self.add_unique_diagonal_constraints()
143
+ if self.jigsaw_id_to_pos is not None:
144
+ self.add_jigsaw_constraints()
145
+ if self.killer is not None:
146
+ self.add_killer_constraints()
97
147
 
98
148
  def add_sandwich_constraints(self):
149
+ """Sandwich constraints, enforce that the sum of the values between 1 and 9 for each row and column is equal to the clue."""
99
150
  for c, clue in enumerate(self.sandwich['bottom']):
100
151
  if clue is None or int(clue) < 0:
101
152
  continue
@@ -113,6 +164,26 @@ class Board:
113
164
  anti_diagonal_vars = [self.model_vars[get_pos(x=i, y=self.V-i-1)] for i in range(min(self.V, self.H))]
114
165
  self.model.AddAllDifferent(anti_diagonal_vars)
115
166
 
167
+ def add_jigsaw_constraints(self):
168
+ """All digits in one jigsaw area must be unique."""
169
+ for pos_list in self.jigsaw_id_to_pos.values():
170
+ self.model.AddAllDifferent([self.model_vars[p] for p in pos_list])
171
+
172
+ def add_killer_constraints(self):
173
+ """Killer constraints, enforce that the sum of the values in each killer block is equal to the clue and all numbers in a block are unique."""
174
+ killer_board, killer_clues = self.killer
175
+ # change clue keys to ints
176
+ killer_clues = {int(k): v for k, v in killer_clues.items()}
177
+ killer_id_to_pos = defaultdict(list)
178
+ for pos in get_all_pos(self.V, self.H):
179
+ v = get_char(killer_board, pos)
180
+ if v.isdecimal():
181
+ killer_id_to_pos[int(v)].append(pos)
182
+ for killer_id, pos_list in killer_id_to_pos.items():
183
+ self.model.AddAllDifferent([self.model_vars[p] for p in pos_list])
184
+ clue = killer_clues[killer_id]
185
+ self.model.Add(sum([self.model_vars[p] for p in pos_list]) == clue)
186
+
116
187
  def solve_and_print(self, verbose: bool = True):
117
188
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
118
189
  assignment: dict[Pos, int] = {}
@@ -45,14 +45,14 @@ class Board:
45
45
  if get_char(self.board, neighbour) != ' ':
46
46
  continue
47
47
  self.model.Add(self.is_tent[neighbour] == 0).OnlyEnforceIf(self.is_tent[pos])
48
- # - the number of tents in each row and column matches the numbers around the edge of the grid
48
+ # - the number of tents in each row and column matches the numbers around the edge of the grid
49
49
  for row in range(self.N):
50
50
  row_vars = [self.is_tent[pos] for pos in get_row_pos(row, self.N)]
51
51
  self.model.Add(lxp.sum(row_vars) == self.sides['side'][row])
52
52
  for col in range(self.N):
53
53
  col_vars = [self.is_tent[pos] for pos in get_col_pos(col, self.N)]
54
54
  self.model.Add(lxp.sum(col_vars) == self.sides['top'][col])
55
- # - it is possible to match tents to trees so that each tree is orthogonally adjacent to its own tent (but may also be adjacent to other tents).
55
+ # - it is possible to match tents to trees so that each tree is orthogonally adjacent to its own tent (but may also be adjacent to other tents).
56
56
  # for each tree, one of the following must be true:
57
57
  # a tent on its left has direction RIGHT
58
58
  # a tent on its right has direction LEFT
@@ -47,7 +47,7 @@ class Board:
47
47
  def create_vars(self):
48
48
  for pos in get_all_pos(self.V, self.H):
49
49
  self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
50
-
50
+
51
51
  def add_all_constraints(self):
52
52
  visited: set[Pos] = set()
53
53
  for cur_pos in self.tip:
@@ -120,7 +120,7 @@ class Board:
120
120
  # create a single bool which decides if I can see it or not
121
121
  res = self.model.NewBoolVar(name)
122
122
  self.model.AddBoolAnd(lits).OnlyEnforceIf(res)
123
- self.model.AddBoolOr([res] + [l.Not() for l in lits])
123
+ self.model.AddBoolOr([res] + [lit.Not() for lit in lits])
124
124
  return res
125
125
 
126
126
  def solve_and_print(self, verbose: bool = True):
@@ -131,7 +131,7 @@ class Board:
131
131
  pos = get_pos(x=i, y=self.N-1)
132
132
  beam_result = beam(self.board, pos, Direction.UP)
133
133
  self.model.add(self.get_var(beam_result) == ground)
134
-
134
+
135
135
  if self.monster_count is not None:
136
136
  for monster, count in self.monster_count.items():
137
137
  if count == -1:
@@ -49,7 +49,7 @@ class Board:
49
49
  continue
50
50
  v = 1 if c == 'B' else 0
51
51
  self.model.Add(self.model_vars[pos] == v)
52
- # no three consecutive squares, horizontally or vertically, are the same colour
52
+ # no three consecutive squares, horizontally or vertically, are the same colour
53
53
  for pos in get_all_pos(self.V, self.H):
54
54
  horiz, vert = get_3_consecutive_horiz_and_vert(pos, self.V, self.H)
55
55
  if len(horiz) == 3:
@@ -48,7 +48,7 @@ class Board:
48
48
  continue
49
49
  self.model.AddBoolOr([self.B[tl], self.B[tr], self.B[bl], self.B[br]])
50
50
  self.model.AddBoolOr([self.B[tl].Not(), self.B[tr].Not(), self.B[bl].Not(), self.B[br].Not()])
51
-
51
+
52
52
  def disallow_checkers(self):
53
53
  # from https://ralphwaldo.github.io/yinyang_summary.html
54
54
  for pos in get_all_pos(self.V, self.H): # disallow (WB/BW) and (BW/WB)
@@ -152,4 +152,4 @@ if __name__ == '__main__':
152
152
  print('max i:', max(nums))
153
153
  if all(str(c).isdigit() for c in nums):
154
154
  print('skipped:', set(range(int(max(nums)) + 1)) - set(int(i) for i in nums))
155
- render_board_image(board, colors)
155
+ render_board_image(board, colors)