multi-puzzle-solver 1.0.3__py3-none-any.whl → 1.0.4__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.

@@ -1,4 +1,4 @@
1
- puzzle_solver/__init__.py,sha256=g1rzu3qSDD7oz46Els59z3HCxigzjkkFmoG244ymWro,4966
1
+ puzzle_solver/__init__.py,sha256=j1j97BCo0zoRY1QF6G4248BMWQCYB9TV55hbkbxbDWI,5178
2
2
  puzzle_solver/core/utils.py,sha256=XBW5j-IwtJMPMP-ycmY6SqRCM1NOVl5O6UeoGqNj618,8153
3
3
  puzzle_solver/core/utils_ortools.py,sha256=ACV3HgKWpEUTt1lpqsPryK1DeZpu7kdWQKEWTLJ2tfs,10384
4
4
  puzzle_solver/core/utils_visualizer.py,sha256=ymuhF75uwJbNhN8XVDYEPqw6sPKoqRaaxlhGeHtXpLs,20201
@@ -12,12 +12,13 @@ puzzle_solver/puzzles/chess_range/chess_melee.py,sha256=D-_Oi8OyxsVe1j3dIKYwRlxg
12
12
  puzzle_solver/puzzles/chess_range/chess_range.py,sha256=_VHlpUPnqeBstvSIt9RtTV-w2etSK7UrEHg6sErNqtU,21068
13
13
  puzzle_solver/puzzles/chess_range/chess_solo.py,sha256=ByDfcRsk5FVmFicpU_DpLoLTJ99Kr___vX4y8ln8_EQ,400
14
14
  puzzle_solver/puzzles/chess_sequence/chess_sequence.py,sha256=6ap3Wouf2PxHV4P56B9ol1QT98Ym6VHaxorQZWl6LnY,13692
15
+ puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py,sha256=PvxwoIUDHITxo51uD_JGoFcW33vf0YXTsvlm-gUjMNY,2610
15
16
  puzzle_solver/puzzles/dominosa/dominosa.py,sha256=Nmb7pn8U27QJwGy9F3wo8ylqo2_U51OAo3GN2soaNpc,7195
16
17
  puzzle_solver/puzzles/filling/filling.py,sha256=R8UIbztk3zNCeNbVClBJoKZHKeHwK_pesjGmMaEEQO0,5536
17
18
  puzzle_solver/puzzles/flip/flip.py,sha256=ZngJLUhRNc7qqo2wtNLdMPx4u9w9JTUge27PmdXyDCw,3985
18
19
  puzzle_solver/puzzles/flood_it/flood_it.py,sha256=jnCtH1sZIt6K4hbQDSsiM1Cd8FjQNP7cfw2ObUW5fEQ,7948
19
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py,sha256=0aw1TbiyxknY2hUAXaP3nXqT6I6mT9BIiERJSCj57xw,8245
20
- puzzle_solver/puzzles/galaxies/galaxies.py,sha256=36X9jaQfvLIWFkBY1VZH6I59eCDkc77U06NDtKRUECY,5571
20
+ puzzle_solver/puzzles/flood_it/parse_map/parse_map.py,sha256=m7gcpvN3THZdYLowdR_Jwx3HyttaV4K2DqrX_U7uFqU,8209
21
+ puzzle_solver/puzzles/galaxies/galaxies.py,sha256=CTSCNqRR35QT7qBiYdXYu9GLnDJmI9Cfd4C5t4_Kqhg,5535
21
22
  puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=XmFqVN_oRfq9AZFWy5ViUJ2Szjgx-srrRkFPJXEEyFo,9358
22
23
  puzzle_solver/puzzles/guess/guess.py,sha256=MpyrF6YVu0S1fzX-BllwxGKRGacWJpeLbNn5GetuEyo,10792
23
24
  puzzle_solver/puzzles/heyawake/heyawake.py,sha256=L_y44dHArOvO_tDyO35dwkvqdk9eEGItO7n4FDfzNDc,5586
@@ -28,13 +29,13 @@ puzzle_solver/puzzles/kakurasu/kakurasu.py,sha256=VNGMJnBHDi6WkghLObRLhUvkmrPaGp
28
29
  puzzle_solver/puzzles/kakuro/kakuro.py,sha256=m22Ju-V2BdQl2Ng_pjVUSrxPCtIfqezdpebutURlhvg,4348
