multi-puzzle-solver 1.1.8__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.
Files changed (106) hide show
  1. multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
  2. multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
  3. multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +184 -0
  6. puzzle_solver/core/utils.py +298 -0
  7. puzzle_solver/core/utils_ortools.py +333 -0
  8. puzzle_solver/core/utils_visualizer.py +575 -0
  9. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  10. puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
  11. puzzle_solver/puzzles/area_51/area_51.py +159 -0
  12. puzzle_solver/puzzles/battleships/battleships.py +139 -0
  13. puzzle_solver/puzzles/binairo/binairo.py +98 -0
  14. puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
  15. puzzle_solver/puzzles/black_box/black_box.py +243 -0
  16. puzzle_solver/puzzles/branches/branches.py +64 -0
  17. puzzle_solver/puzzles/bridges/bridges.py +104 -0
  18. puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
  19. puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
  20. puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
  21. puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
  22. puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
  23. puzzle_solver/puzzles/clouds/clouds.py +81 -0
  24. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  25. puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
  26. puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
  27. puzzle_solver/puzzles/filling/filling.py +94 -0
  28. puzzle_solver/puzzles/flip/flip.py +64 -0
  29. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  30. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
  31. puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
  32. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
  33. puzzle_solver/puzzles/guess/guess.py +232 -0
  34. puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
  35. puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
  36. puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
  37. puzzle_solver/puzzles/inertia/inertia.py +121 -0
  38. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
  39. puzzle_solver/puzzles/inertia/tsp.py +400 -0
  40. puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
  41. puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
  42. puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
  43. puzzle_solver/puzzles/keen/keen.py +76 -0
  44. puzzle_solver/puzzles/kropki/kropki.py +94 -0
  45. puzzle_solver/puzzles/light_up/light_up.py +58 -0
  46. puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
  47. puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
  48. puzzle_solver/puzzles/lits/lits.py +138 -0
  49. puzzle_solver/puzzles/magnets/magnets.py +96 -0
  50. puzzle_solver/puzzles/map/map.py +56 -0
  51. puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
  52. puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
  53. puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
  54. puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
  55. puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
  56. puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
  57. puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
  58. puzzle_solver/puzzles/norinori/norinori.py +96 -0
  59. puzzle_solver/puzzles/number_path/number_path.py +76 -0
  60. puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
  61. puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
  62. puzzle_solver/puzzles/palisade/palisade.py +91 -0
  63. puzzle_solver/puzzles/pearl/pearl.py +107 -0
  64. puzzle_solver/puzzles/pipes/pipes.py +82 -0
  65. puzzle_solver/puzzles/range/range.py +59 -0
  66. puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
  67. puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
  68. puzzle_solver/puzzles/rooms/rooms.py +75 -0
  69. puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
  70. puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
  71. puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
  72. puzzle_solver/puzzles/signpost/signpost.py +93 -0
  73. puzzle_solver/puzzles/singles/singles.py +53 -0
  74. puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
  75. puzzle_solver/puzzles/slant/slant.py +111 -0
  76. puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
  77. puzzle_solver/puzzles/snail/snail.py +97 -0
  78. puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
  79. puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
  80. puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
  81. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
  82. puzzle_solver/puzzles/stitches/stitches.py +96 -0
  83. puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
  84. puzzle_solver/puzzles/suguru/suguru.py +55 -0
  85. puzzle_solver/puzzles/suko/suko.py +54 -0
  86. puzzle_solver/puzzles/tapa/tapa.py +97 -0
  87. puzzle_solver/puzzles/tatami/tatami.py +64 -0
  88. puzzle_solver/puzzles/tents/tents.py +80 -0
  89. puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
  90. puzzle_solver/puzzles/towers/towers.py +89 -0
  91. puzzle_solver/puzzles/tracks/tracks.py +88 -0
  92. puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
  93. puzzle_solver/puzzles/troix/dumplings.py +7 -0
  94. puzzle_solver/puzzles/troix/troix.py +75 -0
  95. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  96. puzzle_solver/puzzles/undead/undead.py +130 -0
  97. puzzle_solver/puzzles/unequal/unequal.py +128 -0
  98. puzzle_solver/puzzles/unruly/unruly.py +54 -0
  99. puzzle_solver/puzzles/vectors/vectors.py +94 -0
  100. puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
  101. puzzle_solver/puzzles/walls/walls.py +52 -0
  102. puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
  103. puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
  104. puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
  105. puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
  106. puzzle_solver/utils/visualizer.py +155 -0
