multi-puzzle-solver 0.9.14__py3-none-any.whl → 0.9.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- {multi_puzzle_solver-0.9.14.dist-info → multi_puzzle_solver-0.9.18.dist-info}/METADATA +292 -11
- {multi_puzzle_solver-0.9.14.dist-info → multi_puzzle_solver-0.9.18.dist-info}/RECORD +12 -7
- puzzle_solver/__init__.py +4 -1
- puzzle_solver/core/utils_ortools.py +24 -18
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +133 -0
- puzzle_solver/puzzles/slant/slant.py +117 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +1 -1
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- {multi_puzzle_solver-0.9.14.dist-info → multi_puzzle_solver-0.9.18.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.14.dist-info → multi_puzzle_solver-0.9.18.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multi-puzzle-solver
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.18
|
|
4
4
|
Summary: Efficient solvers for numerous popular and esoteric logic puzzles using CP-SAT
|
|
5
5
|
Author: Ar-Kareem
|
|
6
6
|
Project-URL: Homepage, https://github.com/Ar-Kareem/puzzle_solver
|
|
@@ -39,22 +39,35 @@ pip install multi-puzzle-solver
|
|
|
39
39
|
Use:
|
|
40
40
|
|
|
41
41
|
```python
|
|
42
|
-
from puzzle_solver import nonograms_solver
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
from puzzle_solver import nonograms_solver as solver
|
|
43
|
+
top_numbers = [[8, 2], [5, 4], [2, 1, 4], [2, 4], [2, 1, 4], [2, 5], [2, 8], [3, 2], [1, 6], [1, 9], [1, 6, 1], [1, 5, 3], [3, 2, 1], [4, 2], [1, 5]]
|
|
44
|
+
side_numbers = [[7, 3], [7, 1, 1], [2, 3], [2, 3], [3, 2], [1, 1, 1, 1, 2], [1, 6, 1], [1, 9], [9], [2, 4], [8], [11], [7, 1, 1], [4, 3], [3, 2]]
|
|
45
|
+
binst = solver.Board(top=top_numbers, side=side_numbers)
|
|
46
|
+
solutions = binst.solve_and_print()
|
|
45
47
|
```
|
|
46
48
|
|
|
47
49
|
Output:
|
|
48
50
|
|
|
49
51
|
```python
|
|
50
52
|
Solution found
|
|
51
|
-
[[' ' 'B' 'B' 'B']
|
|
52
|
-
[' ' 'B' ' ' ' ']
|
|
53
|
-
['B' 'B' ' ' 'B']
|
|
54
|
-
['B' ' ' ' ' ' ']
|
|
53
|
+
[['B' 'B' 'B' 'B' 'B' 'B' 'B' ' ' 'B' 'B' 'B' ' ' ' ' ' ' ' ']
|
|
54
|
+
['B' 'B' 'B' 'B' 'B' 'B' 'B' ' ' ' ' ' ' ' ' ' ' 'B' ' ' 'B']
|
|
55
|
+
['B' 'B' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' 'B' 'B' 'B' ' ']
|
|
56
|
+
['B' 'B' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' 'B' 'B' 'B']
|
|
57
|
+
['B' 'B' 'B' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' 'B' 'B']
|
|
58
|
+
['B' ' ' ' ' ' ' 'B' ' ' 'B' ' ' ' ' 'B' ' ' ' ' ' ' 'B' 'B']
|
|
59
|
+
['B' ' ' ' ' ' ' ' ' ' ' 'B' 'B' 'B' 'B' 'B' 'B' ' ' ' ' 'B']
|
|
60
|
+
['B' ' ' ' ' ' ' ' ' ' ' 'B' 'B' 'B' 'B' 'B' 'B' 'B' 'B' 'B']
|
|
61
|
+
[' ' ' ' ' ' ' ' ' ' 'B' 'B' 'B' 'B' 'B' 'B' 'B' 'B' 'B' ' ']
|
|
62
|
+
[' ' ' ' ' ' ' ' ' ' 'B' 'B' ' ' 'B' 'B' 'B' 'B' ' ' ' ' ' ']
|
|
63
|
+
[' ' ' ' ' ' ' ' 'B' 'B' 'B' 'B' 'B' 'B' 'B' 'B' ' ' ' ' ' ']
|
|
64
|
+
['B' 'B' 'B' 'B' 'B' 'B' 'B' 'B' 'B' 'B' 'B' ' ' ' ' ' ' ' ']
|
|
65
|
+
['B' 'B' 'B' 'B' 'B' 'B' 'B' ' ' ' ' 'B' ' ' 'B' ' ' ' ' ' ']
|
|
66
|
+
[' ' 'B' 'B' 'B' 'B' ' ' ' ' ' ' ' ' 'B' 'B' 'B' ' ' ' ' ' ']
|
|
67
|
+
[' ' 'B' 'B' 'B' ' ' ' ' ' ' ' ' ' ' ' ' ' ' 'B' 'B' ' ' ' ']]
|
|
55
68
|
Solutions found: 1
|
|
56
69
|
status: OPTIMAL
|
|
57
|
-
Time taken: 0.
|
|
70
|
+
Time taken: 0.04 seconds
|
|
58
71
|
```
|
|
59
72
|
(Note: Printing can be turned off by setting `verbose=False`)
|
|
60
73
|
|
|
@@ -269,6 +282,23 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
|
|
|
269
282
|
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/black_box_solved.png" alt="Black Box" width="140">
|
|
270
283
|
</a>
|
|
271
284
|
</td>
|
|
285
|
+
<td align="center">
|
|
286
|
+
<a href="#galaxies-puzzle-type-35"><b>Galaxies</b><br><br>
|
|
287
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/galaxies_solved.png" alt="Galaxies" width="140">
|
|
288
|
+
</a>
|
|
289
|
+
</td>
|
|
290
|
+
</tr>
|
|
291
|
+
<tr>
|
|
292
|
+
<td align="center">
|
|
293
|
+
<a href="#slant-puzzle-type-36"><b>Slant</b><br><br>
|
|
294
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/slant_solved.png" alt="Slant" width="140">
|
|
295
|
+
</a>
|
|
296
|
+
</td>
|
|
297
|
+
<td align="center">
|
|
298
|
+
<a href="#unequal-puzzle-type-37"><b>Unequal</b><br><br>
|
|
299
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/unequal_solved.png" alt="Unequal" width="140">
|
|
300
|
+
</a>
|
|
301
|
+
</td>
|
|
272
302
|
</tr>
|
|
273
303
|
</table>
|
|
274
304
|
|
|
@@ -318,6 +348,9 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
|
|
|
318
348
|
- [Star Battle Shapeless (Puzzle Type #32)](#star-battle-shapeless-puzzle-type-32)
|
|
319
349
|
- [Lits (Puzzle Type #33)](#lits-puzzle-type-33)
|
|
320
350
|
- [Black Box (Puzzle Type #34)](#black-box-puzzle-type-34)
|
|
351
|
+
- [Galaxies (Puzzle Type #35)](#galaxies-puzzle-type-35)
|
|
352
|
+
- [Slant (Puzzle Type #36)](#slant-puzzle-type-36)
|
|
353
|
+
- [Unequal (Puzzle Type #37)](#unequal-puzzle-type-37)
|
|
321
354
|
- [Why SAT / CP-SAT?](#why-sat--cp-sat)
|
|
322
355
|
- [Testing](#testing)
|
|
323
356
|
- [Contributing](#contributing)
|
|
@@ -1772,8 +1805,8 @@ Thus the solver was developed with the additional much harder goal of collecting
|
|
|
1772
1805
|
It does so using the following high level steps:
|
|
1773
1806
|
|
|
1774
1807
|
1. Model the board as a directed graph where the cells are nodes and legal moves as directed edges with unit cost. Each gem has to a group of edges where traversing any one of them collects that gem.
|
|
1775
|
-
2. Model step (1) as a [Generalized Traveling Salesman Problem (GTSP)](https://en.wikipedia.org/wiki/Set_TSP_problem), where each gem
|
|
1776
|
-
3. Apply the [Noon–Bean transformation](https://deepblue.lib.umich.edu/bitstream/handle/2027.42/6834/ban3102.0001.001.pdf?sequence=5) **(Noon & Bean, 1991)** to convert the GTSP from step (2) into an equivalent Asymmetric TSP (ATSP) that can be solved with OR-Tools
|
|
1808
|
+
2. Model step (1) as a [Generalized Traveling Salesman Problem (GTSP)](https://en.wikipedia.org/wiki/Set_TSP_problem), where each gem's edge group forms a cluster.
|
|
1809
|
+
3. Apply the [Noon–Bean transformation](https://deepblue.lib.umich.edu/bitstream/handle/2027.42/6834/ban3102.0001.001.pdf?sequence=5) **(Noon & Bean, 1991)** to convert the GTSP from step (2) into an equivalent Asymmetric TSP (ATSP) that can be solved with OR-Tools' routing solver. (Noon-Bean transformation is mentioned but not described in the [TSP wikipedia page](https://en.wikipedia.org/wiki/Travelling_salesman_problem).)
|
|
1777
1810
|
4. Use a [Vehicle Routing Problem (VRP)](https://en.wikipedia.org/wiki/Vehicle_routing_problem) solver using the [OR-Tools VRP solver](https://developers.google.com/optimization/routing/routing_tasks) to solve the ATSP.
|
|
1778
1811
|
|
|
1779
1812
|
This achieves a final sequence of moves that is empirically always faster than the website's solution.
|
|
@@ -2892,6 +2925,251 @@ Time taken: 30.33 seconds
|
|
|
2892
2925
|
|
|
2893
2926
|
---
|
|
2894
2927
|
|
|
2928
|
+
## Galaxies (Puzzle Type #35)
|
|
2929
|
+
|
|
2930
|
+
* [**Play online**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/galaxies.html)
|
|
2931
|
+
|
|
2932
|
+
* [**Instructions**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/doc/galaxies.html#galaxies)
|
|
2933
|
+
|
|
2934
|
+
* [**Solver Code**][35]
|
|
2935
|
+
|
|
2936
|
+
<details>
|
|
2937
|
+
<summary><strong>Rules</strong></summary>
|
|
2938
|
+
|
|
2939
|
+
You have a rectangular grid containing a number of dots. Your aim is to partition the rectangle into connected regions of squares, in such a way that every region is 180° rotationally symmetric, and contains exactly one dot which is located at its centre of symmetry.
|
|
2940
|
+
|
|
2941
|
+
To enter your solution, you draw lines along the grid edges to mark the boundaries of the regions. The puzzle is complete when the marked lines on the grid are precisely those that separate two squares belonging to different regions.
|
|
2942
|
+
|
|
2943
|
+
</details>
|
|
2944
|
+
|
|
2945
|
+
**Unsolved puzzle**
|
|
2946
|
+
|
|
2947
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/galaxies_unsolved.png" alt="Galaxies unsolved" width="500">
|
|
2948
|
+
|
|
2949
|
+
Code to utilize this package and solve the puzzle:
|
|
2950
|
+
|
|
2951
|
+
Note: The number are arbitrary and simply number each galaxy as an integer.
|
|
2952
|
+
|
|
2953
|
+
```python
|
|
2954
|
+
from puzzle_solver import galaxies_solver as solver
|
|
2955
|
+
galaxies = np.array([
|
|
2956
|
+
[' ', ' ', '00', ' ', ' ', '01', '01', '02', '02', '03', '03', ' ', '04', '04', ' '],
|
|
2957
|
+
['05', '05', ' ', ' ', '06', '01', '01', '02', '02', ' ', ' ', ' ', '07', ' ', ' '],
|
|
2958
|
+
['08', ' ', ' ', ' ', '06', ' ', '09', '09', ' ', ' ', '10', ' ', ' ', ' ', ' '],
|
|
2959
|
+
[' ', ' ', ' ', ' ', ' ', ' ', '11', '11', '12', ' ', ' ', ' ', ' ', '13', '13'],
|
|
2960
|
+
['14', ' ', ' ', ' ', '15', ' ', '11', '11', ' ', ' ', ' ', ' ', '16', ' ', ' '],
|
|
2961
|
+
[' ', '17', ' ', ' ', '15', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '16', ' ', '18'],
|
|
2962
|
+
[' ', '17', '19', ' ', ' ', ' ', ' ', ' ', ' ', '20', ' ', ' ', ' ', '21', '18'],
|
|
2963
|
+
[' ', '22', ' ', ' ', '23', ' ', ' ', ' ', ' ', '20', ' ', '24', '24', '21', '25'],
|
|
2964
|
+
['26', '27', '27', '28', '28', '29', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '30', '30'],
|
|
2965
|
+
[' ', '27', '27', '28', '28', '31', '31', ' ', ' ', ' ', ' ', '32', ' ', '30', '30'],
|
|
2966
|
+
[' ', ' ', ' ', '33', '33', '31', '31', '34', ' ', ' ', '35', ' ', ' ', ' ', ' '],
|
|
2967
|
+
['36', ' ', ' ', '33', '33', ' ', ' ', '34', ' ', ' ', ' ', ' ', ' ', '37', ' '],
|
|
2968
|
+
[' ', ' ', '38', '38', ' ', '39', ' ', '40', '40', '41', '41', '42', ' ', '37', ' '],
|
|
2969
|
+
['43', '44', '38', '38', '45', '45', '46', '40', '40', '41', '41', '42', ' ', ' ', ' '],
|
|
2970
|
+
['43', ' ', ' ', ' ', ' ', ' ', ' ', '47', ' ', ' ', ' ', ' ', '48', '48', ' ']
|
|
2971
|
+
])
|
|
2972
|
+
binst = solver.Board(galaxies=galaxies)
|
|
2973
|
+
solutions = binst.solve_and_print()
|
|
2974
|
+
```
|
|
2975
|
+
**Script Output**
|
|
2976
|
+
|
|
2977
|
+
As the instructions say, the solution to this puzzle is not garunteed to be unique.
|
|
2978
|
+
|
|
2979
|
+
```python
|
|
2980
|
+
Solution found
|
|
2981
|
+
[
|
|
2982
|
+
['00', '00', '00', '00', '00', '01', '01', '02', '02', '03', '03', '04', '04', '04', '04'],
|
|
2983
|
+
['05', '05', '15', '06', '06', '01', '01', '02', '02', '10', '10', '07', '07', '07', '13'],
|
|
2984
|
+
['08', '15', '15', '15', '06', '06', '09', '09', '10', '10', '10', '10', '10', '13', '13'],
|
|
2985
|
+
['14', '15', '15', '15', '15', '15', '11', '11', '12', '20', '10', '10', '16', '13', '13'],
|
|
2986
|
+
['14', '15', '15', '15', '15', '15', '11', '11', '20', '20', '20', '20', '16', '13', '13'],
|
|
2987
|
+
['14', '17', '17', '15', '15', '15', '15', '15', '20', '20', '20', '20', '16', '13', '18'],
|
|
2988
|
+
['17', '17', '19', '15', '15', '15', '15', '15', '20', '20', '20', '24', '16', '21', '18'],
|
|
2989
|
+
['26', '22', '27', '27', '23', '15', '15', '15', '20', '20', '20', '24', '24', '21', '25'],
|
|
2990
|
+
['26', '27', '27', '28', '28', '29', '15', '20', '20', '20', '20', '32', '24', '30', '30'],
|
|
2991
|
+
['26', '27', '27', '28', '28', '31', '31', '20', '20', '20', '20', '32', '37', '30', '30'],
|
|
2992
|
+
['27', '27', '33', '33', '33', '31', '31', '34', '34', '20', '35', '32', '37', '37', '37'],
|
|
2993
|
+
['36', '38', '38', '33', '33', '33', '34', '34', '41', '41', '41', '41', '37', '37', '37'],
|
|
2994
|
+
['44', '44', '38', '38', '45', '39', '46', '40', '40', '41', '41', '42', '37', '37', '37'],
|
|
2995
|
+
['43', '44', '38', '38', '45', '45', '46', '40', '40', '41', '41', '42', '37', '37', '37'],
|
|
2996
|
+
['43', '44', '44', '38', '38', '45', '46', '47', '41', '41', '41', '41', '48', '48', '37'],
|
|
2997
|
+
]
|
|
2998
|
+
Solutions found: 1
|
|
2999
|
+
status: OPTIMAL
|
|
3000
|
+
Time taken: 0.07 seconds
|
|
3001
|
+
```
|
|
3002
|
+
|
|
3003
|
+
**Solved puzzle**
|
|
3004
|
+
|
|
3005
|
+
Applying the solution to the puzzle visually:
|
|
3006
|
+
|
|
3007
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/galaxies_solved.png" alt="Galaxies solved" width="500">
|
|
3008
|
+
|
|
3009
|
+
---
|
|
3010
|
+
|
|
3011
|
+
## Slant (Puzzle Type #36)
|
|
3012
|
+
|
|
3013
|
+
* [**Play online**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/slant.html)
|
|
3014
|
+
|
|
3015
|
+
* [**Instructions**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/doc/slant.html#slant)
|
|
3016
|
+
|
|
3017
|
+
* [**Solver Code**][36]
|
|
3018
|
+
|
|
3019
|
+
<details>
|
|
3020
|
+
<summary><strong>Rules</strong></summary>
|
|
3021
|
+
|
|
3022
|
+
You have a grid of squares. Your aim is to draw a diagonal line through each square, and choose which way each line slants so that the following conditions are met:
|
|
3023
|
+
|
|
3024
|
+
- The diagonal lines never form a loop.
|
|
3025
|
+
- Any point with a circled number has precisely that many lines meeting at it. (Thus, a 4 is the centre of a cross shape, whereas a zero is the centre of a diamond shape – or rather, a partial diamond shape, because a zero can never appear in the middle of the grid because that would immediately cause a loop.)
|
|
3026
|
+
|
|
3027
|
+
</details>
|
|
3028
|
+
|
|
3029
|
+
**Unsolved puzzle**
|
|
3030
|
+
|
|
3031
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/slant_unsolved.png" alt="Slant unsolved" width="500">
|
|
3032
|
+
|
|
3033
|
+
Code to utilize this package and solve the puzzle:
|
|
3034
|
+
|
|
3035
|
+
Note: For an NxM board you need an (N+1)x(M+1) array because the puzzle is to solve for the cells while the input is the values at the corners (there's always one more corner than cells in each dimension).
|
|
3036
|
+
|
|
3037
|
+
```python
|
|
3038
|
+
from puzzle_solver import slant_solver as solver
|
|
3039
|
+
board = np.array([
|
|
3040
|
+
[' ', ' ', '1', ' ', '1', ' ', '1', ' ', '1', ' ', ' ', ' ', ' '],
|
|
3041
|
+
[' ', '1', '2', ' ', ' ', '2', ' ', '2', ' ', '2', ' ', '1', '1'],
|
|
3042
|
+
[' ', '2', '2', ' ', '2', '3', '2', ' ', '3', ' ', ' ', '1', ' '],
|
|
3043
|
+
['1', '1', ' ', '3', '1', '2', ' ', '1', ' ', ' ', '3', ' ', ' '],
|
|
3044
|
+
[' ', ' ', '1', '1', ' ', ' ', ' ', '1', '1', '3', ' ', '3', ' '],
|
|
3045
|
+
['1', '2', ' ', '2', '2', ' ', '2', ' ', ' ', '1', '2', ' ', ' '],
|
|
3046
|
+
[' ', '2', '2', '2', ' ', ' ', '2', '3', '2', ' ', ' ', ' ', ' '],
|
|
3047
|
+
[' ', '1', '2', ' ', ' ', '2', ' ', '2', ' ', ' ', ' ', '1', ' '],
|
|
3048
|
+
[' ', ' ', ' ', '3', '2', '2', ' ', '3', '1', ' ', ' ', ' ', '1'],
|
|
3049
|
+
[' ', '2', '1', '1', '2', ' ', '1', ' ', '1', ' ', '1', '1', ' '],
|
|
3050
|
+
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '1', ' ', ' '],
|
|
3051
|
+
])
|
|
3052
|
+
binst = solver.Board(numbers=board)
|
|
3053
|
+
solutions = binst.solve_and_print()
|
|
3054
|
+
```
|
|
3055
|
+
**Script Output**
|
|
3056
|
+
|
|
3057
|
+
```python
|
|
3058
|
+
Solution found
|
|
3059
|
+
[
|
|
3060
|
+
[ / \ \ / / / / \ \ \ / \ ]
|
|
3061
|
+
[ \ \ \ \ \ \ / \ / / \ \ ]
|
|
3062
|
+
[ \ \ \ / / \ / \ \ \ \ / ]
|
|
3063
|
+
[ \ / \ \ / \ / / \ / \ / ]
|
|
3064
|
+
[ / \ \ / \ \ \ / / / \ \ ]
|
|
3065
|
+
[ / \ \ / \ \ \ / \ / \ \ ]
|
|
3066
|
+
[ / \ \ / \ / / / \ / / \ ]
|
|
3067
|
+
[ \ \ \ \ \ / / / \ / \ \ ]
|
|
3068
|
+
[ / / / \ \ / / \ \ / \ \ ]
|
|
3069
|
+
[ \ \ / / / \ / \ / \ \ / ]
|
|
3070
|
+
]
|
|
3071
|
+
Solutions found: 1
|
|
3072
|
+
status: OPTIMAL
|
|
3073
|
+
Time taken: 0.06 seconds
|
|
3074
|
+
```
|
|
3075
|
+
|
|
3076
|
+
**Solved puzzle**
|
|
3077
|
+
|
|
3078
|
+
Applying the solution to the puzzle visually:
|
|
3079
|
+
|
|
3080
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/slant_solved.png" alt="Slant solved" width="500">
|
|
3081
|
+
|
|
3082
|
+
---
|
|
3083
|
+
|
|
3084
|
+
## Unequal (Puzzle Type #37)
|
|
3085
|
+
|
|
3086
|
+
* [**Play online**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/unequal.html)
|
|
3087
|
+
|
|
3088
|
+
* [**Instructions**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/doc/unequal.html#unequal)
|
|
3089
|
+
|
|
3090
|
+
* [**Solver Code**][37]
|
|
3091
|
+
|
|
3092
|
+
<details>
|
|
3093
|
+
<summary><strong>Rules</strong></summary>
|
|
3094
|
+
|
|
3095
|
+
You have a square grid; each square may contain a digit from 1 to the size of the grid, and some squares have clue signs between them. Your aim is to fully populate the grid with numbers such that:
|
|
3096
|
+
|
|
3097
|
+
- Each row contains only one occurrence of each digit
|
|
3098
|
+
- Each column contains only one occurrence of each digit
|
|
3099
|
+
- All the clue signs are satisfied.
|
|
3100
|
+
|
|
3101
|
+
There are two modes for this game, 'Unequal' and 'Adjacent'.
|
|
3102
|
+
|
|
3103
|
+
In 'Unequal' mode, the clue signs are greater-than symbols indicating one square's value is greater than its neighbour's. In this mode not all clues may be visible, particularly at higher difficulty levels.
|
|
3104
|
+
|
|
3105
|
+
In 'Adjacent' mode, the clue signs are bars indicating one square's value is numerically adjacent (i.e. one higher or one lower) than its neighbour. In this mode all clues are always visible: absence of a bar thus means that a square's value is definitely not numerically adjacent to that neighbour's.
|
|
3106
|
+
|
|
3107
|
+
In 'Trivial' difficulty level (available via the 'Custom' game type selector), there are no greater-than signs in 'Unequal' mode; the puzzle is to solve the Latin square only.
|
|
3108
|
+
|
|
3109
|
+
</details>
|
|
3110
|
+
|
|
3111
|
+
**Unsolved puzzle**
|
|
3112
|
+
|
|
3113
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/unequal_unsolved.png" alt="Unequal unsolved" width="500">
|
|
3114
|
+
|
|
3115
|
+
Code to utilize this package and solve the puzzle:
|
|
3116
|
+
|
|
3117
|
+
Note: For an NxM board you need an (2N-1)x(2M-1) array because the puzzle involves input in between the cells. Each numbered cell has neighbors horizontally to represent ">", "<", and "|" (where "|" represents adjacency) and vertically to represent "∧", "∨" and "-" (where "-" represents adjacency). The "X" in the input are unused corners that shouldnt contain anything (just a corner). The numbers should never appear orthogonal to an "X", only diagonally to it. vice-versa for the comparison operators.
|
|
3118
|
+
|
|
3119
|
+
```python
|
|
3120
|
+
board = np.array([
|
|
3121
|
+
[' ', ' ', ' ', ' ', '9', ' ', '1', ' ', '7', '>', ' ', '>', ' ', ' ', ' ', ' ', ' ', '>', ' '],
|
|
3122
|
+
[' ', 'X', 'V', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', '∧', 'X', ' ', 'X', ' ', 'X', ' '],
|
|
3123
|
+
[' ', ' ', ' ', ' ', ' ', ' ', '6', ' ', ' ', ' ', '9', ' ', ' ', ' ', '5', ' ', '3', ' ', ' '],
|
|
3124
|
+
[' ', 'X', ' ', 'X', '∧', 'X', ' ', 'X', '∧', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' '],
|
|
3125
|
+
[' ', ' ', ' ', '>', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '6', ' ', '9', ' ', ' ', ' ', ' '],
|
|
3126
|
+
[' ', 'X', ' ', 'X', 'V', 'X', 'V', 'X', 'V', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', 'V'],
|
|
3127
|
+
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '<', ' ', '<', ' ', '>', ' ', ' ', ' ', ' ', ' '],
|
|
3128
|
+
[' ', 'X', ' ', 'X', '∧', 'X', 'V', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' '],
|
|
3129
|
+
[' ', ' ', '3', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '<', ' ', ' ', ' '],
|
|
3130
|
+
[' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', '∧', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' '],
|
|
3131
|
+
[' ', '<', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '5', ' ', ' ', '>', ' ', '<', ' ', ' ', '4'],
|
|
3132
|
+
['V', 'X', '∧', 'X', 'V', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', 'V', 'X', ' ', 'X', ' '],
|
|
3133
|
+
[' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '>', ' ', ' ', ' ', ' ', ' '],
|
|
3134
|
+
[' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', '∧', 'X', ' ', 'X', ' ', 'X', 'V'],
|
|
3135
|
+
[' ', ' ', ' ', '<', ' ', ' ', ' ', '<', ' ', ' ', ' ', '<', ' ', '<', ' ', ' ', ' ', '<', ' '],
|
|
3136
|
+
[' ', 'X', ' ', 'X', ' ', 'X', 'V', 'X', ' ', 'X', 'V', 'X', '∧', 'X', ' ', 'X', ' ', 'X', ' '],
|
|
3137
|
+
[' ', ' ', ' ', ' ', ' ', '>', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '>', ' ', ' ', '9', ' ', ' '],
|
|
3138
|
+
['V', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', 'V'],
|
|
3139
|
+
[' ', '>', ' ', ' ', ' ', '>', ' ', ' ', ' ', ' ', '4', '<', ' ', '<', ' ', '<', '7', ' ', '2'],
|
|
3140
|
+
])
|
|
3141
|
+
binst = solver.Board(board=board)
|
|
3142
|
+
solutions = binst.solve_and_print()
|
|
3143
|
+
```
|
|
3144
|
+
**Script Output**
|
|
3145
|
+
|
|
3146
|
+
```python
|
|
3147
|
+
Solution found
|
|
3148
|
+
[
|
|
3149
|
+
[ 6 5 9 1 7 2 0 8 4 3 ]
|
|
3150
|
+
[ 7 1 0 6 4 9 2 5 3 8 ]
|
|
3151
|
+
[ 3 4 2 8 5 0 6 9 1 7 ]
|
|
3152
|
+
[ 5 9 1 7 3 6 8 4 2 0 ]
|
|
3153
|
+
[ 8 3 5 4 0 7 1 2 6 9 ]
|
|
3154
|
+
[ 2 6 7 0 1 5 9 3 8 4 ]
|
|
3155
|
+
[ 0 7 4 9 2 8 3 1 5 6 ]
|
|
3156
|
+
[ 9 2 6 5 8 3 4 7 0 1 ]
|
|
3157
|
+
[ 4 8 3 2 6 1 7 0 9 5 ]
|
|
3158
|
+
[ 1 0 8 3 9 4 5 6 7 2 ]
|
|
3159
|
+
]
|
|
3160
|
+
Solutions found: 1
|
|
3161
|
+
status: OPTIMAL
|
|
3162
|
+
Time taken: 0.05 seconds
|
|
3163
|
+
```
|
|
3164
|
+
|
|
3165
|
+
**Solved puzzle**
|
|
3166
|
+
|
|
3167
|
+
Applying the solution to the puzzle visually:
|
|
3168
|
+
|
|
3169
|
+
<img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/unequal_solved.png" alt="Unequal solved" width="500">
|
|
3170
|
+
|
|
3171
|
+
---
|
|
3172
|
+
|
|
2895
3173
|
---
|
|
2896
3174
|
|
|
2897
3175
|
## Why SAT / CP-SAT?
|
|
@@ -2977,3 +3255,6 @@ Issues and PRs welcome!
|
|
|
2977
3255
|
[32]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/star_battle_shapeless "puzzle_solver/src/puzzle_solver/puzzles/star_battle_shapeless at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
2978
3256
|
[33]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/lits "puzzle_solver/src/puzzle_solver/puzzles/lits at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
2979
3257
|
[34]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/black_box "puzzle_solver/src/puzzle_solver/puzzles/black_box at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
3258
|
+
[35]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/galaxies "puzzle_solver/src/puzzle_solver/puzzles/galaxies at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
3259
|
+
[36]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/slant "puzzle_solver/src/puzzle_solver/puzzles/slant at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
3260
|
+
[37]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/unequal "puzzle_solver/src/puzzle_solver/puzzles/unequal at master · Ar-Kareem/puzzle_solver · GitHub"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
puzzle_solver/__init__.py,sha256
|
|
1
|
+
puzzle_solver/__init__.py,sha256=nqXtO2JuY0r7gNM1HwP_5apXmLX7OeriApzXNaJZjDA,2704
|
|
2
2
|
puzzle_solver/core/utils.py,sha256=_LA81kHrsgvqPvq7RISBeaurXmYMKAU9N6qmV8n0G7s,8063
|
|
3
|
-
puzzle_solver/core/utils_ortools.py,sha256=
|
|
3
|
+
puzzle_solver/core/utils_ortools.py,sha256=2xEL9cMEKmNhRD9lhr2nGdZ3Lbmc9cnHY8xv6iLhUr0,10542
|
|
4
4
|
puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
|
|
5
5
|
puzzle_solver/puzzles/battleships/battleships.py,sha256=RuYCrs4j0vUjlU139NRYYP-uNPAgO0V7hAzbsHrRwD8,7446
|
|
6
6
|
puzzle_solver/puzzles/black_box/black_box.py,sha256=ZnHDVt6PFS_r1kMNSsbz9hav1hxIrNDUvPyERGPjLjM,15635
|
|
@@ -11,6 +11,8 @@ puzzle_solver/puzzles/chess_range/chess_solo.py,sha256=U3v766UsZHx_dC3gxqU90VbjA
|
|
|
11
11
|
puzzle_solver/puzzles/chess_sequence/chess_sequence.py,sha256=6ap3Wouf2PxHV4P56B9ol1QT98Ym6VHaxorQZWl6LnY,13692
|
|
12
12
|
puzzle_solver/puzzles/dominosa/dominosa.py,sha256=Nmb7pn8U27QJwGy9F3wo8ylqo2_U51OAo3GN2soaNpc,7195
|
|
13
13
|
puzzle_solver/puzzles/filling/filling.py,sha256=vrOIil285_r3IQ0F4c9mUBWMRVlPH4vowog_z1tCGdI,5567
|
|
14
|
+
puzzle_solver/puzzles/galaxies/galaxies.py,sha256=p10lpmW0FjtneFCMEjG1FSiEpQuvD8zZG9FG8zYGoes,5582
|
|
15
|
+
puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=v5TCrdREeOB69s9_QFgPHKA7flG69Im1HVzIdxH0qQc,9355
|
|
14
16
|
puzzle_solver/puzzles/guess/guess.py,sha256=sH-NlYhxM3DNbhk4eGde09kgM0KaDvSbLrpHQiwcFGo,10791
|
|
15
17
|
puzzle_solver/puzzles/inertia/inertia.py,sha256=gJBahkh69CrSWNscalKEoP1j4X-Q3XpbIBMiG9PUpU0,5657
|
|
16
18
|
puzzle_solver/puzzles/inertia/tsp.py,sha256=gobiISHtARA4Elq0jr90p6Yhq11ULjGoqsS-rLFhYcc,15389
|
|
@@ -29,19 +31,22 @@ puzzle_solver/puzzles/pearl/pearl.py,sha256=OhzpMYpxqvR3GCd5NH4ETT0NO4X753kRi6p5
|
|
|
29
31
|
puzzle_solver/puzzles/range/range.py,sha256=rruvD5ZSaOgvQuX6uGV_Dkr82nSiWZ5kDz03_j7Tt24,4425
|
|
30
32
|
puzzle_solver/puzzles/signpost/signpost.py,sha256=-0_S6ycwzwlUf9-ZhP127Rgo5gMBOHiTM6t08dLLDac,3869
|
|
31
33
|
puzzle_solver/puzzles/singles/singles.py,sha256=3wACiUa1Vmh2ce6szQ2hPjyBuH7aHiQ888p4R2jFkW4,3342
|
|
34
|
+
puzzle_solver/puzzles/slant/slant.py,sha256=xF-N4PuXYfx638NP1f1mi6YncIZB4mLtXtdS79XyPbg,6122
|
|
35
|
+
puzzle_solver/puzzles/slant/parse_map/parse_map.py,sha256=dxnALSDXe9wU0uSD0QEXnzoh1q801mj1ePTNLtG0n60,4796
|
|
32
36
|
puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
|
|
33
37
|
puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
|
|
34
38
|
puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
|
|
35
|
-
puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=
|
|
39
|
+
puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=dbd7DoqjR4qkMQAeD_lPpuLB_KCFCIHfDauTbjB3W80,8880
|
|
36
40
|
puzzle_solver/puzzles/sudoku/sudoku.py,sha256=M_pry7XyKKzlfCF5rFi02lyOrj5GWZzXnDAxmD3NXvI,3588
|
|
37
41
|
puzzle_solver/puzzles/tents/tents.py,sha256=iyVK2WXfIT5j_9qqlQg0WmwvixwXlZSsHGK3XA-KpII,6283
|
|
38
42
|
puzzle_solver/puzzles/thermometers/thermometers.py,sha256=nsvJZkm7G8FALT27bpaB0lv5E_AWawqmvapQI8QcYXw,4015
|
|
39
43
|
puzzle_solver/puzzles/towers/towers.py,sha256=QvL0Pp-Z2ewCeq9ZkNrh8MShKOh-Y52sFBSudve68wk,6496
|
|
40
44
|
puzzle_solver/puzzles/tracks/tracks.py,sha256=98xds9SKNqtOLFTRUX_KSMC7XYmZo567LOFeqotVQaM,7237
|
|
41
45
|
puzzle_solver/puzzles/undead/undead.py,sha256=IrCUfzQFBem658P5KKqldG7vd2TugTHehcwseCarerM,6604
|
|
46
|
+
puzzle_solver/puzzles/unequal/unequal.py,sha256=ExY2XDCrqROCDpRLfHo8uVr1zuli1QvbCdNCiDhlCac,6978
|
|
42
47
|
puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUyNhGSAu7cQ,3827
|
|
43
48
|
puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
|
|
44
|
-
multi_puzzle_solver-0.9.
|
|
45
|
-
multi_puzzle_solver-0.9.
|
|
46
|
-
multi_puzzle_solver-0.9.
|
|
47
|
-
multi_puzzle_solver-0.9.
|
|
49
|
+
multi_puzzle_solver-0.9.18.dist-info/METADATA,sha256=q9i19rwenN_tEUfhsP8ayNEVfeKcRTm4xK4AmWyzw9M,163560
|
|
50
|
+
multi_puzzle_solver-0.9.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
51
|
+
multi_puzzle_solver-0.9.18.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
|
|
52
|
+
multi_puzzle_solver-0.9.18.dist-info/RECORD,,
|
puzzle_solver/__init__.py
CHANGED
|
@@ -7,6 +7,7 @@ from puzzle_solver.puzzles.chess_range import chess_solo as chess_solo_solver
|
|
|
7
7
|
from puzzle_solver.puzzles.chess_range import chess_melee as chess_melee_solver
|
|
8
8
|
from puzzle_solver.puzzles.dominosa import dominosa as dominosa_solver
|
|
9
9
|
from puzzle_solver.puzzles.filling import filling as filling_solver
|
|
10
|
+
from puzzle_solver.puzzles.galaxies import galaxies as galaxies_solver
|
|
10
11
|
from puzzle_solver.puzzles.guess import guess as guess_solver
|
|
11
12
|
from puzzle_solver.puzzles.inertia import inertia as inertia_solver
|
|
12
13
|
from puzzle_solver.puzzles.kakurasu import kakurasu as kakurasu_solver
|
|
@@ -22,6 +23,7 @@ from puzzle_solver.puzzles.pearl import pearl as pearl_solver
|
|
|
22
23
|
from puzzle_solver.puzzles.range import range as range_solver
|
|
23
24
|
from puzzle_solver.puzzles.signpost import signpost as signpost_solver
|
|
24
25
|
from puzzle_solver.puzzles.singles import singles as singles_solver
|
|
26
|
+
from puzzle_solver.puzzles.slant import slant as slant_solver
|
|
25
27
|
from puzzle_solver.puzzles.star_battle import star_battle as star_battle_solver
|
|
26
28
|
from puzzle_solver.puzzles.star_battle import star_battle_shapeless as star_battle_shapeless_solver
|
|
27
29
|
from puzzle_solver.puzzles.stitches import stitches as stitches_solver
|
|
@@ -31,8 +33,9 @@ from puzzle_solver.puzzles.thermometers import thermometers as thermometers_solv
|
|
|
31
33
|
from puzzle_solver.puzzles.towers import towers as towers_solver
|
|
32
34
|
from puzzle_solver.puzzles.tracks import tracks as tracks_solver
|
|
33
35
|
from puzzle_solver.puzzles.undead import undead as undead_solver
|
|
36
|
+
from puzzle_solver.puzzles.unequal import unequal as unequal_solver
|
|
34
37
|
from puzzle_solver.puzzles.unruly import unruly as unruly_solver
|
|
35
38
|
|
|
36
39
|
from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
|
|
37
40
|
|
|
38
|
-
__version__ = '0.9.
|
|
41
|
+
__version__ = '0.9.18'
|
|
@@ -97,6 +97,8 @@ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any,
|
|
|
97
97
|
|
|
98
98
|
vs = vars_to_force
|
|
99
99
|
v_count = len(vs)
|
|
100
|
+
if v_count <= 2: # graph must have at least 3 nodes to possibly be disconnected
|
|
101
|
+
return {}
|
|
100
102
|
# =V model variables, one for each variable
|
|
101
103
|
is_root: dict[Pos, cp_model.IntVar] = {} # =V, defines the unique root
|
|
102
104
|
prefix_zero: dict[Pos, cp_model.IntVar] = {} # =V, used for picking the unique root
|
|
@@ -128,7 +130,7 @@ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any,
|
|
|
128
130
|
for p in keys_in_order:
|
|
129
131
|
and_constraint(model, is_root[p], [vs[p], prefix_zero[p]])
|
|
130
132
|
# Exactly one root:
|
|
131
|
-
model.Add(sum(is_root.values())
|
|
133
|
+
model.Add(sum(is_root.values()) <= 1)
|
|
132
134
|
|
|
133
135
|
# For each node i, consider only neighbors
|
|
134
136
|
for i, pi in enumerate(keys_in_order):
|
|
@@ -173,10 +175,15 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
|
|
|
173
175
|
tree_edge: dict[tuple[Pos, Pos], cp_model.IntVar] = {} # tree_edge[p, q] means p is parent of q
|
|
174
176
|
prefix_name = "no_loops_"
|
|
175
177
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
parent_of = {p: [] for p in vs.keys()}
|
|
179
|
+
children_of = {p: [] for p in vs.keys()}
|
|
180
|
+
for p in vs.keys():
|
|
181
|
+
for q in vs.keys():
|
|
182
|
+
if p == q:
|
|
183
|
+
continue
|
|
184
|
+
if is_neighbor(p, q):
|
|
185
|
+
parent_of[q].append(p)
|
|
186
|
+
children_of[p].append(q)
|
|
180
187
|
|
|
181
188
|
keys_in_order = list(vs.keys()) # must enforce some ordering
|
|
182
189
|
node_to_idx: dict[Pos, int] = {p: i+1 for i, p in enumerate(keys_in_order)}
|
|
@@ -195,16 +202,13 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
|
|
|
195
202
|
model.Add(block_root[p] == node_to_idx[p]).OnlyEnforceIf([is_root[p]])
|
|
196
203
|
|
|
197
204
|
for p in keys_in_order:
|
|
198
|
-
for q in
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
# a tree_edge[p, q] means p is parent of q thus h[q] = h[p] + 1
|
|
206
|
-
model.Add(node_height[q] == node_height[p] + 1).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
207
|
-
model.Add(block_root[q] == block_root[p]).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
205
|
+
for q in children_of[p]:
|
|
206
|
+
tree_edge[(p, q)] = model.NewBoolVar(f"{prefix_name}tree_edge[{p} is parent of {q}]")
|
|
207
|
+
model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[p].Not()])
|
|
208
|
+
model.Add(tree_edge[(p, q)] == 0).OnlyEnforceIf([vs[q].Not()])
|
|
209
|
+
# a tree_edge[p, q] means p is parent of q thus h[q] = h[p] + 1
|
|
210
|
+
model.Add(node_height[q] == node_height[p] + 1).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
211
|
+
model.Add(block_root[q] == block_root[p]).OnlyEnforceIf([tree_edge[(p, q)]])
|
|
208
212
|
|
|
209
213
|
for (p, q) in tree_edge:
|
|
210
214
|
if (q, p) in tree_edge:
|
|
@@ -212,14 +216,14 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
|
|
|
212
216
|
model.Add(tree_edge[(p, q)] == 1).OnlyEnforceIf([tree_edge[(q, p)].Not(), vs[p], vs[q]])
|
|
213
217
|
|
|
214
218
|
for p in keys_in_order:
|
|
215
|
-
for p_child in children_of
|
|
219
|
+
for p_child in children_of[p]:
|
|
216
220
|
# i am root thus I point to all my children
|
|
217
221
|
model.Add(tree_edge[(p, p_child)] == 1).OnlyEnforceIf([is_root[p], vs[p_child]])
|
|
218
|
-
for p_parent in parent_of
|
|
222
|
+
for p_parent in parent_of[p]:
|
|
219
223
|
# i am root thus I have no parent
|
|
220
224
|
model.Add(tree_edge[(p_parent, p)] == 0).OnlyEnforceIf([is_root[p]])
|
|
221
225
|
# every active node has exactly 1 parent except root has none
|
|
222
|
-
model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of
|
|
226
|
+
model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of[p]] + [vs[p].Not(), is_root[p]])
|
|
223
227
|
|
|
224
228
|
# now each subgraph has directions where each non-root points to a single parent (and its value is parent+1).
|
|
225
229
|
# to break cycles, every non-root active node must be > all neighbors that arent children
|
|
@@ -231,5 +235,7 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
|
|
|
231
235
|
all_new_vars[f"{prefix_name}tree_edge[{k[0]} is parent of {k[1]}]"] = v
|
|
232
236
|
for k, v in node_height.items():
|
|
233
237
|
all_new_vars[f"{prefix_name}node_height[{k}]"] = v
|
|
238
|
+
for k, v in block_root.items():
|
|
239
|
+
all_new_vars[f"{prefix_name}block_root[{k}]"] = v
|
|
234
240
|
|
|
235
241
|
return all_new_vars
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Iterable, Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, Direction, get_next_pos, in_bounds, get_opposite_direction, get_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_numpy(galaxies: np.ndarray) -> list[tuple[Pos, ...]]:
|
|
12
|
+
result = defaultdict(list)
|
|
13
|
+
for pos, arr_id in np.ndenumerate(galaxies):
|
|
14
|
+
if not arr_id.strip():
|
|
15
|
+
continue
|
|
16
|
+
result[arr_id].append(get_pos(x=pos[1], y=pos[0]))
|
|
17
|
+
return [positions for _, positions in sorted(result.items(), key=lambda x: x[0])]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Board:
|
|
21
|
+
def __init__(self, galaxies: Union[list[tuple[Pos, ...]], np.ndarray], V: int = None, H: int = None):
|
|
22
|
+
if isinstance(galaxies, np.ndarray):
|
|
23
|
+
V, H = galaxies.shape
|
|
24
|
+
galaxies = parse_numpy(galaxies)
|
|
25
|
+
else:
|
|
26
|
+
assert V is not None and H is not None, 'V and H must be provided if galaxies is not a numpy array'
|
|
27
|
+
assert V >= 1 and H >= 1, 'V and H must be at least 1'
|
|
28
|
+
assert all(isinstance(galaxy, Iterable) for galaxy in galaxies), 'galaxies must be a list of Iterables'
|
|
29
|
+
assert all(len(galaxy) in [1, 2, 4] for galaxy in galaxies), 'each galaxy must be exactly 1, 2, or 4 positions'
|
|
30
|
+
self.V = V
|
|
31
|
+
self.H = H
|
|
32
|
+
self.n_galaxies = len(galaxies)
|
|
33
|
+
self.galaxies = galaxies
|
|
34
|
+
self.prelocated_positions: set[Pos] = {pos: i for i, galaxy in enumerate(galaxies) for pos in galaxy}
|
|
35
|
+
|
|
36
|
+
self.model = cp_model.CpModel()
|
|
37
|
+
self.pos_to_galaxy: dict[Pos, dict[int, cp_model.IntVar]] = {p: {} for p in get_all_pos(V, H)} # each position can be part of exactly one out of many possible galaxies
|
|
38
|
+
self.allocated_pairs: set[tuple[Pos, Pos]] = set() # each pair is allocated to exactly one galaxy
|
|
39
|
+
|
|
40
|
+
self.create_vars()
|
|
41
|
+
self.add_all_constraints()
|
|
42
|
+
|
|
43
|
+
def create_vars(self):
|
|
44
|
+
for i in range(self.n_galaxies):
|
|
45
|
+
galaxy = self.galaxies[i]
|
|
46
|
+
if len(galaxy) == 1:
|
|
47
|
+
p1, p2 = galaxy[0], galaxy[0]
|
|
48
|
+
elif len(galaxy) == 2:
|
|
49
|
+
p1, p2 = galaxy[0], galaxy[1]
|
|
50
|
+
elif len(galaxy) == 4:
|
|
51
|
+
p1, p2 = galaxy[0], galaxy[3] # [1] and [2] will be linked with symmetry
|
|
52
|
+
self.expand_galaxy(p1, p2, i)
|
|
53
|
+
|
|
54
|
+
def expand_galaxy(self, p1: Pos, p2: Pos, galaxy_idx: int):
|
|
55
|
+
if (p1, p2) in self.allocated_pairs or (p2, p1) in self.allocated_pairs:
|
|
56
|
+
return
|
|
57
|
+
if p1 in self.prelocated_positions and self.prelocated_positions[p1] != galaxy_idx:
|
|
58
|
+
return
|
|
59
|
+
if p2 in self.prelocated_positions and self.prelocated_positions[p2] != galaxy_idx:
|
|
60
|
+
return
|
|
61
|
+
if not in_bounds(p1, self.V, self.H) or not in_bounds(p2, self.V, self.H):
|
|
62
|
+
return
|
|
63
|
+
self.bind_pair(p1, p2, galaxy_idx)
|
|
64
|
+
# symmetrically expand the galaxy until illegal position is hit
|
|
65
|
+
for direction in [Direction.RIGHT, Direction.UP, Direction.DOWN, Direction.LEFT]:
|
|
66
|
+
symmetrical_direction = get_opposite_direction(direction)
|
|
67
|
+
new_p1 = get_next_pos(p1, direction)
|
|
68
|
+
new_p2 = get_next_pos(p2, symmetrical_direction)
|
|
69
|
+
self.expand_galaxy(new_p1, new_p2, galaxy_idx)
|
|
70
|
+
|
|
71
|
+
def bind_pair(self, p1: Pos, p2: Pos, galaxy_idx: int):
|
|
72
|
+
assert galaxy_idx not in self.pos_to_galaxy[p1], f'p1={p1} already has galaxy idx={galaxy_idx}'
|
|
73
|
+
assert galaxy_idx not in self.pos_to_galaxy[p2], f'p2={p2} already has galaxy idx={galaxy_idx}'
|
|
74
|
+
self.allocated_pairs.add((p1, p2))
|
|
75
|
+
v1 = self.model.NewBoolVar(f'{p1}:{galaxy_idx}')
|
|
76
|
+
v2 = self.model.NewBoolVar(f'{p2}:{galaxy_idx}')
|
|
77
|
+
self.model.Add(v1 == v2)
|
|
78
|
+
self.pos_to_galaxy[p1][galaxy_idx] = v1
|
|
79
|
+
self.pos_to_galaxy[p2][galaxy_idx] = v2
|
|
80
|
+
|
|
81
|
+
def add_all_constraints(self):
|
|
82
|
+
galaxy_vars = {}
|
|
83
|
+
for pos in get_all_pos(self.V, self.H):
|
|
84
|
+
pos_vars = list(self.pos_to_galaxy[pos].values())
|
|
85
|
+
self.model.AddExactlyOne(pos_vars)
|
|
86
|
+
for galaxy_idx, v in self.pos_to_galaxy[pos].items():
|
|
87
|
+
galaxy_vars.setdefault(galaxy_idx, {})[pos] = v
|
|
88
|
+
for galaxy_idx, pos_vars in galaxy_vars.items():
|
|
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)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/galaxies.html and converts them to a json file.
|
|
3
|
+
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
|
+
The output json is used in the test_solve.py file to test the solver.
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import numpy as np
|
|
8
|
+
cv = None
|
|
9
|
+
Image = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_lines(bw):
|
|
13
|
+
# Create the images that will use to extract the horizontal and vertical lines
|
|
14
|
+
horizontal = np.copy(bw)
|
|
15
|
+
vertical = np.copy(bw)
|
|
16
|
+
|
|
17
|
+
cols = horizontal.shape[1]
|
|
18
|
+
horizontal_size = cols // 5
|
|
19
|
+
# Create structure element for extracting horizontal lines through morphology operations
|
|
20
|
+
horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
|
|
21
|
+
horizontal = cv.erode(horizontal, horizontalStructure)
|
|
22
|
+
horizontal = cv.dilate(horizontal, horizontalStructure)
|
|
23
|
+
horizontal_means = np.mean(horizontal, axis=1)
|
|
24
|
+
horizontal_cutoff = np.percentile(horizontal_means, 50)
|
|
25
|
+
# location where the horizontal lines are
|
|
26
|
+
horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
|
|
27
|
+
# print(f"horizontal_idx: {horizontal_idx}")
|
|
28
|
+
height = len(horizontal_idx)
|
|
29
|
+
# show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
|
|
30
|
+
|
|
31
|
+
rows = vertical.shape[0]
|
|
32
|
+
verticalsize = rows // 5
|
|
33
|
+
# Create structure element for extracting vertical lines through morphology operations
|
|
34
|
+
verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
|
|
35
|
+
vertical = cv.erode(vertical, verticalStructure)
|
|
36
|
+
vertical = cv.dilate(vertical, verticalStructure)
|
|
37
|
+
vertical_means = np.mean(vertical, axis=0)
|
|
38
|
+
vertical_cutoff = np.percentile(vertical_means, 50)
|
|
39
|
+
vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
|
|
40
|
+
# print(f"vertical_idx: {vertical_idx}")
|
|
41
|
+
width = len(vertical_idx)
|
|
42
|
+
# print(f"height: {height}, width: {width}")
|
|
43
|
+
# print(f"vertical_means: {vertical_means}")
|
|
44
|
+
# show_wait_destroy("vertical", vertical) # this has the vertical lines
|
|
45
|
+
|
|
46
|
+
vertical = cv.bitwise_not(vertical)
|
|
47
|
+
# show_wait_destroy("vertical_bit", vertical)
|
|
48
|
+
|
|
49
|
+
return horizontal_idx, vertical_idx
|
|
50
|
+
|
|
51
|
+
def show_wait_destroy(winname, img):
|
|
52
|
+
cv.imshow(winname, img)
|
|
53
|
+
cv.moveWindow(winname, 500, 0)
|
|
54
|
+
cv.waitKey(0)
|
|
55
|
+
cv.destroyWindow(winname)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def mean_consecutives(arr: np.ndarray) -> np.ndarray:
|
|
59
|
+
"""if a sequence of values is consecutive, then average the values"""
|
|
60
|
+
sums = []
|
|
61
|
+
counts = []
|
|
62
|
+
for i in range(len(arr)):
|
|
63
|
+
if i == 0:
|
|
64
|
+
sums.append(arr[i])
|
|
65
|
+
counts.append(1)
|
|
66
|
+
elif arr[i] == arr[i-1] + 1:
|
|
67
|
+
sums[-1] += arr[i]
|
|
68
|
+
counts[-1] += 1
|
|
69
|
+
else:
|
|
70
|
+
sums.append(arr[i])
|
|
71
|
+
counts.append(1)
|
|
72
|
+
return np.array(sums) // np.array(counts)
|
|
73
|
+
|
|
74
|
+
def main(image):
|
|
75
|
+
global Image
|
|
76
|
+
global cv
|
|
77
|
+
import matplotlib.pyplot as plt
|
|
78
|
+
from PIL import Image as Image_module
|
|
79
|
+
import cv2 as cv_module
|
|
80
|
+
Image = Image_module
|
|
81
|
+
cv = cv_module
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
image_path = Path(image)
|
|
85
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
86
|
+
src = cv.imread(image, cv.IMREAD_COLOR)
|
|
87
|
+
assert src is not None, f'Error opening image: {image}'
|
|
88
|
+
if len(src.shape) != 2:
|
|
89
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
90
|
+
else:
|
|
91
|
+
gray = src
|
|
92
|
+
# now the image is in grayscale
|
|
93
|
+
|
|
94
|
+
# Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
|
|
95
|
+
gray = cv.bitwise_not(gray)
|
|
96
|
+
bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
97
|
+
cv.THRESH_BINARY, 15, -2)
|
|
98
|
+
# show_wait_destroy("binary", bw)
|
|
99
|
+
|
|
100
|
+
# show_wait_destroy("src", src)
|
|
101
|
+
horizontal_idx, vertical_idx = extract_lines(bw)
|
|
102
|
+
horizontal_idx = mean_consecutives(horizontal_idx)
|
|
103
|
+
vertical_idx = mean_consecutives(vertical_idx)
|
|
104
|
+
height = len(horizontal_idx)
|
|
105
|
+
width = len(vertical_idx)
|
|
106
|
+
print(f"height: {height}, width: {width}")
|
|
107
|
+
print(f"horizontal_idx: {horizontal_idx}")
|
|
108
|
+
print(f"vertical_idx: {vertical_idx}")
|
|
109
|
+
arr = np.zeros((height - 1, width - 1), dtype=object)
|
|
110
|
+
output = {(dx, dy): arr.copy() for dx in [-1, 0, 1] for dy in [-1, 0, 1]}
|
|
111
|
+
hists = {(dx, dy): {} for dx in [-1, 0, 1] for dy in [-1, 0, 1]}
|
|
112
|
+
for j in range(height - 1):
|
|
113
|
+
for i in range(width - 1):
|
|
114
|
+
hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
|
|
115
|
+
vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
|
|
116
|
+
hidx1 = max(0, hidx1 - 2)
|
|
117
|
+
hidx2 = min(src.shape[0], hidx2 + 4)
|
|
118
|
+
vidx1 = max(0, vidx1 - 2)
|
|
119
|
+
vidx2 = min(src.shape[1], vidx2 + 4)
|
|
120
|
+
cell = src[hidx1:hidx2, vidx1:vidx2]
|
|
121
|
+
mid_x = cell.shape[1] // 2
|
|
122
|
+
mid_y = cell.shape[0] // 2
|
|
123
|
+
cell = cv.bitwise_not(cell) # invert colors
|
|
124
|
+
for dx in [-1, 0, 1]:
|
|
125
|
+
for dy in [-1, 0, 1]:
|
|
126
|
+
mx = mid_x + dx*mid_x
|
|
127
|
+
my = mid_y + dy*mid_y
|
|
128
|
+
mx0 = max(0, mx - 5)
|
|
129
|
+
mx1 = min(cell.shape[1], mx + 5)
|
|
130
|
+
my0 = max(0, my - 5)
|
|
131
|
+
my1 = min(cell.shape[0], my + 5)
|
|
132
|
+
cell_part = cell[my0:my1, mx0:mx1]
|
|
133
|
+
hists[(dx, dy)][j, i] = np.sum(cell_part)
|
|
134
|
+
# top = cell[0:10, mid_y-5:mid_y+5]
|
|
135
|
+
# hists['top'][j, i] = np.sum(top)
|
|
136
|
+
# left = cell[mid_x-5:mid_x+5, 0:10]
|
|
137
|
+
# hists['left'][j, i] = np.sum(left)
|
|
138
|
+
# right = cell[mid_x-5:mid_x+5, -10:]
|
|
139
|
+
# hists['right'][j, i] = np.sum(right)
|
|
140
|
+
# bottom = cell[-10:, mid_y-5:mid_y+5]
|
|
141
|
+
# hists['bottom'][j, i] = np.sum(bottom)
|
|
142
|
+
# print(f"cell_{i}_{j}, ", [hists[(dx, dy)][j, i] for dx in [-1, 0, 1] for dy in [-1, 0, 1]])
|
|
143
|
+
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
144
|
+
|
|
145
|
+
fig, axs = plt.subplots(3, 3)
|
|
146
|
+
target = 100
|
|
147
|
+
for dx in [-1, 0, 1]:
|
|
148
|
+
for dy in [-1, 0, 1]:
|
|
149
|
+
axs[dx+1, dy+1].hist(list(hists[(dx, dy)].values()), bins=100)
|
|
150
|
+
axs[dx+1, dy+1].set_title(f'{dx},{dy}')
|
|
151
|
+
# target = np.mean(list(hists[(dx, dy)].values()))
|
|
152
|
+
axs[dx+1, dy+1].axvline(target, color='red')
|
|
153
|
+
# plt.show()
|
|
154
|
+
# 1/0
|
|
155
|
+
for j in range(height - 1):
|
|
156
|
+
for i in range(width - 1):
|
|
157
|
+
sums_str = ''
|
|
158
|
+
out_str = ''
|
|
159
|
+
for dx in [-1, 0, 1]:
|
|
160
|
+
out_xpart = 'L' if dx == -1 else 'C' if dx == 0 else 'R'
|
|
161
|
+
for dy in [-1, 0, 1]:
|
|
162
|
+
out_ypart = 'T' if dy == -1 else 'C' if dy == 0 else 'B'
|
|
163
|
+
sums_str += str(hists[(dx, dy)][j, i]) + ' '
|
|
164
|
+
if hists[(dx, dy)][j, i] < target:
|
|
165
|
+
out_str += (out_xpart + out_ypart + ' ')
|
|
166
|
+
output[(dx, dy)][j, i] = 1
|
|
167
|
+
print(f"cell_{j}_{i}", end=': ')
|
|
168
|
+
print(out_str)
|
|
169
|
+
print(' Sums: ', sums_str)
|
|
170
|
+
|
|
171
|
+
out = np.full_like(output[(0, 0)], ' ', dtype='U2')
|
|
172
|
+
counter = 0
|
|
173
|
+
for j in range(out.shape[0]):
|
|
174
|
+
for i in range(out.shape[1]):
|
|
175
|
+
for dx in [-1, 0, 1]:
|
|
176
|
+
for dy in [-1, 0, 1]:
|
|
177
|
+
if output[(dx, dy)][j, i] == 1:
|
|
178
|
+
# out[j, i] = dxdy_to_char[(dx, dy)]
|
|
179
|
+
if dx == 0 and dy == 0: # single point
|
|
180
|
+
out[j, i] = str(counter).zfill(2)
|
|
181
|
+
counter += 1
|
|
182
|
+
elif dx == 0 and dy == 1: # vertical
|
|
183
|
+
out[j, i] = str(counter).zfill(2)
|
|
184
|
+
out[j+1, i] = str(counter).zfill(2)
|
|
185
|
+
counter += 1
|
|
186
|
+
elif dx == 1 and dy == 0: # horizontal
|
|
187
|
+
out[j, i] = str(counter).zfill(2)
|
|
188
|
+
out[j, i+1] = str(counter).zfill(2)
|
|
189
|
+
counter += 1
|
|
190
|
+
elif dx == 1 and dy == 1: # 2 by 2
|
|
191
|
+
out[j, i] = str(counter).zfill(2)
|
|
192
|
+
out[j+1, i] = str(counter).zfill(2)
|
|
193
|
+
out[j, i+1] = str(counter).zfill(2)
|
|
194
|
+
out[j+1, i+1] = str(counter).zfill(2)
|
|
195
|
+
counter += 1
|
|
196
|
+
|
|
197
|
+
# print(out)
|
|
198
|
+
with open(output_path, 'w') as f:
|
|
199
|
+
f.write('[\n')
|
|
200
|
+
for i, row in enumerate(out):
|
|
201
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
202
|
+
if i != len(out) - 1:
|
|
203
|
+
f.write(',')
|
|
204
|
+
f.write('\n')
|
|
205
|
+
f.write(']')
|
|
206
|
+
print('output json: ', output_path)
|
|
207
|
+
|
|
208
|
+
if __name__ == '__main__':
|
|
209
|
+
# to run this script and visualize the output, in the root run:
|
|
210
|
+
# python .\src\puzzle_solver\puzzles\galaxies\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
211
|
+
# main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
|
|
212
|
+
# main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
|
|
213
|
+
# main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
|
|
214
|
+
# main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
|
|
215
|
+
# main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
|
|
216
|
+
main(Path(__file__).parent / 'input_output' / 'eofodowmumgzzdkopzlpzkzaezrhefoezejvdtxrzmpgozzemxjdcigcqzrk.png')
|
|
@@ -0,0 +1,133 @@
|
|
|
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.
|
|
3
|
+
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
|
+
The output json is used in the test_solve.py file to test the solver.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json, itertools
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import numpy as np
|
|
10
|
+
cv = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_lines(bw):
|
|
14
|
+
horizontal = np.copy(bw)
|
|
15
|
+
vertical = np.copy(bw)
|
|
16
|
+
|
|
17
|
+
cols = horizontal.shape[1]
|
|
18
|
+
horizontal_size = max(5, cols // 20)
|
|
19
|
+
h_kernel = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
|
|
20
|
+
horizontal = cv.erode(horizontal, h_kernel)
|
|
21
|
+
horizontal = cv.dilate(horizontal, h_kernel)
|
|
22
|
+
h_means = np.mean(horizontal, axis=1)
|
|
23
|
+
h_idx = np.where(h_means > np.percentile(h_means, 70))[0]
|
|
24
|
+
|
|
25
|
+
rows = vertical.shape[0]
|
|
26
|
+
verticalsize = max(5, rows // 20)
|
|
27
|
+
v_kernel = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
|
|
28
|
+
vertical = cv.erode(vertical, v_kernel)
|
|
29
|
+
vertical = cv.dilate(vertical, v_kernel)
|
|
30
|
+
v_means = np.mean(vertical, axis=0)
|
|
31
|
+
v_idx = np.where(v_means > np.percentile(v_means, 70))[0]
|
|
32
|
+
return h_idx, v_idx
|
|
33
|
+
|
|
34
|
+
def mean_consecutives(arr):
|
|
35
|
+
if len(arr) == 0:
|
|
36
|
+
return arr
|
|
37
|
+
sums, counts = [arr[0]], [1]
|
|
38
|
+
for k in arr[1:]:
|
|
39
|
+
if k == sums[-1] + counts[-1]:
|
|
40
|
+
sums[-1] += k; counts[-1] += 1
|
|
41
|
+
else:
|
|
42
|
+
sums.append(k); counts.append(1)
|
|
43
|
+
return np.array(sums)//np.array(counts)
|
|
44
|
+
|
|
45
|
+
def main(img_path):
|
|
46
|
+
global cv
|
|
47
|
+
import cv2 as cv_module
|
|
48
|
+
cv = cv_module
|
|
49
|
+
image_path = Path(img_path)
|
|
50
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
51
|
+
src = cv.imread(img_path, cv.IMREAD_COLOR)
|
|
52
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
53
|
+
inv = cv.bitwise_not(gray)
|
|
54
|
+
bw = cv.adaptiveThreshold(inv, 255, cv.ADAPTIVE_THRESH_MEAN_C, cv.THRESH_BINARY, 15, -2)
|
|
55
|
+
h_idx, v_idx = extract_lines(bw)
|
|
56
|
+
h_idx = mean_consecutives(h_idx)
|
|
57
|
+
v_idx = mean_consecutives(v_idx)
|
|
58
|
+
|
|
59
|
+
# Estimate grid cell and circle radii
|
|
60
|
+
cell = int(np.median(np.diff(h_idx))) if len(h_idx) > 3 else 40
|
|
61
|
+
r_min = max(6, int(cell*0.18))
|
|
62
|
+
r_max = int(cell*0.52)
|
|
63
|
+
|
|
64
|
+
# Global Hough detection with parameter sweep
|
|
65
|
+
blur = cv.medianBlur(gray, 5)
|
|
66
|
+
detected = [] # x, y, r
|
|
67
|
+
|
|
68
|
+
for dp, p2 in itertools.product([1.2, 1.0], [20, 18, 16, 14, 12]):
|
|
69
|
+
circles = cv.HoughCircles(
|
|
70
|
+
blur, cv.HOUGH_GRADIENT, dp=dp, minDist=max(12, int(cell*0.75)),
|
|
71
|
+
param1=120, param2=p2, minRadius=r_min, maxRadius=r_max
|
|
72
|
+
)
|
|
73
|
+
if circles is not None:
|
|
74
|
+
for (x, y, r) in np.round(circles[0, :]).astype(int):
|
|
75
|
+
detected.append((x, y, r))
|
|
76
|
+
|
|
77
|
+
# Non-maximum suppression to remove duplicates
|
|
78
|
+
def nms(circles, dist_thr=10):
|
|
79
|
+
kept = []
|
|
80
|
+
for x,y,r in sorted(circles, key=lambda c: -c[2]):
|
|
81
|
+
if all((x-kx)**2+(y-ky)**2 > dist_thr**2 for kx,ky,kr in kept):
|
|
82
|
+
kept.append((x,y,r))
|
|
83
|
+
return kept
|
|
84
|
+
|
|
85
|
+
detected = nms(detected, dist_thr=max(10,int(cell*0.4)))
|
|
86
|
+
|
|
87
|
+
# Map circle centers to nearest intersection
|
|
88
|
+
H, W = len(h_idx), len(v_idx)
|
|
89
|
+
presence = np.zeros((H, W), dtype=int)
|
|
90
|
+
|
|
91
|
+
# Build KD-like search by grid proximity
|
|
92
|
+
tol = int(cell*0.5) # max distance from an intersection to accept a circle
|
|
93
|
+
for (cx, cy, r) in detected:
|
|
94
|
+
# find nearest indices
|
|
95
|
+
j = int(np.argmin(np.abs(h_idx - cy)))
|
|
96
|
+
i = int(np.argmin(np.abs(v_idx - cx)))
|
|
97
|
+
if abs(h_idx[j]-cy) <= tol and abs(v_idx[i]-cx) <= tol:
|
|
98
|
+
presence[j, i] = 1
|
|
99
|
+
|
|
100
|
+
with open(output_path, 'w') as f:
|
|
101
|
+
f.write('[\n')
|
|
102
|
+
for i, row in enumerate(presence):
|
|
103
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
104
|
+
if i != len(presence) - 1:
|
|
105
|
+
f.write(',')
|
|
106
|
+
f.write('\n')
|
|
107
|
+
f.write(']')
|
|
108
|
+
print('output json: ', output_path)
|
|
109
|
+
print('output json: ', output_path)
|
|
110
|
+
print('output json: ', output_path)
|
|
111
|
+
|
|
112
|
+
overlay = src.copy()
|
|
113
|
+
for (cx, cy, r) in detected:
|
|
114
|
+
cv.circle(overlay, (cx, cy), r, (255,0,0), 2)
|
|
115
|
+
for j, y in enumerate(h_idx):
|
|
116
|
+
for i, x in enumerate(v_idx):
|
|
117
|
+
color = (0,0,255) if presence[j,i]==1 else (0,255,0)
|
|
118
|
+
cv.circle(overlay, (int(x), int(y)), 4, color, 2)
|
|
119
|
+
show_wait_destroy("overlay", overlay)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def show_wait_destroy(winname, img):
|
|
124
|
+
cv.imshow(winname, img)
|
|
125
|
+
cv.moveWindow(winname, 500, 0)
|
|
126
|
+
cv.waitKey(0)
|
|
127
|
+
cv.destroyWindow(winname)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == '__main__':
|
|
131
|
+
# to run this script and visualize the output, in the root run:
|
|
132
|
+
# python .\src\puzzle_solver\puzzles\slant\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
133
|
+
main(Path(__file__).parent / 'input_output' / '23131379850022376.png')
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from typing import Literal, Optional, Union
|
|
2
|
+
from dataclasses import dataclass
|
|
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, in_bounds, get_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import force_no_loops, generic_solve_all, SingleSolution
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Node:
|
|
13
|
+
"""The grid is represented as a graph of cells connected to corners."""
|
|
14
|
+
node_type: Union[Literal["Cell"], Literal["Corner"]]
|
|
15
|
+
pos: Pos
|
|
16
|
+
slant: Union[Literal["//"], Literal["\\"], None]
|
|
17
|
+
|
|
18
|
+
def get_neighbors(self, board_nodes: dict[tuple[str, Pos, Optional[str]], "Node"]) -> list["Node"]:
|
|
19
|
+
if self.node_type == "Cell" and self.slant == "//":
|
|
20
|
+
n1 = board_nodes[("Corner", get_pos(self.pos.x+1, self.pos.y), None)]
|
|
21
|
+
n2 = board_nodes[("Corner", get_pos(self.pos.x, self.pos.y+1), None)]
|
|
22
|
+
return [n1, n2]
|
|
23
|
+
elif self.node_type == "Cell" and self.slant == "\\":
|
|
24
|
+
n1 = board_nodes[("Corner", get_pos(self.pos.x, self.pos.y), None)]
|
|
25
|
+
n2 = board_nodes[("Corner", get_pos(self.pos.x+1, self.pos.y+1), None)]
|
|
26
|
+
return [n1, n2]
|
|
27
|
+
elif self.node_type == "Corner":
|
|
28
|
+
# 4 cells, 2 cells per slant
|
|
29
|
+
n1 = ("Cell", get_pos(self.pos.x-1, self.pos.y-1), "\\")
|
|
30
|
+
n2 = ("Cell", get_pos(self.pos.x, self.pos.y-1), "//")
|
|
31
|
+
n3 = ("Cell", get_pos(self.pos.x-1, self.pos.y), "//")
|
|
32
|
+
n4 = ("Cell", get_pos(self.pos.x, self.pos.y), "\\")
|
|
33
|
+
return {board_nodes[n] for n in [n1, n2, n3, n4] if n in board_nodes}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Board:
|
|
37
|
+
def __init__(self, numbers: Union[list[tuple[Pos, int]], np.array], V: int = None, H: int = None):
|
|
38
|
+
if isinstance(numbers, np.ndarray):
|
|
39
|
+
V, H = numbers.shape
|
|
40
|
+
V = V - 1
|
|
41
|
+
H = H - 1
|
|
42
|
+
numbers = [(get_pos(x=pos[1], y=pos[0]), int(d)) for pos, d in np.ndenumerate(numbers) if str(d).isdecimal()]
|
|
43
|
+
numbers = [(p, n) for p, n in numbers if n >= 0]
|
|
44
|
+
else:
|
|
45
|
+
assert V is not None and H is not None, 'V and H must be provided if numbers is not a numpy array'
|
|
46
|
+
assert V >= 1 and H >= 1, 'V and H must be at least 1'
|
|
47
|
+
assert all(isinstance(number, int) and number >= 0 for (pos, number) in numbers), 'numbers must be a list of integers'
|
|
48
|
+
self.V = V
|
|
49
|
+
self.H = H
|
|
50
|
+
self.numbers = numbers
|
|
51
|
+
self.pos_to_number: dict[Pos, int] = {pos: number for pos, number in numbers}
|
|
52
|
+
|
|
53
|
+
self.model = cp_model.CpModel()
|
|
54
|
+
self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
|
|
55
|
+
self.nodes: dict[Node, cp_model.IntVar] = {}
|
|
56
|
+
self.neighbor_dict: dict[Node, set[Node]] = {}
|
|
57
|
+
|
|
58
|
+
self.create_vars()
|
|
59
|
+
self.add_all_constraints()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_vars(self):
|
|
63
|
+
for pos in get_all_pos(self.V, self.H):
|
|
64
|
+
self.model_vars[(pos, '//')] = self.model.NewBoolVar(f'{pos}://')
|
|
65
|
+
self.model_vars[(pos, '\\')] = self.model.NewBoolVar(f'{pos}:\\')
|
|
66
|
+
self.model.AddExactlyOne([self.model_vars[(pos, '//')], self.model_vars[(pos, '\\')]])
|
|
67
|
+
for (pos, slant), v in self.model_vars.items():
|
|
68
|
+
self.nodes[Node(node_type="Cell", pos=pos, slant=slant)] = v
|
|
69
|
+
for pos in get_all_pos(self.V + 1, self.H + 1):
|
|
70
|
+
self.nodes[Node(node_type="Corner", pos=pos, slant=None)] = self.model.NewConstant(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def add_all_constraints(self):
|
|
74
|
+
for pos, number in self.pos_to_number.items():
|
|
75
|
+
# pos is a position on the intersection of 4 cells
|
|
76
|
+
# when pos is (xi, yi) then it gets a +1 contribution for each:
|
|
77
|
+
# - cell (xi-1, yi-1) is a "\\"
|
|
78
|
+
# - cell (xi, yi) is a "\\"
|
|
79
|
+
# - cell (xi, yi-1) is a "//"
|
|
80
|
+
# - cell (xi-1, yi) is a "//"
|
|
81
|
+
xi, yi = pos.x, pos.y
|
|
82
|
+
tl_pos = get_pos(xi-1, yi-1)
|
|
83
|
+
br_pos = get_pos(xi, yi)
|
|
84
|
+
tr_pos = get_pos(xi, yi-1)
|
|
85
|
+
bl_pos = get_pos(xi-1, yi)
|
|
86
|
+
tl_var = self.model_vars[(tl_pos, '\\')] if in_bounds(tl_pos, self.V, self.H) else 0
|
|
87
|
+
br_var = self.model_vars[(br_pos, '\\')] if in_bounds(br_pos, self.V, self.H) else 0
|
|
88
|
+
tr_var = self.model_vars[(tr_pos, '//')] if in_bounds(tr_pos, self.V, self.H) else 0
|
|
89
|
+
bl_var = self.model_vars[(bl_pos, '//')] if in_bounds(bl_pos, self.V, self.H) else 0
|
|
90
|
+
self.model.Add(sum([tl_var, tr_var, bl_var, br_var]) == number)
|
|
91
|
+
board_nodes = {(node.node_type, node.pos, node.slant): node for node in self.nodes.keys()}
|
|
92
|
+
self.neighbor_dict = {node: node.get_neighbors(board_nodes) for node in self.nodes.keys()}
|
|
93
|
+
no_loops_vars = force_no_loops(self.model, self.nodes, is_neighbor=lambda n1, n2: n1 in self.neighbor_dict[n2])
|
|
94
|
+
self.no_loops_vars = no_loops_vars
|
|
95
|
+
|
|
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
|
+
# graph = {node: solver.Value(var) for node, var in board.nodes.items()}
|
|
101
|
+
for (pos, s), var in board.model_vars.items():
|
|
102
|
+
if solver.Value(var) == 1:
|
|
103
|
+
assignment[pos] = s
|
|
104
|
+
for p in get_all_pos(self.V, self.H):
|
|
105
|
+
assert p in assignment, f'position {p} is not assigned a number'
|
|
106
|
+
return SingleSolution(assignment=assignment)
|
|
107
|
+
def callback(single_res: SingleSolution):
|
|
108
|
+
print("Solution found")
|
|
109
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
110
|
+
for pos in get_all_pos(self.V, self.H):
|
|
111
|
+
set_char(res, pos, '/' if single_res.assignment[pos] == '//' else '\\')
|
|
112
|
+
print('[')
|
|
113
|
+
for row in range(self.V):
|
|
114
|
+
line = ' [ ' + ' '.join(res[row].tolist()) + ' ]'
|
|
115
|
+
print(line)
|
|
116
|
+
print(']')
|
|
117
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
This file is a simple helper that parses the images from https://www.
|
|
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
|
"""
|
|
@@ -0,0 +1,128 @@
|
|
|
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, get_row_pos, get_col_pos, set_char, get_pos, get_char, Direction, in_bounds, get_next_pos
|
|
5
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_board(board: np.array) -> tuple[np.array, list[tuple[Pos, Pos, str]]]:
|
|
9
|
+
"""Returns the internal board and a list for every pair of positions (p1, p2, comparison_type) where p1 < p2 if comparison_type is '<' otherwise abs(p1 - p2)==1 if comparison_type is '|'"""
|
|
10
|
+
V = int(np.ceil(board.shape[0] / 2))
|
|
11
|
+
H = int(np.ceil(board.shape[1] / 2))
|
|
12
|
+
internal_board = np.full((V, H), ' ', dtype=object)
|
|
13
|
+
pairs = []
|
|
14
|
+
for row_i in range(board.shape[0]):
|
|
15
|
+
for col_i in range(board.shape[1]):
|
|
16
|
+
cell = board[row_i, col_i]
|
|
17
|
+
if row_i % 2 == 0 and col_i % 2 == 0: # number or empty cell
|
|
18
|
+
if cell == ' ':
|
|
19
|
+
continue
|
|
20
|
+
# map A to 10, B to 11, etc.
|
|
21
|
+
if str(cell).isalpha() and len(str(cell)) == 1:
|
|
22
|
+
cell = ord(cell.upper()) - ord('A') + 10
|
|
23
|
+
assert str(cell).isdecimal(), f'expected number at {row_i, col_i}, got {cell}'
|
|
24
|
+
internal_board[row_i // 2, col_i // 2] = int(cell)
|
|
25
|
+
elif row_i % 2 == 0 and col_i % 2 == 1: # horizontal comparison
|
|
26
|
+
assert cell in ['<', '>', '|', ' '], f'expected <, >, |, or empty cell at {row_i, col_i}, got {cell}'
|
|
27
|
+
if cell == ' ':
|
|
28
|
+
continue
|
|
29
|
+
p1 = get_pos(x=col_i // 2, y=row_i // 2)
|
|
30
|
+
p2 = get_pos(x=p1.x + 1, y=p1.y)
|
|
31
|
+
if cell == '<':
|
|
32
|
+
pairs.append((p1, p2, '<'))
|
|
33
|
+
elif cell == '>':
|
|
34
|
+
pairs.append((p2, p1, '<'))
|
|
35
|
+
elif cell == '|':
|
|
36
|
+
pairs.append((p1, p2, '|'))
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError(f'unexpected cell {cell} at {row_i, col_i}')
|
|
39
|
+
elif row_i % 2 == 1 and col_i % 2 == 0: # vertical comparison
|
|
40
|
+
assert cell in ['∧', '∨', 'U', 'D', 'V', 'n', '-', '|', ' '], f'expected ∧, ∨, U, D, V, n, -, |, or empty cell at {row_i, col_i}, got {cell}'
|
|
41
|
+
if cell == ' ':
|
|
42
|
+
continue
|
|
43
|
+
p1 = get_pos(x=col_i // 2, y=row_i // 2)
|
|
44
|
+
p2 = get_pos(x=p1.x, y=p1.y + 1)
|
|
45
|
+
if cell in ['∨', 'U', 'V']:
|
|
46
|
+
pairs.append((p2, p1, '<'))
|
|
47
|
+
elif cell in ['∧', 'D', 'n']:
|
|
48
|
+
pairs.append((p1, p2, '<'))
|
|
49
|
+
elif cell in ['-', '|']:
|
|
50
|
+
pairs.append((p1, p2, '|'))
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError(f'unexpected cell {cell} at {row_i, col_i}')
|
|
53
|
+
else:
|
|
54
|
+
assert cell in [' ', '.', 'X'], f'expected empty cell or dot or X at unused corner {row_i, col_i}, got {cell}'
|
|
55
|
+
return internal_board, pairs
|
|
56
|
+
|
|
57
|
+
class Board:
|
|
58
|
+
def __init__(self, board: np.array, adjacent_mode: bool = False, include_zero_before_letter: bool = True):
|
|
59
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
60
|
+
assert board.shape[0] > 0 and board.shape[1] > 0, 'board must be non-empty'
|
|
61
|
+
self.board, self.pairs = parse_board(board)
|
|
62
|
+
self.adjacent_mode = adjacent_mode
|
|
63
|
+
self.V, self.H = self.board.shape
|
|
64
|
+
self.lb = 1
|
|
65
|
+
self.N = max(self.V, self.H)
|
|
66
|
+
if include_zero_before_letter and self.N > 9: # zero is introduced when board gets to 10, then we add 1 letter after that
|
|
67
|
+
self.lb = 0
|
|
68
|
+
self.N -= 1
|
|
69
|
+
|
|
70
|
+
self.model = cp_model.CpModel()
|
|
71
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
72
|
+
self.create_vars()
|
|
73
|
+
self.add_all_constraints()
|
|
74
|
+
|
|
75
|
+
def create_vars(self):
|
|
76
|
+
for pos in get_all_pos(self.V, self.H):
|
|
77
|
+
self.model_vars[pos] = self.model.NewIntVar(self.lb, self.N, f'{pos}')
|
|
78
|
+
|
|
79
|
+
def add_all_constraints(self):
|
|
80
|
+
for row_i in range(self.V):
|
|
81
|
+
self.model.AddAllDifferent([self.model_vars[pos] for pos in get_row_pos(row_i, self.H)])
|
|
82
|
+
for col_i in range(self.H):
|
|
83
|
+
self.model.AddAllDifferent([self.model_vars[pos] for pos in get_col_pos(col_i, self.V)])
|
|
84
|
+
for pos in get_all_pos(self.V, self.H):
|
|
85
|
+
c = get_char(self.board, pos)
|
|
86
|
+
if str(c).isdecimal():
|
|
87
|
+
self.model.Add(self.model_vars[pos] == int(c))
|
|
88
|
+
|
|
89
|
+
for p1, p2, comparison_type in self.pairs:
|
|
90
|
+
assert comparison_type in ['<', '|'], f'SHOULD NEVER HAPPEN: invalid comparison type {comparison_type}, expected < or |'
|
|
91
|
+
if comparison_type == '<':
|
|
92
|
+
self.model.Add(self.model_vars[p1] < self.model_vars[p2])
|
|
93
|
+
elif comparison_type == '|':
|
|
94
|
+
aux = self.model.NewIntVar(0, 2*self.N, f'aux_{p1}_{p2}')
|
|
95
|
+
self.model.AddAbsEquality(aux, self.model_vars[p1] - self.model_vars[p2])
|
|
96
|
+
self.model.Add(aux == 1)
|
|
97
|
+
if self.adjacent_mode:
|
|
98
|
+
# in adjacent mode, there is strict NON adjacency if a | does not exist
|
|
99
|
+
all_pairs = {(p1, p2) for p1, p2, _ in self.pairs}
|
|
100
|
+
for pos in get_all_pos(self.V, self.H):
|
|
101
|
+
for direction in [Direction.RIGHT, Direction.DOWN]:
|
|
102
|
+
neighbor = get_next_pos(pos, direction)
|
|
103
|
+
if not in_bounds(neighbor, self.V, self.H):
|
|
104
|
+
continue
|
|
105
|
+
if (pos, neighbor) in all_pairs:
|
|
106
|
+
continue
|
|
107
|
+
assert (neighbor, pos) not in all_pairs, f'SHOULD NEVER HAPPEN: both {pos}->{neighbor} and {neighbor}->{pos} are in the same pair'
|
|
108
|
+
aux = self.model.NewIntVar(0, 2*self.N, f'aux_{pos}_{neighbor}')
|
|
109
|
+
self.model.AddAbsEquality(aux, self.model_vars[pos] - self.model_vars[neighbor])
|
|
110
|
+
self.model.Add(aux != 1)
|
|
111
|
+
|
|
112
|
+
def solve_and_print(self, verbose: bool = True):
|
|
113
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
114
|
+
assignment: dict[Pos, int] = {}
|
|
115
|
+
for pos, var in board.model_vars.items():
|
|
116
|
+
assignment[pos] = solver.Value(var)
|
|
117
|
+
return SingleSolution(assignment=assignment)
|
|
118
|
+
def callback(single_res: SingleSolution):
|
|
119
|
+
print("Solution found")
|
|
120
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
121
|
+
for pos in get_all_pos(self.V, self.H):
|
|
122
|
+
set_char(res, pos, str(single_res.assignment[pos]))
|
|
123
|
+
print('[')
|
|
124
|
+
for row in range(self.V):
|
|
125
|
+
line = ' [ ' + ' '.join(res[row].tolist()) + ' ]'
|
|
126
|
+
print(line)
|
|
127
|
+
print(']')
|
|
128
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
File without changes
|
|
File without changes
|