29
30
  puzzle_solver/puzzles/keen/keen.py,sha256=adSA_pc1m6F6jV7a-PpQxdci1bv4psCNRNt9hMIQdSY,5034
30
31
  puzzle_solver/puzzles/light_up/light_up.py,sha256=iSA1rjZMFsnI0V0Nxivxox4qZkB7PvUrROSHXcoUXds,4541
31
- puzzle_solver/puzzles/lits/lits.py,sha256=3fPIkhAIUz8JokcfaE_ZM3b0AFEnf5xPzGJ2qnm8SWY,7099
32
+ puzzle_solver/puzzles/lits/lits.py,sha256=-iKgsdfFHA0aOQD8QCjSCXabQaHFaHN5idk6LBvgqRc,7166
32
33
  puzzle_solver/puzzles/magnets/magnets.py,sha256=-Wl49JD_PKeq735zQVMQ3XSQX6gdHiY-7PKw-Sh16jw,6474
33
34
  puzzle_solver/puzzles/map/map.py,sha256=sxc57tapB8Tsgam-yoDitln1o-EB_SbIYvO6WEYy3us,2582
34
35
  puzzle_solver/puzzles/minesweeper/minesweeper.py,sha256=gSdFsuZ-KrwVxgI1HF2q_pYleZ6vBm9jjRTFlboVnLY,5871
35
36
  puzzle_solver/puzzles/mosaic/mosaic.py,sha256=QX_nVpVKQg8OfaUcqFk9tKqsDyVqvZc6-XWvfI3YcSw,2175
36
37
  puzzle_solver/puzzles/nonograms/nonograms.py,sha256=dTKfMwBL49hW3bNd34ETXW7lBRPuQeSPNSCHqHmfybg,6066
37
- puzzle_solver/puzzles/norinori/norinori.py,sha256=qR7V7NbZRN_ME90R2jL47AkGik1CY6JlAPhLBMXP2Gw,4714
38
+ puzzle_solver/puzzles/norinori/norinori.py,sha256=iwJ2UgQJfx6JLYPQqJHdg1GJ8IBAjMDOtNybhO1jmNc,4968
38
39
  puzzle_solver/puzzles/nurikabe/nurikabe.py,sha256=3cbW7X4kAMQK8PkH_t65fzT5cI0O6tWWOqpQUVyuGT4,6501
39
40
  puzzle_solver/puzzles/palisade/palisade.py,sha256=T-LXlaLU5OwUQ24QWJWhBUFUktg0qDODTilNmBaXs4I,5014
40
41
  puzzle_solver/puzzles/pearl/pearl.py,sha256=OhzpMYpxqvR3GCd5NH4ETT0NO4X753kRi6p5omYLChM,6798
@@ -48,7 +49,7 @@ puzzle_solver/puzzles/singles/singles.py,sha256=KKn_Yl-eW874Bl1UmmcqoQ5vhNiO1JbM
48
49
  puzzle_solver/puzzles/slant/slant.py,sha256=xF-N4PuXYfx638NP1f1mi6YncIZB4mLtXtdS79XyPbg,6122
49
50
  puzzle_solver/puzzles/slant/parse_map/parse_map.py,sha256=8thQxWbq0qjehKb2VzgUP22PGj-9n9djwbt3LGMVLJw,4811
50
51
  puzzle_solver/puzzles/slitherlink/slitherlink.py,sha256=JpyNQk8K4nUziwWKxSvWEkF1RRBGLnCppCWK1Yf5bt0,7052
51
- puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
52
+ puzzle_solver/puzzles/star_battle/star_battle.py,sha256=hFV5IKPQDWrIWr50YpiOS9VF2kUScDQpvAGvZKBwuyM,3937
52
53
  puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
53
54
  puzzle_solver/puzzles/stitches/stitches.py,sha256=bb5JXyclkbKq350MQ9d8AuGteQwSF8knaJ0DU9M92Uw,6515
54
55
  puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=b21SQvlnDM6wOl_1iUhZ7X6akpBZoOnj3kEzImBCh8Q,10497
