multi-puzzle-solver 1.0.7__py3-none-any.whl → 1.0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- {multi_puzzle_solver-1.0.7.dist-info → multi_puzzle_solver-1.0.8.dist-info}/METADATA +82 -1
- {multi_puzzle_solver-1.0.7.dist-info → multi_puzzle_solver-1.0.8.dist-info}/RECORD +10 -9
- puzzle_solver/__init__.py +3 -1
- puzzle_solver/core/utils_visualizer.py +565 -561
- puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -221
- puzzle_solver/puzzles/palisade/palisade.py +91 -91
- puzzle_solver/puzzles/tracks/tracks.py +1 -1
- {multi_puzzle_solver-1.0.7.dist-info → multi_puzzle_solver-1.0.8.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-1.0.7.dist-info → multi_puzzle_solver-1.0.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import numpy as np
|
|
3
|
+
from ortools.sat.python import cp_model
|
|
4
|
+
from ortools.util.python import sorted_interval_list
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils import Direction, Pos, get_char, get_next_pos, get_row_pos, get_col_pos, in_bounds, set_char
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class var_with_bounds:
|
|
13
|
+
var: cp_model.IntVar
|
|
14
|
+
min_value: int
|
|
15
|
+
max_value: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _div_bounds(a_min: int, a_max: int, b_min: int, b_max: int) -> tuple[int, int]:
|
|
19
|
+
assert not (b_min == 0 and b_max == 0), "Denominator interval cannot be [0, 0]."
|
|
20
|
+
denoms = [b_min, b_max]
|
|
21
|
+
if 0 in denoms:
|
|
22
|
+
denoms.remove(0)
|
|
23
|
+
if b_min <= -1:
|
|
24
|
+
denoms += [-1]
|
|
25
|
+
if b_max >= 1:
|
|
26
|
+
denoms += [1]
|
|
27
|
+
candidates = [a_min // d for d in denoms] + [a_max // d for d in denoms]
|
|
28
|
+
return min(candidates), max(candidates)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Board:
|
|
32
|
+
def __init__(self, board: np.array, digits: list[int]):
|
|
33
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
34
|
+
self.board = board
|
|
35
|
+
self.V, self.H = board.shape
|
|
36
|
+
assert self.V >= 3 and self.V % 2 == 1, f'board must have at least 3 rows and an odd number of rows. Got {self.V} rows.'
|
|
37
|
+
assert self.H >= 3 and self.H % 2 == 1, f'board must have at least 3 columns and an odd number of columns. Got {self.H} columns.'
|
|
38
|
+
self.digits = digits
|
|
39
|
+
self.domain_values = sorted_interval_list.Domain.FromValues(self.digits)
|
|
40
|
+
self.domain_values_no_zero = sorted_interval_list.Domain.FromValues([d for d in self.digits if d != 0])
|
|
41
|
+
|
|
42
|
+
self.model = cp_model.CpModel()
|
|
43
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
44
|
+
self.create_vars()
|
|
45
|
+
assert len(self.model_vars) == len(self.digits), f'len(model_vars) != len(digits), {len(self.model_vars)} != {len(self.digits)}'
|
|
46
|
+
self.model.AddAllDifferent(list(self.model_vars.values()))
|
|
47
|
+
|
|
48
|
+
def create_vars(self):
|
|
49
|
+
for row in range(0, self.V-2, 2):
|
|
50
|
+
line_pos = [pos for pos in get_row_pos(row, self.H)]
|
|
51
|
+
self.parse_line(line_pos)
|
|
52
|
+
for col in range(0, self.H-2, 2):
|
|
53
|
+
line_pos = [pos for pos in get_col_pos(col, self.V)]
|
|
54
|
+
self.parse_line(line_pos)
|
|
55
|
+
|
|
56
|
+
def parse_line(self, line_pos: list[Pos]) -> list[int]:
|
|
57
|
+
last_num = get_char(self.board, line_pos[-1])
|
|
58
|
+
equal_sign = get_char(self.board, line_pos[-2])
|
|
59
|
+
assert equal_sign == '=', f'last element of line must be =, got {equal_sign}'
|
|
60
|
+
line_pos = line_pos[:-2]
|
|
61
|
+
operators = [get_char(self.board, pos) for pos in line_pos[1::2]]
|
|
62
|
+
assert all(c.strip() in ['+', '-', '*', '/'] for c in operators), f'even indices of line must be operators, got {operators}'
|
|
63
|
+
digits_pos = line_pos[::2]
|
|
64
|
+
running_var = self.get_var(digits_pos[0], fixed=get_char(self.board, digits_pos[0]))
|
|
65
|
+
for pos, operator in zip(digits_pos[1:], operators):
|
|
66
|
+
running_var = self.apply_operator(operator, running_var, self.get_var(pos, fixed=get_char(self.board, pos)))
|
|
67
|
+
self.model.Add(running_var.var == int(last_num))
|
|
68
|
+
return running_var
|
|
69
|
+
|
|
70
|
+
def get_var(self, pos: Pos, fixed: str) -> var_with_bounds:
|
|
71
|
+
if pos not in self.model_vars:
|
|
72
|
+
domain = self.domain_values_no_zero if self.might_be_denominator(pos) else self.domain_values
|
|
73
|
+
self.model_vars[pos] = self.model.NewIntVarFromDomain(domain, f'{pos}')
|
|
74
|
+
if fixed.strip():
|
|
75
|
+
self.model.Add(self.model_vars[pos] == int(fixed))
|
|
76
|
+
return var_with_bounds(var=self.model_vars[pos], min_value=min(self.digits), max_value=max(self.digits))
|
|
77
|
+
|
|
78
|
+
def might_be_denominator(self, pos: Pos) -> bool:
|
|
79
|
+
"Important since if the variable might be a denominator and the domain includes 0 then ortools immediately sets the model as INVALID"
|
|
80
|
+
above_pos = get_next_pos(pos, Direction.UP)
|
|
81
|
+
left_pos = get_next_pos(pos, Direction.LEFT)
|
|
82
|
+
above_operator = get_char(self.board, above_pos) if in_bounds(above_pos, self.V, self.H) else None
|
|
83
|
+
left_operator = get_char(self.board, left_pos) if in_bounds(left_pos, self.V, self.H) else None
|
|
84
|
+
return above_operator == '/' or left_operator == '/'
|
|
85
|
+
|
|
86
|
+
def apply_operator(self, operator: str, a: var_with_bounds, b: var_with_bounds) -> var_with_bounds:
|
|
87
|
+
assert operator in ['+', '-', '*', '/'], f'invalid operator: {operator}'
|
|
88
|
+
if operator == "+":
|
|
89
|
+
lo = a.min_value + b.min_value
|
|
90
|
+
hi = a.max_value + b.max_value
|
|
91
|
+
res = self.model.NewIntVar(lo, hi, "sum")
|
|
92
|
+
self.model.Add(res == a.var + b.var)
|
|
93
|
+
elif operator == "-":
|
|
94
|
+
lo = a.min_value - b.max_value
|
|
95
|
+
hi = a.max_value - b.min_value
|
|
96
|
+
res = self.model.NewIntVar(lo, hi, "diff")
|
|
97
|
+
self.model.Add(res == a.var - b.var)
|
|
98
|
+
elif operator == "*":
|
|
99
|
+
cands = [a.min_value*b.min_value, a.min_value*b.max_value, a.max_value*b.min_value, a.max_value*b.max_value]
|
|
100
|
+
lo, hi = min(cands), max(cands)
|
|
101
|
+
res = self.model.NewIntVar(lo, hi, "prod")
|
|
102
|
+
self.model.AddMultiplicationEquality(res, [a.var, b.var])
|
|
103
|
+
elif operator == "/":
|
|
104
|
+
self.model.Add(b.var != 0)
|
|
105
|
+
lo, hi = _div_bounds(a.min_value, a.max_value, b.min_value, b.max_value)
|
|
106
|
+
res = self.model.NewIntVar(lo, hi, "quot")
|
|
107
|
+
self.model.AddDivisionEquality(res, a.var, b.var)
|
|
108
|
+
return var_with_bounds(res, lo, hi)
|
|
109
|
+
|
|
110
|
+
def solve_and_print(self, verbose: bool = True):
|
|
111
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
112
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
113
|
+
def callback(single_res: SingleSolution):
|
|
114
|
+
print("Solution found")
|
|
115
|
+
output_board = self.board.copy()
|
|
116
|
+
for pos, var in single_res.assignment.items():
|
|
117
|
+
set_char(output_board, pos, str(var))
|
|
118
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: str(output_board[r, c])))
|
|
119
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -1,221 +1,220 @@
|
|
|
1
|
-
from collections import defaultdict
|
|
2
|
-
from typing import Optional
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from ortools.sat.python import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from puzzle_solver.core.
|
|
9
|
-
from puzzle_solver.core.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
assert_input(
|
|
30
|
-
|
|
31
|
-
self.
|
|
32
|
-
self.
|
|
33
|
-
self.
|
|
34
|
-
self.
|
|
35
|
-
self.
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
self.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ground_sequence
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
ground_sequence
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
min_needed
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
#
|
|
105
|
-
# If
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
self.extra_vars[f"{ns}
|
|
122
|
-
self.extra_vars[f"{ns}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
self.model.Add(s_i
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
end_expr
|
|
134
|
-
self.model.Add(end_expr
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
self.model.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
v
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
total_cells_k
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
print(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
from matplotlib import
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
plt.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
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 Optional
|
|
3
|
+
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_row_pos, get_col_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def assert_input(lines: list[list[tuple[int, str]]]):
|
|
13
|
+
for line in lines:
|
|
14
|
+
for i,c in enumerate(line):
|
|
15
|
+
if c == -1:
|
|
16
|
+
continue
|
|
17
|
+
elif isinstance(c, str):
|
|
18
|
+
assert c[:-1].isdigit(), f'strings must begin with a digit, got {c}'
|
|
19
|
+
line[i] = (int(c[:-1]), c[-1])
|
|
20
|
+
elif isinstance(c, tuple):
|
|
21
|
+
assert len(c) == 2 and isinstance(c[0], int) and isinstance(c[1], str), f'tuples must be (int, str), got {c}'
|
|
22
|
+
else:
|
|
23
|
+
raise ValueError(f'invalid cell value: {c}')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Board:
|
|
27
|
+
def __init__(self, top: list[list[tuple[int, str]]], side: list[list[tuple[int, str]]]):
|
|
28
|
+
assert_input(top)
|
|
29
|
+
assert_input(side)
|
|
30
|
+
self.top = top
|
|
31
|
+
self.side = side
|
|
32
|
+
self.V = len(side)
|
|
33
|
+
self.H = len(top)
|
|
34
|
+
self.unique_colors = list(set([i[1] for line in top for i in line if i != -1] + [i[1] for line in side for i in line if i != -1]))
|
|
35
|
+
self.model = cp_model.CpModel()
|
|
36
|
+
self.model_vars: dict[Pos, dict[str, cp_model.IntVar]] = defaultdict(dict)
|
|
37
|
+
self.extra_vars = {}
|
|
38
|
+
|
|
39
|
+
self.create_vars()
|
|
40
|
+
self.add_all_constraints()
|
|
41
|
+
|
|
42
|
+
def create_vars(self):
|
|
43
|
+
for pos in get_all_pos(self.V, self.H):
|
|
44
|
+
for color in self.unique_colors:
|
|
45
|
+
self.model_vars[pos][color] = self.model.NewBoolVar(f'{pos}:{color}')
|
|
46
|
+
|
|
47
|
+
def add_all_constraints(self):
|
|
48
|
+
for pos in get_all_pos(self.V, self.H):
|
|
49
|
+
self.model.Add(lxp.sum(list(self.model_vars[pos].values())) <= 1)
|
|
50
|
+
for i in range(self.V):
|
|
51
|
+
ground_sequence = self.side[i]
|
|
52
|
+
if tuple(ground_sequence) == (-1,):
|
|
53
|
+
continue
|
|
54
|
+
current_sequence = [self.model_vars[pos] for pos in get_row_pos(i, self.H)]
|
|
55
|
+
self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_side_{i}')
|
|
56
|
+
for i in range(self.H):
|
|
57
|
+
ground_sequence = self.top[i]
|
|
58
|
+
if tuple(ground_sequence) == (-1,):
|
|
59
|
+
continue
|
|
60
|
+
current_sequence = [self.model_vars[pos] for pos in get_col_pos(i, self.V)]
|
|
61
|
+
self.constrain_nonogram_sequence(ground_sequence, current_sequence, f'ngm_top_{i}')
|
|
62
|
+
|
|
63
|
+
def constrain_nonogram_sequence(self, clues: list[tuple[int, str]], current_sequence: list[dict[str, cp_model.IntVar]], ns: str):
|
|
64
|
+
"""
|
|
65
|
+
Constrain a colored sequence (current_sequence) to match the nonogram clues in clues.
|
|
66
|
+
|
|
67
|
+
clues: e.g., [(3, 'R'), (1, 'G')] means: a run of 3 red ones, then a run of 1 green one. If two clues are next to each other and have the same color, they must be separated by at least one blank.
|
|
68
|
+
current_sequence: list of dicts of IntVar in {0,1} for each color.
|
|
69
|
+
|
|
70
|
+
steps:
|
|
71
|
+
- Create start position s_i for each run i.
|
|
72
|
+
- Enforce order and >=1 separation between runs.
|
|
73
|
+
- Link each cell j to exactly one run interval (or none) via coverage booleans.
|
|
74
|
+
- Force sum of ones to equal sum(clues).
|
|
75
|
+
"""
|
|
76
|
+
L = len(current_sequence)
|
|
77
|
+
R = len(clues)
|
|
78
|
+
|
|
79
|
+
# Early infeasibility check:
|
|
80
|
+
# Minimum required blanks equals number of adjacent pairs with same color.
|
|
81
|
+
same_color_separators = sum(1 for (len_i, col_i), (len_j, col_j) in zip(clues, clues[1:]) if col_i == col_j)
|
|
82
|
+
min_needed = sum(len_i for len_i, _ in clues) + same_color_separators
|
|
83
|
+
if min_needed > L:
|
|
84
|
+
print(f"Infeasible: clues {clues} need {min_needed} cells but line length is {L} for {ns}")
|
|
85
|
+
self.model.Add(0 == 1)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Collect the color set present in clues and in the line vars
|
|
89
|
+
clue_colors = {c for _, c in clues}
|
|
90
|
+
seq_colors = set()
|
|
91
|
+
for j in range(L):
|
|
92
|
+
seq_colors.update(current_sequence[j].keys())
|
|
93
|
+
colors = sorted(clue_colors | seq_colors)
|
|
94
|
+
|
|
95
|
+
# Start vars per run
|
|
96
|
+
starts: list[cp_model.IntVar] = []
|
|
97
|
+
self.extra_vars[f"{ns}_starts"] = starts
|
|
98
|
+
for i in range(len(clues)):
|
|
99
|
+
# s_i in [0, L] but we will bound by containment constraint below
|
|
100
|
+
s = self.model.NewIntVar(0, L, f"{ns}_s[{i}]")
|
|
101
|
+
starts.append(s)
|
|
102
|
+
|
|
103
|
+
# Ordering + separation:
|
|
104
|
+
# If same color: s[i+1] >= s[i] + len[i] + 1
|
|
105
|
+
# If different color: s[i+1] >= s[i] + len[i]
|
|
106
|
+
for i in range(R - 1):
|
|
107
|
+
len_i, col_i = clues[i]
|
|
108
|
+
_, col_next = clues[i + 1]
|
|
109
|
+
gap = 1 if col_i == col_next else 0
|
|
110
|
+
self.model.Add(starts[i + 1] >= starts[i] + len_i + gap)
|
|
111
|
+
|
|
112
|
+
# Containment: s[i] + len[i] <= L
|
|
113
|
+
for i, (run_len, _) in enumerate(clues):
|
|
114
|
+
self.model.Add(starts[i] + run_len <= L)
|
|
115
|
+
|
|
116
|
+
# Coverage booleans: cover[i][j] <=> (starts[i] <= j) AND (j < starts[i] + run_len)
|
|
117
|
+
cover = [[None] * L for _ in range(R)]
|
|
118
|
+
list_b_le = [[None] * L for _ in range(R)]
|
|
119
|
+
list_b_lt_end = [[None] * L for _ in range(R)]
|
|
120
|
+
self.extra_vars[f"{ns}_cover"] = cover
|
|
121
|
+
self.extra_vars[f"{ns}_list_b_le"] = list_b_le
|
|
122
|
+
self.extra_vars[f"{ns}_list_b_lt_end"] = list_b_lt_end
|
|
123
|
+
|
|
124
|
+
for i, (run_len, _) in enumerate(clues):
|
|
125
|
+
s_i = starts[i]
|
|
126
|
+
for j in range(L):
|
|
127
|
+
b_le = self.model.NewBoolVar(f"{ns}_le[{i},{j}]") # s_i <= j
|
|
128
|
+
self.model.Add(s_i <= j).OnlyEnforceIf(b_le)
|
|
129
|
+
self.model.Add(s_i >= j + 1).OnlyEnforceIf(b_le.Not())
|
|
130
|
+
|
|
131
|
+
b_lt_end = self.model.NewBoolVar(f"{ns}_lt_end[{i},{j}]") # j < s_i + run_len <=> s_i + run_len - 1 >= j
|
|
132
|
+
end_expr = s_i + run_len - 1
|
|
133
|
+
self.model.Add(end_expr >= j).OnlyEnforceIf(b_lt_end)
|
|
134
|
+
self.model.Add(end_expr <= j - 1).OnlyEnforceIf(b_lt_end.Not())
|
|
135
|
+
|
|
136
|
+
b_cov = self.model.NewBoolVar(f"{ns}_cov[{i},{j}]")
|
|
137
|
+
self.model.AddBoolAnd([b_le, b_lt_end]).OnlyEnforceIf(b_cov)
|
|
138
|
+
self.model.AddBoolOr([b_cov, b_le.Not(), b_lt_end.Not()])
|
|
139
|
+
|
|
140
|
+
cover[i][j] = b_cov
|
|
141
|
+
list_b_le[i][j] = b_le
|
|
142
|
+
list_b_lt_end[i][j] = b_lt_end
|
|
143
|
+
|
|
144
|
+
# Link coverage to per-cell, per-color variables.
|
|
145
|
+
# For each color k and cell j:
|
|
146
|
+
# sum_{i: color_i == k} cover[i][j] == current_sequence[j][k]
|
|
147
|
+
# Also tie the total cover at j to the sum over all colors at j:
|
|
148
|
+
# sum_i cover[i][j] == sum_k current_sequence[j][k]
|
|
149
|
+
# This enforces that at most one color is active per cell (since the LHS is in {0,1} due to non-overlap).
|
|
150
|
+
# If a color var is missing in current_sequence[j], assume it’s an implicit 0 by creating a fixed zero var.
|
|
151
|
+
# (Alternatively, require the caller to provide all colors per cell.)
|
|
152
|
+
zero_cache = {}
|
|
153
|
+
def get_zero(name: str):
|
|
154
|
+
if name not in zero_cache:
|
|
155
|
+
z = self.model.NewConstant(0)
|
|
156
|
+
zero_cache[name] = z
|
|
157
|
+
return zero_cache[name]
|
|
158
|
+
|
|
159
|
+
# Pre-index runs by color for efficiency
|
|
160
|
+
runs_by_color = {k: [] for k in colors}
|
|
161
|
+
for i, (_, k) in enumerate(clues):
|
|
162
|
+
runs_by_color[k].append(i)
|
|
163
|
+
|
|
164
|
+
for j in range(L):
|
|
165
|
+
# Total coverage at cell j
|
|
166
|
+
total_cov_j = sum(cover[i][j] for i in range(R)) if R > 0 else 0
|
|
167
|
+
|
|
168
|
+
# Sum of color vars at cell j
|
|
169
|
+
color_vars_j = []
|
|
170
|
+
for k in colors:
|
|
171
|
+
v = current_sequence[j].get(k, None)
|
|
172
|
+
if v is None:
|
|
173
|
+
v = get_zero(f"{ns}_zero_{k}")
|
|
174
|
+
color_vars_j.append(v)
|
|
175
|
+
|
|
176
|
+
# Per-color coverage equality
|
|
177
|
+
if runs_by_color[k]:
|
|
178
|
+
self.model.Add(sum(cover[i][j] for i in runs_by_color[k]) == v)
|
|
179
|
+
else:
|
|
180
|
+
# No runs of this color -> force cell color var to 0
|
|
181
|
+
self.model.Add(v == 0)
|
|
182
|
+
|
|
183
|
+
# Tie total coverage to sum of color vars (blank vs exactly-one color)
|
|
184
|
+
if R > 0:
|
|
185
|
+
self.model.Add(total_cov_j == sum(color_vars_j))
|
|
186
|
+
else:
|
|
187
|
+
# No runs at all: all cells must be blank across all colors
|
|
188
|
+
for v in color_vars_j:
|
|
189
|
+
self.model.Add(v == 0)
|
|
190
|
+
|
|
191
|
+
# Optional but strong propagation: per-color totals must match total clue lengths of that color
|
|
192
|
+
total_len_by_color = {k: 0 for k in colors}
|
|
193
|
+
for length, k in clues:
|
|
194
|
+
total_len_by_color[k] += length
|
|
195
|
+
|
|
196
|
+
for k in colors:
|
|
197
|
+
total_cells_k = sum(current_sequence[j].get(k, get_zero(f"{ns}_zero_{k}")) for j in range(L))
|
|
198
|
+
self.model.Add(total_cells_k == total_len_by_color[k])
|
|
199
|
+
|
|
200
|
+
def solve_and_print(self, verbose: bool = True, visualize_colors: Optional[dict[str, str]] = None):
|
|
201
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
202
|
+
return SingleSolution(assignment={pos: color for pos, d in board.model_vars.items() for color, var in d.items() if solver.value(var) == 1})
|
|
203
|
+
def callback(single_res: SingleSolution):
|
|
204
|
+
print("Solution found")
|
|
205
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ' ')))
|
|
206
|
+
if visualize_colors is not None:
|
|
207
|
+
from matplotlib import pyplot as plt
|
|
208
|
+
from matplotlib.colors import ListedColormap
|
|
209
|
+
visualize_colors[' '] = 'black'
|
|
210
|
+
visualize_colors_keys = list(visualize_colors.keys())
|
|
211
|
+
char_to_int = {c: i for i, c in enumerate(visualize_colors_keys)}
|
|
212
|
+
nums = [[char_to_int[single_res.assignment.get(get_pos(x=c, y=r), ' ')] for c in range(self.H)] for r in range(self.V)]
|
|
213
|
+
plt.imshow(nums,
|
|
214
|
+
aspect='equal',
|
|
215
|
+
cmap=ListedColormap([visualize_colors[c] for c in visualize_colors_keys]),
|
|
216
|
+
extent=[0, self.H, self.V, 0])
|
|
217
|
+
plt.colorbar()
|
|
218
|
+
# plt.grid(True)
|
|
219
|
+
plt.show()
|
|
220
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|