@@ -0,0 +1,128 @@
1
+ from dataclasses import dataclass
2
+
3
+ import numpy as np
4
+
5
+ from ortools.sat.python import cp_model
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, Direction, get_next_pos
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
+
11
+
12
+ def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
13
+ """Return all unique pairs (a, b) such that a * b == N, with a, b <= upper_limit."""
14
+ if N <= 0 or upper_limit_i <= 0 or upper_limit_j <= 0:
15
+ return []
16
+
17
+ pairs = []
18
+ i = 1
19
+ while i * i <= N:
20
+ if N % i == 0:
21
+ j = N // i
22
+ if i <= upper_limit_i and j <= upper_limit_j:
23
+ pairs.append((i, j))
24
+ if i != j and j <= upper_limit_i and i <= upper_limit_j:
25
+ pairs.append((j, i))
26
+ i += 1
27
+ return pairs
28
+
29
+
30
+ @dataclass
31
+ class Rectangle:
32
+ active: cp_model.IntVar
33
+ N: int
34
+ clue_id: int
35
+ width: int
36
+ height: int
37
+ body: set[Pos]
38
+
39
+
40
+ class Board:
41
+ def __init__(self, board: np.array):
42
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
43
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
44
+ self.board = board
45
+ self.V, self.H = board.shape
46
+ self.clue_pos: list[Pos] = [pos for pos in get_all_pos(self.V, self.H) if str(get_char(self.board, pos)).isdecimal()]
47
+ self.clue_pos_to_id: dict[Pos, int] = {pos: i for i, pos in enumerate(self.clue_pos)}
48
+ self.clue_pos_to_value: dict[Pos, int] = {pos: int(get_char(self.board, pos)) for pos in self.clue_pos}
49
+
50
+ self.model = cp_model.CpModel()
51
+ self.model_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
52
+ self.rectangles: list[Rectangle] = []
53
+
54
+ self.create_vars()
55
+ self.add_all_constraints()
56
+
57
+ def create_vars(self):
58
+ self.init_rectangles()
59
+ # for each position it belongs to exactly 1 clue
60
+ # instead of iterating over all clues, we only look at the clues that are possible for this position (by looking at the rectangles that contain this position)
61
+ for pos in get_all_pos(self.V, self.H):
62
+ possible_clue_here = {rectangle.clue_id for rectangle in self.rectangles if pos in rectangle.body} # get the clue position for any rectangle that contains this position
63
+ for possible_clue in possible_clue_here:
64
+ self.model_vars[(pos, possible_clue)] = self.model.NewBoolVar(f'{pos}:{possible_clue}')
65
+
66
+ def init_rectangles(self) -> list[Rectangle]:
67
+ self.fixed_pos: set[Pos] = set(self.clue_pos)
68
+ for pos in self.clue_pos: # for each clue on the board
69
+ clue_id = self.clue_pos_to_id[pos]
70
+ clue_num = self.clue_pos_to_value[pos]
71
+ other_fixed_pos = self.fixed_pos - {pos}
72
+ for width, height in factor_pairs(clue_num, self.V, self.H): # for each possible width x height rectangle that can fit the clue
73
+ # if the digit is at pos and we have a width x height rectangle then we can translate the rectangle "0 to width" to the left and "0 to height" to the top
74
+ for dx in range(width):
75
+ for dy in range(height):
76
+ body = {Pos(x=pos.x - dx + i, y=pos.y - dy + j) for i in range(width) for j in range(height)}
77
+ if any(not in_bounds(p, self.V, self.H) for p in body): # a rectangle cannot be out of bounds
78
+ continue
79
+ if any(p in other_fixed_pos for p in body): # a rectangle cannot contain a different clue; each clue is 1 rectangle only
80
+ continue
81
+ rectangle = Rectangle(active=self.model.NewBoolVar(f'{clue_id}'), N=clue_num, clue_id=clue_id, width=width, height=height, body=body)
82
+ self.rectangles.append(rectangle)
83
+
84
+ def add_all_constraints(self):
85
+ # each pos has only 1 rectangle active
86
+ for pos in get_all_pos(self.V, self.H):
87
+ self.model.AddExactlyOne(rectangle.active for rectangle in self.rectangles if pos in rectangle.body)
88
+ # each pos has only 1 clue active
89
+ for pos in get_all_pos(self.V, self.H):
90
+ self.model.AddExactlyOne(self.model_vars[(pos, clue_id)] for clue_id in self.clue_pos_to_id.values() if (pos, clue_id) in self.model_vars)
91
+ # a rectangle being active means all its body ponts to the clue
92
+ for rectangle in self.rectangles:
93
+ is_active = rectangle.active
94
+ for pos in rectangle.body:
95
+ self.model.Add(self.model_vars[(pos, rectangle.clue_id)] == 1).OnlyEnforceIf(is_active)
96
+
97
+ def solve_and_print(self, verbose: bool = True):
98
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
99
+ assignment: dict[Pos, int] = {}
100
+ for rectangle in self.rectangles:
101
+ if solver.Value(rectangle.active) == 1:
102
+ for pos in rectangle.body:
103
+ assignment[pos] = f'id{rectangle.clue_id}:N={rectangle.N}:{rectangle.height}x{rectangle.width}'
104
+ return SingleSolution(assignment=assignment)
105
+ def callback(single_res: SingleSolution):
106
+ print("Solution found")
107
+ res = np.full((self.V, self.H), '', dtype=object)
108
+ id_board = np.full((self.V, self.H), '', dtype=object)
109
+ for pos in get_all_pos(self.V, self.H):
110
+ cur = single_res.assignment[pos]
111
+ set_char(id_board, pos, cur)
112
+ left_pos = get_next_pos(pos, Direction.LEFT)
113
+ right_pos = get_next_pos(pos, Direction.RIGHT)
114
+ top_pos = get_next_pos(pos, Direction.UP)
115
+ bottom_pos = get_next_pos(pos, Direction.DOWN)
116
+ if left_pos not in single_res.assignment or single_res.assignment[left_pos] != cur:
117
+ set_char(res, pos, get_char(res, pos) + 'L')
118
+ if right_pos not in single_res.assignment or single_res.assignment[right_pos] != cur:
119
+ set_char(res, pos, get_char(res, pos) + 'R')
120
+ if top_pos not in single_res.assignment or single_res.assignment[top_pos] != cur:
121
+ set_char(res, pos, get_char(res, pos) + 'U')
122
+ if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
123
+ set_char(res, pos, get_char(res, pos) + 'D')
124
+ print(combined_function(self.V, self.H,
125
+ cell_flags=lambda r, c: res[r, c],
126
+ center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else ' '))
127
+
128
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,83 @@
1
+ from collections import defaultdict
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_pos, Direction, get_next_pos, in_bounds
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
9
+
10
+
11
+ def get_orthogonals_with_dist(pos: Pos, V: int, H: int) -> list[tuple[Pos, int]]:
12
+ out = []
13
+ for direction in Direction:
14
+ current_pos = pos
15
+ current_dist = 0
16
+ while True:
17
+ current_pos = get_next_pos(current_pos, direction)
18
+ current_dist += 1
19
+ if not in_bounds(current_pos, V, H):
20
+ break
21
+ out.append((current_pos, current_dist))
22
+ return out
23
+
24
+
25
+ class Board:
26
+ def __init__(self, board: np.array, id_board: np.array):
27
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
28
+ assert id_board.shape == board.shape, f'id_board and board must have the same shape, got {id_board.shape} and {board.shape}'
29
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
30
+ assert all(str(c.item()).isdecimal() for c in np.nditer(id_board)), 'id_board must contain only digits'
31
+ self.board = board
32
+ self.id_board = id_board
33
+ self.V, self.H = board.shape
34
+ self.id_to_pos: dict[int, set[Pos]] = defaultdict(set)
35
+ for pos in get_all_pos(self.V, self.H):
36
+ self.id_to_pos[int(get_char(self.id_board, pos))].add(pos)
37
+ self.id_to_max_val: dict[int, int] = {id_: len(self.id_to_pos[id_]) for id_ in self.id_to_pos}
38
+ self.model = cp_model.CpModel()
39
+ self.model_vars: dict[tuple[Pos, int], cp_model.IntVar] = {}
40
+ self.pos_vars: dict[Pos, set[cp_model.IntVar]] = defaultdict(set)
41
+ self.create_vars()
42
+ self.add_all_constraints()
43
+
44
+ def create_vars(self):
45
+ for pos in get_all_pos(self.V, self.H):
46
+ id_ = int(get_char(self.id_board, pos))
47
+ max_val = self.id_to_max_val[id_]
48
+ for n in range(1, max_val + 1):
49
+ self.model_vars[(pos, n)] = self.model.NewBoolVar(f'{pos}:{n}')
50
+ self.pos_vars[pos].add(self.model_vars[(pos, n)])
51
+
52
+ def add_all_constraints(self):
53
+ for pos in get_all_pos(self.V, self.H):
54
+ self.model.AddExactlyOne(self.pos_vars[pos]) # each position has exactly one number
55
+ c = get_char(self.board, pos).strip() # force clues
56
+ if c != '':
57
+ self.model.Add(self.model_vars[(pos, int(c))] == 1)
58
+ for id_ in self.id_to_pos: # each group has at most one number
59
+ max_val = self.id_to_max_val[id_]
60
+ for n in range(1, max_val + 1):
61
+ self.model.AddExactlyOne([self.model_vars[(pos, n)] for pos in self.id_to_pos[id_]])
62
+ for pos in get_all_pos(self.V, self.H):
63
+ # if pos is X then neighbors within X cant be X
64
+ orthogonals = get_orthogonals_with_dist(pos, self.V, self.H)
65
+ for neighbor, dist in orthogonals:
66
+ cur_n = dist
67
+ while True:
68
+ if (pos, cur_n) not in self.model_vars: # current position cant be as high as "cur_n"
69
+ break
70
+ if (neighbor, cur_n) not in self.model_vars: # neighbor position cant be as high as "cur_n"
71
+ break
72
+ self.model.Add(self.model_vars[(neighbor, cur_n)] == 0).OnlyEnforceIf(self.model_vars[(pos, cur_n)])
73
+ cur_n += 1
74
+
75
+ def solve_and_print(self, verbose: bool = True):
76
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
77
+ return SingleSolution(assignment={pos: n for (pos, n), var in board.model_vars.items() if solver.Value(var) == 1})
78
+ def callback(single_res: SingleSolution):
79
+ print("Solution found")
80
+ print(combined_function(self.V, self.H,
81
+ cell_flags=lambda r, c: id_board_to_wall_fn(self.id_board)(r, c),
82
+ center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)])))
83
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,75 @@
1
+ from collections import defaultdict
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, Direction, get_next_pos, get_opposite_direction, get_pos, get_ray, in_bounds
7
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
8
+ from puzzle_solver.core.utils_visualizer import combined_function
9
+
10
+
11
+ class Board:
12
+ def __init__(self, board: np.array):
13
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
14
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
15
+ self.board = board
16
+ self.V, self.H = board.shape
17
+ self.model = cp_model.CpModel()
18
+ self.model_vars: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
19
+ self.create_vars()
20
+ self.add_all_constraints()
21
+
22
+ def create_vars(self):
23
+ for pos in get_all_pos(self.V, self.H):
24
+ for direction in Direction:
25
+ next_pos = get_next_pos(pos, direction)
26
+ opposite_direction = get_opposite_direction(direction)
27
+ if (next_pos, opposite_direction) in self.model_vars:
28
+ self.model_vars[(pos, direction)] = self.model_vars[(next_pos, opposite_direction)]
29
+ elif not in_bounds(next_pos, self.V, self.H):
30
+ self.model_vars[(pos, direction)] = self.model.NewConstant(1)
31
+ else:
32
+ self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
33
+
34
+ def add_all_constraints(self):
35
+ for pos in get_all_pos(self.V, self.H):
36
+ c = get_char(self.board, pos)
37
+ if not str(c).isdecimal():
38
+ continue
39
+ self.range_clue(pos, int(c))
40
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
41
+ p1, d1 = pd1
42
+ p2, d2 = pd2
43
+ if d1 is None or d2 is None: # cell center, only neighbor to its own walls
44
+ return p1 == p2
45
+ if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
46
+ return True
47
+ if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
48
+ return True
49
+ return False
50
+ not_walls = {k: v.Not() for k, v in self.model_vars.items()}
51
+ cell_centers = {(k, None): self.model.NewConstant(1) for k in get_all_pos(self.V, self.H)}
52
+ force_connected_component(self.model, {**not_walls, **cell_centers}, is_neighbor=is_neighbor)
53
+
54
+ def range_clue(self, pos: Pos, k: int):
55
+ vis_vars: list[cp_model.IntVar] = []
56
+ for direction in Direction: # Build visibility chains in four direction
57
+ ray = get_ray(pos, direction, self.V, self.H, include_self=True) # cells outward
58
+ for idx in range(len(ray)):
59
+ v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
60
+ and_constraint(self.model, target=v, cs=[self.model_vars[(p, direction)].Not() for p in ray[:idx+1]])
61
+ vis_vars.append(v)
62
+ self.model.Add(sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
63
+
64
+ def solve_and_print(self, verbose: bool = True):
65
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
66
+ assignment: dict[Pos, str] = defaultdict(str)
67
+ for pos in get_all_pos(self.V, self.H):
68
+ for direction in Direction:
69
+ if (pos, direction) in board.model_vars and solver.Value(board.model_vars[(pos, direction)]) == 1:
70
+ assignment[pos] += direction.name[0]
71
+ return SingleSolution(assignment=assignment)
72
+ def callback(single_res: SingleSolution):
73
+ print("Solution found")
74
+ print(combined_function(self.V, self.H, cell_flags=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ''), center_char=lambda r, c: str(self.board[r, c]).strip()))
75
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=4)
@@ -0,0 +1,73 @@
1
+ from dataclasses import dataclass
2
+ import json
3
+
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SingleSolution:
11
+ assignment: dict[int, str]
12
+
13
+ def get_hashable_solution(self) -> str:
14
+ return json.dumps(self.assignment, sort_keys=True)
15
+
16
+
17
+ def all_pairs(lst: list[int]) -> list[tuple[int, int]]:
18
+ for i, ni in enumerate(lst):
19
+ for _j, nj in enumerate(lst[i:]):
20
+ yield ni, nj
21
+
22
+
23
+ class SchurNumbers:
24
+ def __init__(self, colors: list[str], n: int):
25
+ self.N = n
26
+ self.num_colors = len(colors)
27
+ self.int_to_color: dict[int, str] = {i+1: c for i, c in enumerate(colors)}
28
+
29
+ self.model = cp_model.CpModel()
30
+ self.model_vars: dict[int, cp_model.IntVar] = {}
31
+ self.eq_vars: dict[tuple[int, int], cp_model.BoolVar] = {}
32
+ self.create_vars()
33
+ self.add_all_constraints()
34
+
35
+ def create_vars(self):
36
+ for number in range(1, self.N + 1):
37
+ self.model_vars[number] = self.model.NewIntVar(1, self.num_colors, f'{number}:color')
38
+ for other_number in range(number + 1, self.N + 1):
39
+ self.eq_vars[(number, other_number)] = self.model.NewBoolVar(f'{number} == {other_number}')
40
+
41
+ def add_all_constraints(self):
42
+ numbers = list(self.model_vars.keys())
43
+ for (number, other_number) in self.eq_vars.keys(): # enforce auxiliary variables
44
+ v = self.eq_vars[(number, other_number)]
45
+ self.model.Add(self.model_vars[number] == self.model_vars[other_number]).OnlyEnforceIf(v)
46
+ self.model.Add(self.model_vars[number] != self.model_vars[other_number]).OnlyEnforceIf(v.Not())
47
+
48
+ for ni, nj in all_pairs(numbers):
49
+ if ni + nj not in numbers:
50
+ continue
51
+ nk = ni + nj
52
+ self.model.AddBoolOr([self.eq_vars[(ni, nk)].Not(), self.eq_vars[(nj, nk)].Not()])
53
+
54
+ def count_num_ways(self) -> int:
55
+ def board_to_solution(board: SchurNumbers, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
56
+ return SingleSolution(assignment={number: board.int_to_color[solver.Value(var)] for number, var in board.model_vars.items()})
57
+ solutions = generic_solve_all(self, board_to_solution, callback=None, verbose=False)
58
+ return len(solutions), solutions
59
+
60
+ def is_feasible(self) -> bool:
61
+ solver = cp_model.CpSolver()
62
+ solver.solve(self.model)
63
+ return solver.StatusName() in ['OPTIMAL', 'FEASIBLE']
64
+
65
+
66
+ def find_max_n(colors: list[str], n=1) -> int:
67
+ while True:
68
+ print(f'checking n = {n}')
69
+ solver = SchurNumbers(colors=colors, n=n)
70
+ if not solver.is_feasible():
71
+ return n
72
+ n += 1
73
+ return n
@@ -0,0 +1,201 @@
1
+ from collections import defaultdict
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+ import numpy as np
6
+ from ortools.sat.python import cp_model
7
+
8
+ from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_neighbors4, in_bounds
9
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
10
+ from puzzle_solver.core.utils_visualizer import render_bw_tiles_split
11
+
12
+
13
+ TPos = tuple[int, int]
14
+
15
+ class State(Enum):
16
+ WHITE = 'W'
17
+ BLACK = 'B'
18
+ TOP_LEFT = 'TL'
19
+ TOP_RIGHT = 'TR'
20
+ BOTTOM_LEFT = 'BL'
21
+ BOTTOM_RIGHT = 'BR'
22
+
23
+ @dataclass
24
+ class Rectangle:
25
+ is_rotated: bool
26
+ width: int
27
+ height: int
28
+ body: frozenset[tuple[TPos, State]]
29
+ disallow_white: frozenset[tuple[TPos]]
30
+ max_x: int
31
+ max_y: int
32
+
33
+ @dataclass
34
+ class RectangleOnBoard:
35
+ is_active: cp_model.IntVar
36
+ rectangle: Rectangle
37
+ body: frozenset[tuple[Pos, State]]
38
+ body_positions: frozenset[Pos]
39
+ disallow_white: frozenset[Pos]
40
+ translate: Pos
41
+ width: int
42
+ height: int
43
+
44
+
45
+ def init_rectangles(V: int, H: int) -> list[Rectangle]:
46
+ """Returns all possible upright and 45 degree rotated rectangles on a VxH board that are NOT translated (i.e. both min_x and min_y are always 0)"""
47
+ rectangles = []
48
+ # up right rectangles
49
+ for height in range(1, V+1):
50
+ for width in range(1, H+1):
51
+ body = {(x, y) for x in range(width) for y in range(height)}
52
+ # disallow any orthogonal adjacent white positions
53
+ disallow_white = set((p[0] + dxdy[0], p[1] + dxdy[1]) for p in body for dxdy in ((1,0),(-1,0),(0,1),(0,-1)))
54
+ disallow_white -= body
55
+ rectangles.append(Rectangle(
56
+ is_rotated=False,
57
+ width=width,
58
+ height=height,
59
+ body={(p, State.WHITE) for p in body},
60
+ disallow_white=disallow_white,
61
+ max_x=width-1,
62
+ max_y=height-1,
63
+ ))
64
+ # now imagine rectangles rotated clockwise by 45 degrees
65
+ for height in range(1, V+1):
66
+ for width in range(1, H+1):
67
+ if width + height > V or width + height > H: # this rotated rectangle won't fit
68
+ continue
69
+ body = {}
70
+ tl_body = {(i, height-1-i) for i in range(height)} # top left edge
71
+ tr_body = {(height+i, i) for i in range(width)} # top right edge
72
+ br_body = {(width+height-i-1, width+i) for i in range(height)} # bottom right edge
73
+ bl_body = {(width-i-1, width+height-i-1) for i in range(width)} # bottom left edge
74
+ inner_body = set() # inner body is anything to the right of L and to the left of R
75
+ for y in range(width+height):
76
+ row_is_active = False
77
+ for x in range(width+height):
78
+ if (x, y) in tl_body or (x, y) in bl_body:
79
+ row_is_active = True
80
+ continue
81
+ if (x, y) in tr_body or (x, y) in br_body:
82
+ break
83
+ if row_is_active:
84
+ inner_body.add((x, y))
85
+ tl_body = {(p, State.TOP_LEFT) for p in tl_body}
86
+ tr_body = {(p, State.TOP_RIGHT) for p in tr_body}
87
+ br_body = {(p, State.BOTTOM_RIGHT) for p in br_body}
88
+ bl_body = {(p, State.BOTTOM_LEFT) for p in bl_body}
89
+ inner_body = {(p, State.WHITE) for p in inner_body}
90
+ rectangles.append(Rectangle(
91
+ is_rotated=True, width=width, height=height, body=tl_body | tr_body | br_body | bl_body | inner_body, disallow_white=set(),
92
+ # clear from vizualization, both width and height contribute to both dimensions since it is rotated
93
+ max_x=width + height - 1,
94
+ max_y=width + height - 1,
95
+ ))
96
+ return rectangles
97
+
98
+
99
+ class Board:
100
+ def __init__(self, board: np.array):
101
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
102
+ assert all((c.item() in [' ', 'B', '0', '1', '2', '3', '4']) for c in np.nditer(board)), 'board must contain only space, B, 0, 1, 2, 3, 4'
103
+ self.board = board
104
+ self.V, self.H = board.shape
105
+ self.black_positions: set[Pos] = {pos for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos).strip() != ''}
106
+ self.black_positions_tuple: set[TPos] = {(p.x, p.y) for p in self.black_positions}
107
+ self.pos_to_rectangle_on_board: dict[Pos, list[RectangleOnBoard]] = defaultdict(list)
108
+ self.model = cp_model.CpModel()
109
+ self.B: dict[Pos, cp_model.IntVar] = {}
110
+ self.W: dict[Pos, cp_model.IntVar] = {}
111
+ self.rectangles_on_board: list[RectangleOnBoard] = []
112
+ self.init_rectangles_on_board()
113
+ self.create_vars()
114
+ self.add_all_constraints()
115
+
116
+ def init_rectangles_on_board(self):
117
+ rectangles = init_rectangles(self.V, self.H)
118
+ for rectangle in rectangles:
119
+ # translate
120
+ for dx in range(self.H - rectangle.max_x):
121
+ for dy in range(self.V - rectangle.max_y):
122
+ body: list[tuple[Pos, State]] = [None] * len(rectangle.body)
123
+ for i, (p, s) in enumerate(rectangle.body):
124
+ pp = (p[0] + dx, p[1] + dy)
125
+ body[i] = (pp, s)
126
+ if pp in self.black_positions_tuple:
127
+ body = None
128
+ break
129
+ if body is None:
130
+ continue
131
+ disallow_white = {Pos(x=p[0] + dx, y=p[1] + dy) for p in rectangle.disallow_white}
132
+ body_positions = set((Pos(x=p[0], y=p[1])) for p, _ in body)
133
+ rectangle_on_board = RectangleOnBoard(
134
+ is_active=self.model.NewBoolVar(f'{rectangle.is_rotated}:{rectangle.width}x{rectangle.height}:{dx}:{dy}:is_active'),
135
+ rectangle=rectangle,
136
+ body=set((Pos(x=p[0], y=p[1]), s) for p, s in body),
137
+ body_positions=body_positions,
138
+ disallow_white=disallow_white,
139
+ translate=Pos(x=dx, y=dy),
140
+ width=rectangle.width,
141
+ height=rectangle.height,
142
+ )
143
+ self.rectangles_on_board.append(rectangle_on_board)
144
+ for p in body_positions:
145
+ self.pos_to_rectangle_on_board[p].append(rectangle_on_board)
146
+
147
+ def create_vars(self):
148
+ for pos in get_all_pos(self.V, self.H):
149
+ self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
150
+ self.W[pos] = self.B[pos].Not()
151
+ if pos in self.black_positions:
152
+ self.model.Add(self.B[pos] == 1)
153
+
154
+ def add_all_constraints(self):
155
+ # every position not fixed must be part of exactly one rectangle
156
+ for pos in get_all_pos(self.V, self.H):
157
+ if pos in self.black_positions:
158
+ continue
159
+ self.model.AddExactlyOne([r.is_active for r in self.pos_to_rectangle_on_board[pos]])
160
+ # if a rectangle is active then all its body is black and all its disallow_white is white
161
+ for rectangle_on_board in self.rectangles_on_board:
162
+ for pos, state in rectangle_on_board.body:
163
+ if state == State.WHITE:
164
+ self.model.Add(self.W[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
165
+ else:
166
+ self.model.Add(self.B[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
167
+ for pos in rectangle_on_board.disallow_white:
168
+ if not in_bounds(pos, self.V, self.H):
169
+ continue
170
+ self.model.Add(self.B[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
171
+ # if a position has a clue, enforce it
172
+ for pos in get_all_pos(self.V, self.H):
173
+ c = get_char(self.board, pos)
174
+ if c.strip() != '' and c.strip().isdecimal():
175
+ clue = int(c.strip())
176
+ neighbors = [self.B[p] for p in get_neighbors4(pos, self.V, self.H) if p not in self.black_positions]
177
+ self.model.Add(sum(neighbors) == clue)
178
+
179
+ def solve_and_print(self, verbose: bool = True):
180
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
181
+ assignment: dict[Pos, int] = {}
182
+ for rectangle_on_board in board.rectangles_on_board:
183
+ if solver.Value(rectangle_on_board.is_active) == 1:
184
+ for p, s in rectangle_on_board.body:
185
+ assignment[p] = s.value
186
+ return SingleSolution(assignment=assignment)
187
+ def callback(single_res: SingleSolution):
188
+ print("Solution found")
189
+ res = np.full((self.V, self.H), 'W', dtype=object)
190
+ text = np.full((self.V, self.H), '', dtype=object)
191
+ for pos in get_all_pos(self.V, self.H):
192
+ if pos in single_res.assignment:
193
+ val = single_res.assignment[pos]
194
+ else:
195
+ c = get_char(self.board, pos)
196
+ if c.strip() != '':
197
+ val = 'B'
198
+ text[pos.y][pos.x] = c if c.strip() != 'B' else '.'
199
+ set_char(res, pos, val)
200
+ print(render_bw_tiles_split(res, cell_w=6, cell_h=3, borders=True, mode="text", cell_text=lambda r, c: text[r][c]))
201
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)