@@ -58,13 +59,14 @@ puzzle_solver/puzzles/tents/tents.py,sha256=jccUXWA7KWAtPKpVJJYNI6masTYWQgx0eitc
58
59
  puzzle_solver/puzzles/thermometers/thermometers.py,sha256=bGcVmpPeqL5AJtj8jkK8gYThzv9aGCd_QrWEiYBCA2s,4011
59
60
  puzzle_solver/puzzles/towers/towers.py,sha256=OLyTf9nTFR5L32-S_fhVyBmpz4i5YUNJotwOwbw_Fjg,6500
60
61
  puzzle_solver/puzzles/tracks/tracks.py,sha256=98xds9SKNqtOLFTRUX_KSMC7XYmZo567LOFeqotVQaM,7237
62
+ puzzle_solver/puzzles/twiddle/twiddle.py,sha256=3gPoeD0DoiiZbIhtptdXFldO_t1QShL6IxkDqJMzjkk,5446
61
63
  puzzle_solver/puzzles/undead/undead.py,sha256=IGFQysgoaKZH8rKjqlrkoHsH28ve4_hKor2f0QOsWY0,6596
62
64
  puzzle_solver/puzzles/unequal/unequal.py,sha256=ExY2XDCrqROCDpRLfHo8uVr1zuli1QvbCdNCiDhlCac,6978
63
65
  puzzle_solver/puzzles/unruly/unruly.py,sha256=xwOUpC12uHbmlDj2guN60VaaHpLr1Y-WmMD5TKeHbZE,3826
64
66
  puzzle_solver/puzzles/yin_yang/yin_yang.py,sha256=5WixT_7K1HwfQ_dWbuBlQfpU8p69zB2KvOg32XJ8vno,5255
65
67
  puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py,sha256=drjfoHqmFf6U-ZQUwrBbfGINRxDQpgbvy4U3D9QyMhM,6617
66
68
  puzzle_solver/utils/visualizer.py,sha256=T2g5We9J3tkhyXWoN2OrIDIJDjt6w5sDd2ksOub0ZI8,6819
67
- multi_puzzle_solver-1.0.3.dist-info/METADATA,sha256=XxSydqNr_sbU-cmZcqvrI5ODi-vrpx7KuLWNmK9q4W4,350517
68
- multi_puzzle_solver-1.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
69
- multi_puzzle_solver-1.0.3.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
70
- multi_puzzle_solver-1.0.3.dist-info/RECORD,,
69
+ multi_puzzle_solver-1.0.4.dist-info/METADATA,sha256=JSM2UZgGRSx0oipFuvGnNmKLfXWWfZGxgVWHNtWIH3I,372540
70
+ multi_puzzle_solver-1.0.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
71
+ multi_puzzle_solver-1.0.4.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
72
+ multi_puzzle_solver-1.0.4.dist-info/RECORD,,
puzzle_solver/__init__.py CHANGED
@@ -7,6 +7,7 @@ from puzzle_solver.puzzles.bridges import bridges as bridges_solver
7
7
  from puzzle_solver.puzzles.chess_range import chess_range as chess_range_solver
8
8
  from puzzle_solver.puzzles.chess_range import chess_solo as chess_solo_solver
9
9
  from puzzle_solver.puzzles.chess_range import chess_melee as chess_melee_solver
10
+ from puzzle_solver.puzzles.connect_the_dots import connect_the_dots as connect_the_dots_solver
10
11
  from puzzle_solver.puzzles.dominosa import dominosa as dominosa_solver
11
12
  from puzzle_solver.puzzles.filling import filling as filling_solver
12
13
  from puzzle_solver.puzzles.flood_it import flood_it as flood_it_solver
@@ -47,6 +48,7 @@ from puzzle_solver.puzzles.tents import tents as tents_solver
47
48
  from puzzle_solver.puzzles.thermometers import thermometers as thermometers_solver
48
49
  from puzzle_solver.puzzles.towers import towers as towers_solver
49
50
  from puzzle_solver.puzzles.tracks import tracks as tracks_solver
51
+ from puzzle_solver.puzzles.twiddle import twiddle as twiddle_solver
50
52
  from puzzle_solver.puzzles.undead import undead as undead_solver
51
53
  from puzzle_solver.puzzles.unequal import unequal as unequal_solver
52
54
  from puzzle_solver.puzzles.unruly import unruly as unruly_solver
@@ -64,6 +66,7 @@ __all__ = [
64
66
  chess_range_solver,
65
67
  chess_solo_solver,
66
68
  chess_melee_solver,
69
+ connect_the_dots_solver,
67
70
  dominosa_solver,
68
71
  filling_solver,
69
72
  flood_it_solver,
@@ -104,6 +107,7 @@ __all__ = [
104
107
  thermometers_solver,
105
108
  towers_solver,
106
109
  tracks_solver,
110
+ twiddle_solver,
107
111
  undead_solver,
108
112
  unequal_solver,
109
113
  unruly_solver,
@@ -111,4 +115,4 @@ __all__ = [
111
115
  inertia_image_parser,
112
116
  ]
113
117
 
114
- __version__ = '1.0.3'
118
+ __version__ = '1.0.4'
@@ -0,0 +1,48 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
6
+ from puzzle_solver.core.utils_visualizer import id_board_to_wall_board, render_grid
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
+ self.board = board
13
+ self.V, self.H = board.shape
14
+ self.unique_colors = set([str(c.item()).strip() for c in np.nditer(board) if str(c.item()).strip() not in ['', '#']])
15
+ assert all(np.count_nonzero(board == color) >= 2 for color in self.unique_colors), f'each color must appear >= 2 times, got {self.unique_colors}'
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
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 color in self.unique_colors:
24
+ self.model_vars[(pos, color)] = self.model.NewBoolVar(f'{pos}:{color}')
25
+
26
+ def add_all_constraints(self):
27
+ for pos in get_all_pos(self.V, self.H):
28
+ self.model.AddExactlyOne([self.model_vars[(pos, color)] for color in self.unique_colors])
29
+ c = get_char(self.board, pos)
30
+ if c.strip() not in ['', '#']:
31
+ self.model.Add(self.model_vars[(pos, c)] == 1)
32
+ for color in self.unique_colors:
33
+ force_connected_component(self.model, {pos: self.model_vars[(pos, color)] for pos in get_all_pos(self.V, self.H)})
34
+
35
+ def solve_and_print(self, verbose: bool = True):
36
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
37
+ assignment: dict[Pos, str] = {}
38
+ for (pos, color), var in board.model_vars.items():
39
+ if solver.Value(var) == 1:
40
+ assignment[pos] = color
41
+ return SingleSolution(assignment=assignment)
42
+ def callback(single_res: SingleSolution):
43
+ print("Solution found")
44
+ res = np.full((self.V, self.H), ' ', dtype=object)
45
+ for pos in get_all_pos(self.V, self.H):
46
+ set_char(res, pos, single_res.assignment[pos])
47
+ print(render_grid(id_board_to_wall_board(res), center_char=lambda r, c: res[r][c]))
48
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -75,7 +75,6 @@ def mean_consecutives(arr: np.ndarray) -> np.ndarray:
75
75
  def main(image):
76
76
  global Image
77
77
  global cv
78
- import matplotlib.pyplot as plt
79
78
  from PIL import Image as Image_module
80
79
  import cv2 as cv_module
81
80
  Image = Image_module
@@ -1,110 +1,108 @@
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 id_board_to_wall_board, render_grid
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(render_grid(id_board_to_wall_board(res), center_char=lambda r, c: '.' if (Pos(x=c, y=r) in self.prelocated_positions) else ' '))
108
+ 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 id_board_to_wall_board, render_grid
9
10
 
10
11
 
11
12
  # a shape on the 2d board is just a set of positions
@@ -128,9 +129,8 @@ 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(render_grid(id_board_to_wall_board(self.board), center_char=lambda r, c: res[r][c]))
136
136
  return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
@@ -5,6 +5,7 @@ from ortools.sat.python import cp_model
5
5
 
6
6
  from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, in_bounds, get_next_pos, Direction
7
7
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import id_board_to_wall_board, render_grid
8
9
 
9
10
 
10
11
  @dataclass
@@ -89,13 +90,14 @@ class Board:
89
90
  return SingleSolution(assignment=assignment)
90
91
  def callback(single_res: SingleSolution):
91
92
  print("Solution found")
92
- res = np.full((self.V, self.H), ' ', dtype=object)
93
- for pos in get_all_pos(self.V, self.H):
94
- c = get_char(self.board, pos)
95
- c = 'X' if pos in single_res.assignment else ' '
96
- set_char(res, pos, c)
97
- print('[')
98
- for row in res:
99
- print(" [ '" + "', '".join(row.tolist()) + "' ],")
100
- print(']')
93
+ # res = np.full((self.V, self.H), ' ', dtype=object)
94
+ # for pos in get_all_pos(self.V, self.H):
95
+ # c = get_char(self.board, pos)
96
+ # c = 'X' if pos in single_res.assignment else ' '
97
+ # set_char(res, pos, c)
98
+ # print('[')
99
+ # for row in res:
100
+ # print(" [ '" + "', '".join(row.tolist()) + "' ],")
101
+ # print(']')
102
+ print(render_grid(id_board_to_wall_board(self.board), center_char=lambda r, c: 'X' if (Pos(x=c, y=r) in single_res.assignment) else ' '))
101
103
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -3,6 +3,7 @@ from ortools.sat.python import cp_model
3
3
 
4
4
  from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_neighbors8, get_row_pos, get_col_pos
5
5
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import id_board_to_wall_board, render_grid
6
7
 
7
8
 
8
9
  class Board:
@@ -61,11 +62,13 @@ class Board:
61
62
  print("Solution found")
62
63
  res = np.full((self.V, self.H), ' ', dtype=object)
63
64
  for pos in get_all_pos(self.V, self.H):
64
- c = '*' if single_res.assignment[pos] == 1 else ' '
65
- set_char(res, pos, c)
66
- for row in range(self.V):
67
- print(res[row].tolist(), end='')
68
- if row != self.V - 1:
69
- print(',', end='')
70
- print()
65
+ if single_res.assignment[pos] == 1:
66
+ set_char(res, pos, 'X')
67
+ else:
68
+ b = get_char(self.board, pos)
69
+ if b == 'B':
70
+ set_char(res, pos, ' ')
71
+ else:
72
+ set_char(res, pos, '.')
73
+ print(render_grid(id_board_to_wall_board(self.board), center_char=lambda r, c: res[r][c]))
71
74
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,112 @@
1
+ import time
2
+ import numpy as np
3
+ from ortools.sat.python import cp_model
4
+ from ortools.sat.python.cp_model import LinearExpr as lxp
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_next_pos, Direction
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array, time_horizon: int = 10):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert all(str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only digits'
13
+ self.board = board
14
+ self.target_state = np.sort(board, axis=None).reshape(board.shape)
15
+ self.V, self.H = board.shape
16
+ self.min_value = int(np.min(board.flatten()))
17
+ self.max_value = int(np.max(board.flatten()))
18
+ self.time_horizon = time_horizon
19
+
20
+ self.model = cp_model.CpModel()
21
+ self.state: dict[tuple[Pos, int], cp_model.IntVar] = {}
22
+ self.decision: dict[int, dict[Pos, cp_model.IntVar]] = {t: {} for t in range(self.time_horizon - 1)}
23
+
24
+ self.create_vars()
25
+ self.add_all_constraints()
26
+ self.minimize_actions()
27
+ self.constrain_final_state()
28
+
29
+ def create_vars(self):
30
+ for pos in get_all_pos(self.V, self.H):
31
+ for t in range(self.time_horizon):
32
+ self.state[pos, t] = self.model.NewIntVar(self.min_value, self.max_value, f'state:{pos}:{t}')
33
+ for t in range(self.time_horizon - 1):
34
+ self.decision[t]['NOOP'] = self.model.NewBoolVar(f'decision:NOOP:{t}')
35
+ for pos in get_all_pos(self.V, self.H):
36
+ if pos.x == self.H - 1 or pos.y == self.V - 1:
37
+ continue
38
+ self.decision[t][pos] = self.model.NewBoolVar(f'decision:{pos}:{t}')
39
+
40
+ def add_all_constraints(self):
41
+ # one action at most every time
42
+ for decision_at_t in self.decision.values():
43
+ self.model.AddExactlyOne(list(decision_at_t.values()))
44
+ # constrain the state at t=0
45
+ for pos in get_all_pos(self.V, self.H):
46
+ self.model.Add(self.state[pos, 0] == get_char(self.board, pos))
47
+ # constrain the state dynamics at t=1..T
48
+ for action_pos in get_all_pos(self.V, self.H):
49
+ if action_pos.x == self.H - 1 or action_pos.y == self.V - 1:
50
+ continue
51
+ self.constrain_state(action_pos)
52
+ # state does not change if NOOP is chosen
53
+ for t in range(1, self.time_horizon):
54
+ noop_var = self.decision[t - 1]['NOOP']
55
+ for pos in get_all_pos(self.V, self.H):
56
+ self.model.Add(self.state[pos, t] == self.state[pos, t - 1]).OnlyEnforceIf(noop_var)
57
+
58
+ def constrain_state(self, action: Pos):
59
+ tl = action
60
+ tr = get_next_pos(tl, Direction.RIGHT)
61
+ bl = get_next_pos(tl, Direction.DOWN)
62
+ br = get_next_pos(tr, Direction.DOWN)
63
+ two_by_two = (tl, tr, br, bl)
64
+ # lock state outside the two by two
65
+ for pos in get_all_pos(self.V, self.H):
66
+ if pos in two_by_two:
67
+ continue
68
+ for t in range(1, self.time_horizon):
69
+ self.model.Add(self.state[pos, t] == self.state[pos, t - 1]).OnlyEnforceIf(self.decision[t - 1][action])
70
+ # rotate clockwise inside the two by two
71
+ clockwise = two_by_two[-1:] + two_by_two[:-1]
72
+ # print('action', action)
73
+ # print('two_by_two', two_by_two)
74
+ # print('clockwise', clockwise)
75
+ for pre_pos, post_pos in zip(clockwise, two_by_two):
76
+ for t in range(1, self.time_horizon):
77
+ # print(f'IF self.decision[{t - 1}][{action}] THEN self.state[{post_pos}, {t}] == self.state[{pre_pos}, {t - 1}]')
78
+ self.model.Add(self.state[post_pos, t] == self.state[pre_pos, t - 1]).OnlyEnforceIf(self.decision[t - 1][action])
79
+
80
+ def constrain_final_state(self):
81
+ final_time = self.time_horizon - 1
82
+ for pos in get_all_pos(self.V, self.H):
83
+ self.model.Add(self.state[pos, final_time] == get_char(self.target_state, pos))
84
+
85
+ def minimize_actions(self):
86
+ flat_decisions = [(var, t+1) for t, tvs in self.decision.items() for pos, var in tvs.items() if pos != 'NOOP']
87
+ self.model.Minimize(lxp.weighted_sum([p[0] for p in flat_decisions], [p[1] for p in flat_decisions]))
88
+
89
+ def solve_and_print(self, verbose: bool = True):
90
+ solver = cp_model.CpSolver()
91
+ tic = time.time()
92
+ solver.solve(self.model)
93
+ assignment: dict[Pos] = [None for _ in range(self.time_horizon - 1)]
94
+ if solver.StatusName() in ['OPTIMAL', 'FEASIBLE']:
95
+ for t, tvs in self.decision.items():
96
+ for pos, var in tvs.items():
97
+ if solver.Value(var) == 1:
98
+ assignment[t] = (pos.x, pos.y) if pos != 'NOOP' else 'NOOP'
99
+ for t in range(self.time_horizon):
100
+ res_at_t = np.full((self.V, self.H), ' ', dtype=object)
101
+ for pos in get_all_pos(self.V, self.H):
102
+ res_at_t[pos.y][pos.x] = solver.Value(self.state[pos, t])
103
+ print(f't={t}')
104
+ print(res_at_t)
105
+ if verbose:
106
+ print("Solution found:", assignment)
107
+ if verbose:
108
+ print("status:", solver.StatusName())
109
+ toc = time.time()
110
+ print(f"Time taken: {toc - tic:.2f} seconds")
111
+ return assignment
112
+