diff --git a/examples/chess-recognition.py b/examples/chess-recognition.py index 1c21f9249..ba47c6cf6 100644 --- a/examples/chess-recognition.py +++ b/examples/chess-recognition.py @@ -118,18 +118,12 @@ def softmax(vector): (Count(board[0], P) == 0), (Count(board[7], p) == 0), (Count(board[7], P) == 0), - # The number of pieces of each piece type without promotion - ((Count(board, p) == 8) | (Count(board, P) == 8)).implies( - (Count(board, b) <= 2) & - (Count(board, r) <= 2) & - (Count(board, n) <= 2) & - (Count(board, q) <= 1) + # The number of pieces can't exceed the starting number plus the number of promotions possible + ( + (Count(board, b) + Count(board, r) + Count(board, n) + Count(board, q) <= 2+2+1+1 + 8-Count(board, p)) ), - ((Count(board, P) == 8) | (Count(board, p) == 8)).implies( - (Count(board, B) <= 2) & - (Count(board, R) <= 2) & - (Count(board, N) <= 2) & - (Count(board, Q) <= 1) + ( + (Count(board, B) + Count(board, R) + Count(board, N) + Count(board, Q) <= 2+2+1+1 + 8-Count(board, P)) ), # Bishops can't have moved if the pawns are still in starting position ((board[1, 1] == p) & (board[1, 3] == p) & ( diff --git a/examples/sudoku_chaos_killer.py b/examples/sudoku_chaos_killer.py new file mode 100644 index 000000000..32102194d --- /dev/null +++ b/examples/sudoku_chaos_killer.py @@ -0,0 +1,159 @@ +import cpmpy as cp +import numpy as np + + +""" +Puzzle source: https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=0009KE + +Rules: + +- Place the numbers from 1 to 9 exactly once in every row, column, and region. +- Each region is orthogonally connected and must be located by the solver. +- An area outlined by dashes is called a killer cage. + All numbers in a killer cage belong to the same region and sum to the number in the top left corner of the killer cage. +""" + +SIZE = 9 + +# sudoku cells +cell_values = cp.intvar(1,SIZE, shape=(SIZE,SIZE)) +# decision variables for the regions +cell_regions = cp.intvar(1,SIZE, shape=(SIZE,SIZE)) +# regions cardinals +region_cardinals = cp.intvar(1,SIZE, shape=(SIZE,SIZE)) + + +def killer_cage(idxs, values, regions, total): + """ + the sum of the cells in the cage must equal the total + all cells in the cage must be in the same region + + Args: + idxs (np.array): array of indices representing the cells in the cage + values (cp.intvar): cpmpy variable representing the cell values + regions (cp.intvar): cpmpy variable representing the cell regions + total (int): the total sum for the cage + + Returns: + list: list of cpmpy constraints enforcing the killer cage rules + """ + constraints = [] + constraints.append(cp.sum([values[r, c] for r,c in idxs]) == total) + constraints.append(cp.AllEqual([regions[r, c] for r,c in idxs])) + return constraints + +def get_neighbours(r, c): + """ + from a cell get the indices of its orthogonally adjacent neighbours + + Args: + r (int): row index + c (int): column index + + Returns: + list: list of tuples representing the indices of orthogonally adjacent neighbours + """ + # a cell must be orthogonally adjacent to a cell in the same region + + # check if on top row + if r == 0: + if c == 0: + return [(r, c+1), (r+1, c)] + elif c == SIZE-1: + return [(r, c-1), (r+1, c)] + else: + return [(r, c-1), (r, c+1), (r+1, c)] + # check if on bottom row + elif r == SIZE-1: + if c == 0: + return [(r, c+1), (r-1, c)] + elif c == SIZE-1: + return [(r, c-1), (r-1, c)] + else: + return [(r, c-1), (r, c+1), (r-1, c)] + # check if on left column + elif c == 0: + return [(r-1, c), (r+1, c), (r, c+1)] + # check if on right column + elif c == SIZE-1: + return [(r-1, c), (r+1, c), (r, c-1)] + # check if in the middle + else: + return [(r-1, c), (r+1, c), (r, c-1), (r, c+1)] + + + +m = cp.Model( + killer_cage(np.array([[0,0],[0,1],[1,1]]), cell_values, cell_regions, 18), + killer_cage(np.array([[1,0],[2,0],[2,1]]), cell_values, cell_regions, 8), + killer_cage(np.array([[3,0],[3,1],[3,2],[2,2],[1,2]]), cell_values, cell_regions, 27), + killer_cage(np.array([[4,0],[4,1]]), cell_values, cell_regions, 17), + killer_cage(np.array([[5,0],[5,1]]), cell_values, cell_regions, 8), + killer_cage(np.array([[1,3],[1,4]]), cell_values, cell_regions, 6), + killer_cage(np.array([[0,7],[0,8]]), cell_values, cell_regions, 4), + killer_cage(np.array([[2,3],[3,3],[3,4],[4,3]]), cell_values, cell_regions, 12), + killer_cage(np.array([[1,5],[2,5],[3,5],[2,4]]), cell_values, cell_regions, 28), + killer_cage(np.array([[4,4],[4,5],[5,5],[4,6]]), cell_values, cell_regions, 16), + killer_cage(np.array([[2,8],[3,8]]), cell_values, cell_regions, 15), + killer_cage(np.array([[3,6],[3,7],[4,7],[4,8]]), cell_values, cell_regions, 17), + killer_cage(np.array([[6,2],[6,3],[7,3]]), cell_values, cell_regions, 21), + killer_cage(np.array([[7,2],[8,2]]), cell_values, cell_regions, 5), + killer_cage(np.array([[8,3],[8,4]]), cell_values, cell_regions, 15), + killer_cage(np.array([[6,4],[7,4]]), cell_values, cell_regions, 8), + killer_cage(np.array([[6,5],[7,5],[6,6]]), cell_values, cell_regions, 19), + killer_cage(np.array([[8,5],[8,6]]), cell_values, cell_regions, 11), + killer_cage(np.array([[7,6],[7,7],[8,7]]), cell_values, cell_regions, 10), +) + +for i in range(cell_values.shape[0]): + m += cp.AllDifferent(cell_values[i,:]) + m += cp.AllDifferent(cell_values[:,i]) + + +total_cells = SIZE**2 +for i in range(total_cells-1): + r1 = i // SIZE + c1 = i % SIZE + + neighbours = get_neighbours(r1, c1) + + # at least one neighbour must be in the same region with a smaller cardinal number or the cell itself must have cardinal number 1; this forces connectedness of regions + m += cp.any([cp.all([cell_regions[r1,c1] == cell_regions[r2,c2], region_cardinals[r1, c1] > region_cardinals[r2, c2]]) for r2,c2 in neighbours]) | (region_cardinals[r1,c1] == 1) + + + +for r in range(1, SIZE+1): + # Enforce size for each region + m += cp.Count(cell_regions, r) == SIZE # each region must be of size SIZE + + +# Create unique IDs for each (value, region) pair to enforce all different +m += cp.AllDifferent([(cell_values[i,j]-1)*SIZE+cell_regions[i,j]-1 for i in range(SIZE) for j in range(SIZE)]) +# Only 1 cell with cardinal number 1 per region +for r in range(1, SIZE+1): + m += cp.Count([(region_cardinals[i,j])*(cell_regions[i,j] == r) for i in range(SIZE) for j in range(SIZE)], 1) == 1 + + +# Symmetry breaking for the regions +# fix top-left and bottom-right region to reduce symmetry +m += cell_regions[0,0] == 1 +m += cell_regions[SIZE-1, SIZE-1] == SIZE + +sol = m.solve() + +print("The solution is:") +print(cell_values.value()) +print("The regions are:") +print(cell_regions.value()) + +assert (cell_values.value() == [[9, 6, 7, 4, 8, 5, 2, 1, 3], + [2, 3, 8, 1, 5, 4, 6, 9, 7], + [1, 5, 2, 3, 9, 7, 4, 8, 6], + [4, 7, 6, 2, 1, 8, 3, 5, 9], + [8, 9, 3, 6, 4, 1, 5, 7, 2], + [7, 1, 9, 5, 3, 6, 8, 2, 4], + [6, 8, 5, 9, 2, 3, 7, 4, 1], + [5, 2, 4, 7, 6, 9, 1, 3, 8], + [3, 4, 1, 8, 7, 2, 9, 6, 5]]).all() + +# can not assert regions as there are symmetric solutions.. I can add symmetry breaking constraint, but it is slow in this case \ No newline at end of file diff --git a/examples/sudoku_chockablock.py b/examples/sudoku_chockablock.py index f35d05eb7..0fa8bc38c 100644 --- a/examples/sudoku_chockablock.py +++ b/examples/sudoku_chockablock.py @@ -1,7 +1,22 @@ import cpmpy as cp import numpy as np -# This cpmpy example solves a sudoku by marty_sears, which can be found on https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000I2P + +""" +Puzzle source: https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000I2P + +Rules: + +- Normal sudoku rules apply. +- Unique N-Lines: ALL lines in the grid are N-Lines. Each N-line is composed of one or more non-overlapping sets of adjacent digits, each of which sum to N. Every line has a different N value. +- Coloured lines (not including the grey ones) each have an additional rule: +- Renban (pink): Digits on a pink line form a non-repeating consecutive set, which can be arranged in any order. +- Even Sum Lines (darker blue): All the digits on a darker blue line sum to an even number. +- Prime Lines (purple): Adjacent digits on a purple line sum to a prime number. +- Anti-Kropki Lines (red): No two digits anywhere on the same red line are consecutive, or in a 1:2 ratio (but they may repeat). +- Same Difference Lines (turquoise): Each pair of adjacent digits on a turquoise line have the same difference. This difference must be determined for each turquoise line. +- The grey perimeter can be used for making notes. +""" # sudoku cells cells = cp.intvar(1,9, shape=(9,9)) @@ -10,11 +25,20 @@ def n_lines(array, total): - # N-lines are composed of one or more non-overlapping sets of adjacent cells - # The number of such partitions is exponential, luckily all the lines are of length 3 so we can easily hardcode them - # partitions = [(3), (2,1), (1,2), (1,1,1)] - # For every partition we can check whether each subpartition sums to the same number - # the N variable should be equal to a partition where it holds that each subpartition sums to the same number + """" + N-lines are composed of one or more non-overlapping sets of adjacent cells + The number of such partitions is exponential, luckily all the lines are of length 3 so we can easily hardcode them + partitions = [(3), (2,1), (1,2), (1,1,1)] + For every partition we can check whether each subpartition sums to the same number + the N variable should be equal to a partition where it holds that each subpartition sums to the same number + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + total (cp.intvar): cpmpy variable representing the total sum for the line + + Returns: + cpmpy constraint enforcing the N-line rule + """ all_partitions = [[array], [array[:2], array[2:]], [array[:1], array[1:]], [array[:1], array[1:2], array[2:]]] sums = cp.intvar(0,27, shape=4) partial_sums = [] @@ -34,30 +58,84 @@ def n_lines(array, total): return cp.all(constraints) def even_sum(array, total): - # line must sum to an even total + """ + line must sum to an even total + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + total (cp.intvar): cpmpy variable representing the total sum for the line + + Returns: + cpmpy constraint enforcing the even sum rule and the n-line rule + """ return cp.all([n_lines(array, total), total % 2 == 0]) def prime_sum(array, total): - # all prime sums reachable for two sudoku digits + """ + line must sum to a prime total + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + total (cp.intvar): cpmpy variable representing the total sum for the line + + Returns: + cpmpy constraint enforcing the prime sum rule and the n-line rule + """ primes = [2, 3, 5, 7, 11, 13, 17] pairs = [(a,b) for a,b in zip(array, array[1:])] return cp.all([n_lines(array, total), cp.all([cp.any([cp.sum(pair) == p for p in primes]) for pair in pairs])]) def renban(array, total): - # digits on a pink renban form a set of consecutive non repeating digits + """ + digits on a pink renban form a set of consecutive non repeating digits + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + total (cp.intvar): cpmpy variable representing the total sum for the line + + Returns: + cpmpy constraint enforcing the renban rule and the n-line rule + """ return cp.all([n_lines(array, total), cp.AllDifferent(array), cp.max(array) - cp.min(array) == len(array) - 1]) # there are no kropki dots in this sudoku the two following functions are used for the anti-kropki line def white_kropki(a, b): - # digits separated by a white dot differ by 1 + """ + digits separated by a white dot differ by 1 + + Args: + a (cp.intvar): cpmpy variable representing cell a + b (cp.intvar): cpmpy variable representing cell b + + Returns: + cpmpy constraint enforcing the white kropki rule + """ return abs(a-b) == 1 def black_kropki(a, b): - # digits separated by a black dot are in a 1:2 ratio + """ + digits separated by a black dot are in a 1:2 ratio + + Args: + a (cp.intvar): cpmpy variable representing cell a + b (cp.intvar): cpmpy variable representing cell b + + Returns: + cpmpy constraint enforcing the black kropki rule + """ return cp.any([a * 2 == b, a == 2 * b]) def anti_kropki(array, total): - # no pair anywhere on the line may be in a kropki relationship + """ + no pair anywhere on the line may be in a kropki relationship + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + total (cp.intvar): cpmpy variable representing the total sum for the line + + Returns: + cpmpy constraint enforcing the anti-kropki rule and the n-line rule + """ all_pairs = [(a, b) for idx, a in enumerate(array) for b in array[idx+1:]] constraints = [] for pair in all_pairs: @@ -65,13 +143,30 @@ def anti_kropki(array, total): return cp.all([cp.all(constraints), n_lines(array, total)]) def same_difference(array, total): - # adjacent cells on the line must all have the same difference + """ + adjacent cells on the line must all have the same difference + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + total (cp.intvar): cpmpy variable representing the total sum for the line + + Returns: + cpmpy constraint enforcing the same difference rule and the n-line rule + """ diff = cp.intvar(0,8, shape=1) return cp.all([cp.all([abs(a-b) == diff for a,b in zip(array, array[1:])]), n_lines(array, total)]) def regroup_to_blocks(grid): - # Create an empty list to store the blocks + """ + Regroup the 9x9 grid into its 3x3 blocks. + + Args: + grid (cp.intvar): cpmpy variable representing the 9x9 sudoku grid + + Returns: + list: list of lists representing the 3x3 blocks of the sudoku grid + """ blocks = [[] for _ in range(9)] for row_index in range(9): @@ -138,3 +233,13 @@ def regroup_to_blocks(grid): sol = m.solve() print("The solution is:") print(cells.value()) + +assert (cells.value() == [[3, 1, 9, 5, 6, 8, 7, 2, 4], + [7, 2, 8, 9, 4, 1, 5, 3, 6], + [4, 5, 6, 7, 2, 3, 1, 8, 9], + [9, 7, 5, 2, 3, 4, 6, 1, 8], + [2, 8, 1, 6, 9, 5, 3, 4, 7], + [6, 4, 3, 1, 8, 7, 2, 9, 5], + [1, 6, 4, 8, 7, 2, 9, 5, 3], + [5, 3, 7, 4, 1, 9, 8, 6, 2], + [8, 9, 2, 3, 5, 6, 4, 7, 1]]).all() \ No newline at end of file diff --git a/examples/sudoku_ratrun1.py b/examples/sudoku_ratrun1.py index 075590fee..7ff69e76f 100644 --- a/examples/sudoku_ratrun1.py +++ b/examples/sudoku_ratrun1.py @@ -1,7 +1,21 @@ import cpmpy as cp import numpy as np -# This cpmpy example solves a sudoku by marty_sears, which can be found on https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000IFI +""" +Puzzle source: https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000IFI + +Rules: + +- Normal 6x6 sudoku rules apply; + fill the grid with the digits 1-6 so that digits don't repeat in a row, column or 3x2 box (marked with dotted lines.) +- AIM OF EXPERIMENT: Finkz the rat must reach the cupcake by finding a path through the maze. + The path will be a snaking line that passes through the centres of cells, without visiting any cell more than once, + crossing itself or passing through any thick maze walls. +- As well as moving orthogonally, Finkz may move diagonally if there's a 2x2 space in which to do so, + but may never pass diagonally through the rounded end / corner of a wall. +- TEST CONSTRAINT: In this experiment, any two cells that are adjacent along the correct path must sum to a prime number. + Also, all the digits that lie anywhere on the correct path within the same 3x2 sudoku box must sum to a prime number too. +""" # sudoku cells cells = cp.intvar(1,6, shape=(6,6)) @@ -33,7 +47,16 @@ PRIMES = [2,3,5,7,11,13,17,19] def get_reachable_neighbours(row, column): - # from a cell get the indices of its reachable neighbours + """ + from a cell get the indices of its reachable neighbours + + Args: + row (int): row index + column (int): column index + + Returns: + list: list of tuples representing the indices of reachable neighbours + """ reachable_neighbours = [] if row != 0: if H_WALLS[row-1, column] == 0: @@ -62,6 +85,7 @@ def get_reachable_neighbours(row, column): return reachable_neighbours def path_valid(path): + """Check if the path is valid according to the maze rules.""" constraints = [] for r in range(path.shape[0]): for c in range(path.shape[0]): @@ -77,7 +101,16 @@ def path_valid(path): def prime_block(block, path): - # sum of pathcells in block must be prime + """ + sum of pathcells in block must be prime + + Args: + block (list): list of cpmpy variables representing the cells in the block + path (list): list of cpmpy variables representing the path cells in the block + + Returns: + cpmpy constraint enforcing the prime sum rule for the block + """ return cp.any([cp.sum(block[i]*(path[i]!=0) for i in range(len(block))) == p for p in PRIMES]) @@ -96,6 +129,15 @@ def prime_block(block, path): ) def regroup_to_blocks(grid): + """ + Regroup the 6x6 grid into its 2x3 blocks. + + Args: + grid (cp.intvar): cpmpy variable representing the 6x6 sudoku grid + + Returns: + list: list of lists representing the 2x3 blocks of the sudoku grid + """ # Create an empty list to store the blocks blocks = [[] for _ in range(6)] @@ -123,4 +165,18 @@ def regroup_to_blocks(grid): print("The solution is:") print(cells.value()) print("The path is (0 if not on the path):") -print(path.value()) \ No newline at end of file +print(path.value()) + +assert (cells.value() == [[1, 4, 2, 6, 3, 5], + [6, 3, 5, 2, 1, 4], + [2, 5, 4, 1, 6, 3], + [3, 6, 1, 4, 5, 2], + [4, 1, 3, 5, 2, 6], + [5, 2, 6, 3, 4, 1]]).all() + +assert (path.value() == [[ 0, 0, 1, 0, 0, 0], + [ 0, 0, 2, 3, 4, 5], + [14, 13, 0, 10, 9, 6], + [15, 12, 11, 0, 8, 7], + [16, 17, 0, 19, 20, 0], + [ 0, 0, 18, 0, 0, 0]]).all() \ No newline at end of file diff --git a/examples/sudoku_removeandrepeat.py b/examples/sudoku_removeandrepeat.py index 8730f3be3..7fa28d6e6 100644 --- a/examples/sudoku_removeandrepeat.py +++ b/examples/sudoku_removeandrepeat.py @@ -2,7 +2,21 @@ import numpy as np from setuptools.namespaces import flatten -# This cpmpy example solves a sudoku by marty_sears, which can be found on https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000JUJ +""" +Puzzle source: https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000JUJ + +Rules: + +- The grid contains the digits 1-6. In each row and column, one digit has been removed, and one digit is repeated. + Eg: 352156 - 4 has been removed, and 5 is repeated. +- Each digit 1-6 is repeated in exactly one row and one column, and removed in exactly one row and one column. +- Digits joined by an X sum to 10. +- Digits joined by a white dot are consecutive. +- Digits joined by a black dot are in a 1:2 ratio. +- Digits in a cage sum to the clue in the corner. +- Digits on a pink renban form a non-repeating consecutive sequence which can be in any order. +- Digits an equal distance from the central spot on a lavender zipper line sum to the digit on the central spot. +""" # sudoku cells cells = cp.intvar(1,6, shape=(6,6)) @@ -14,34 +28,92 @@ removals_cs = cp.intvar(1,6, shape=6) def white_kropki(a, b): - # digits separated by a white dot differ by 1 + """ + digits separated by a white dot differ by 1 + + Args: + a (cp.intvar): cpmpy variable representing cell a + b (cp.intvar): cpmpy variable representing cell b + + Returns: + cpmpy constraint enforcing the white kropki rule + """ return abs(a-b) == 1 def black_kropki(a, b): - # digits separated by a black dot are in a 1:2 ratio + """ + digits separated by a black dot are in a 1:2 ratio + + Args: + a (cp.intvar): cpmpy variable representing cell a + b (cp.intvar): cpmpy variable representing cell b + + Returns: + cpmpy constraint enforcing the black kropki rule + """ return cp.any([a * 2 == b, a == 2 * b]) def zipper(args): - # equidistant cells from the middle, sum to the value in the value in the middle - assert len(args) % 2 == 1 + """ + equidistant cells from the middle, sum to the value in the value in the middle + + Args: + args (list): list of cpmpy variables representing the cells in the zipper line + + Returns: + cpmpy constraint enforcing the zipper rule + """ + assert len(args) % 2 == 1 # line must have odd length mid = len(args) // 2 return cp.all([args[i] + args[len(args)-1-i] == args[mid] for i in range(mid)]) def X(a, b): - # digits separated by an X sum to 10 + """ + digits separated by an X sum to 10 + + Args: + a (cp.intvar): cpmpy variable representing cell a + b (cp.intvar): cpmpy variable representing cell b + + Returns: + cpmpy constraint enforcing the X rule + """ return a + b == 10 def renban(args): - # digits on a pink renban form a set of consecutive non repeating digits + """ + digits on a pink renban form a set of consecutive non repeating digits + + Args: + args (list): list of cpmpy variables representing the cells in the renban line + + Returns: + cpmpy constraint enforcing the renban rule + """ return cp.all([cp.AllDifferent(args), cp.max(args) - cp.min(args) == len(args) - 1]) def cage(args, total): - # digits in a cage sum to the given total - # no all different necessary in this puzzle + """ + digits in a cage sum to the given total + no all different necessary in this puzzle + + Args: + args (list): list of cpmpy variables representing the cells in the cage + total (int): the total sum for the cage + """ return cp.sum(args) == total def duplicate(array, doppel): - # every row and column has one duplicated digit + """ + every row and column has one duplicated digit + + Args: + array (cp.intvar): cpmpy variable representing the cells in the row/column + doppel (cp.intvar): cpmpy variable representing the duplicated digit + + Returns: + cpmpy constraint enforcing the duplicate rule + """ all_triplets = [[(a, b, c), (a, c, b), (b,c,a)] for idx, a in enumerate(array) for idx2, b in enumerate(array[idx + 1:]) for c in array[idx+idx2+2:]] all_triplets = flatten(all_triplets) # any vars in a pair cannot be equal to a third var @@ -50,7 +122,16 @@ def duplicate(array, doppel): return cp.all([(var1 == var2).implies(cp.all([var1==doppel, var1 != var3])) for var1, var2, var3 in all_triplets]) def missing(array, removed): - # every row and column has one missing digit + """ + every row and column has one missing digit + + Args: + array (cp.intvar): cpmpy variable representing the cells in the row/column + removed (cp.intvar): cpmpy variable representing the removed digit + + Returns: + cpmpy constraint enforcing the missing rule + """ return cp.all([removed != elm for elm in array]) m = cp.Model( diff --git a/examples/sudoku_schrodingers_flatmates.py b/examples/sudoku_schrodingers_flatmates.py index c3676df60..2e1cb625b 100644 --- a/examples/sudoku_schrodingers_flatmates.py +++ b/examples/sudoku_schrodingers_flatmates.py @@ -1,6 +1,19 @@ import cpmpy as cp import numpy as np + +""" +Puzzle source: https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000KBK + +Rules: + +- Schrödinger Sudoku: Place one or two digits from 0-6 in every empty cell. + Every row, column and box must contain each digit 0-6 exactly once. +- Values: The value of a cell is the sum of its digit(s). +- Line: Values can't repeat on the line. +- Schrödingers Flat Mates: Every cell with value 5 must have a cell with value 1 directly above it + and/or a cell with value 9 directly below it. +""" # This cpmpy example solves a sudoku by gdc, which can be found on https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000KBK # sudoku cells @@ -11,6 +24,18 @@ values = cp.intvar(0,11, shape=(6,6)) def schrodinger_flats(cells, schrodinger, values): + """ + schrodinger flatmate rules + + Args: + cells (cp.intvar): cpmpy variable representing the sudoku cells + schrodinger (cp.intvar): cpmpy variable representing the schrodinger cells + values (cp.intvar): cpmpy variable representing the true values + + Returns: + list: list of cpmpy constraints enforcing the schrodinger flatmate rules + """ + # go over all cells constraints = [] for r in range(6): @@ -62,6 +87,15 @@ def schrodinger_flats(cells, schrodinger, values): ) def regroup_to_blocks(grid): + """ + Regroup the 6x6 grid into its 2x3 blocks. + + Args: + grid (cp.intvar): cpmpy variable representing the 6x6 sudoku grid + + Returns: + list: list of lists representing the 2x3 blocks of the sudoku grid + """ # Create an empty list to store the blocks blocks = [[] for _ in range(6)] @@ -94,4 +128,18 @@ def regroup_to_blocks(grid): print("With these schrödinger cells (-1 if not schrödinger):") print(schrodinger.value()) print("Resulting in these true values:") -print(values.value()) \ No newline at end of file +print(values.value()) + +assert (cells.value() == [[6, 3, 1, 5, 2, 4], + [2, 4, 5, 6, 0, 1], + [4, 5, 3, 0, 1, 6], + [1, 6, 0, 2, 4, 5], + [5, 0, 6, 1, 3, 2], + [3, 1, 2, 4, 6, 0]]).all() + +assert (schrodinger.value() == [[ 0, -1, -1, -1, -1, -1], + [-1, -1, -1, 3, -1, -1], + [-1, 2, -1, -1, -1, -1], + [-1, -1, -1, -1, -1, 3], + [-1, -1, 4, -1, -1, -1], + [-1, -1, -1, -1, 5, -1]]).all() \ No newline at end of file diff --git a/examples/sudoku_schrodingers_rat.py b/examples/sudoku_schrodingers_rat.py new file mode 100644 index 000000000..1c8ca3316 --- /dev/null +++ b/examples/sudoku_schrodingers_rat.py @@ -0,0 +1,575 @@ +import cpmpy as cp +import numpy as np + +""" +Puzzle source: https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000N39 + +Rules: + +- Normal Sudoku rules apply. + +- Standart RAT RUN RULES apply: +- The rat must reach the hole by finding a path through the maze. +- The path must not visit any cell more than once, cross itself, or pass through any thick maze walls. + As well as moving orthogonally, the rat may move diagonally if there's a 2x2 space in which to do so, + but may never pass diagonally through a round wall-spot on the corner of a cell. +- The rat may only pass directly through a purple arrow if moving in the direction the arrow is pointing. +- An arrow always points to the smaller of the two digits it sits between. + +SCHRÖDINGER LINE EMITTERS: +- Scattered around the lab are colored line emitters. + The path segment connecting two Emitters (including the emitter cells themselves) + must follow the rules of both emitters (see below). The emitters at both ends + of a segment must have different colors. +Clarification: The two segments extending from a single emitter are considered independent lines +(For example, a digit may appear on both sides of a renban emitter without violating its rule). +The segments extending from a region sum emitter may have different sums. + +EMITTER RULES: +- RENBAN (purple): The line contains a set of consecutive digits (not necessarily in order). +- NABNER (yellow): No two digits are consecutive, and no digit repeats. +- MODULAR (teal): Every set of three consecutive digits must include one digit from {1,4,7}, one from {2,5,8}, and one from {3,6,9}. +- ENTROPY (peach): Every set of three consecutive digits must include one digit from {1,2,3}, one from {4,5,6}, and one from {7,8,9}. +- REGION SUM (blue): The sum of the digits on the line is the same in every 3×3 box it passes through. The line has to cross at least one box-border. +- TEN SUM (gray): The line can be divided into one or more non-overlapping segments that each sum to 10. +""" + +# sudoku cells +cells = cp.intvar(1,9, shape=(9,9), name="values") +# path indices 0 if not on path else the var indicates at what point the rat passes this cell +path = cp.intvar(0, 81, shape=(9,9), name="path") +# list of cells (before, after) on path with indices and value, used for emitter constraint. (Due to the structure of the puzzle, we know there will never be more than 20 cells between two emitters on the path. This in itself is hard to know, but for efficiency reasons we reduce this amount using 'expert knowledge'.) +sequence = cp.intvar(-1, 9, shape=(9,9,6,20), name="sequence") # for each cell: before value, before row, before column, after value, after row, after column | -1 if not applicable + +# inducers for diagonal walls (prevent diagonal self-crossings) +inducers = cp.intvar(0, 80, shape=(8,8), name="inducers") + +# givens +# vertical walls +V_WALLS = np.array([[0,0,0,0,0,0,1,0], + [0,0,0,1,0,1,0,1], + [0,0,0,0,1,1,1,0], + [1,0,1,0,1,0,0,1], + [1,0,0,0,1,0,0,0], + [0,0,0,1,0,0,0,0], + [0,1,0,0,0,1,0,0], + [0,1,0,0,0,0,0,1], + [0,0,0,0,0,0,1,0]]) +# horizontal walls +H_WALLS = np.array([[0,1,0,0,0,0,0,0,0], + [0,0,1,1,0,0,0,0,0], + [0,0,1,0,0,0,0,1,0], + [0,0,0,0,0,1,0,0,0], + [0,0,0,0,0,0,0,1,1], + [0,0,0,1,0,0,1,1,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,1,1,0,0,0,0], + [0,0,0,0,0,0,0,0,0]]) +# corners walls (to block diagonal movement and future proofing for puzzles that only include walls just made out of a corner) +C_WALLS = np.array([[1,1,0,1,0,1,1,1], + [1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1], + [1,1,1,0,1,1,0,1], + [1,0,1,1,1,0,1,1], + [0,1,1,1,0,1,1,1], + [0,1,0,0,0,1,0,1], + [0,1,1,1,1,0,1,1]]) + +# emitters 1 = renban, 2 = nabner, 3 = modular, 4 = entropy, 5 = region_sum, 6 = ten_sum +EMITTERS = {(0, 5): 6, (2, 1): 5, (2, 4): 3, (2, 8): 1, (4, 2): 4, (4, 6): 6, (5, 0): 3, (5, 6): 2, (6, 5): 2, (6, 6): 4, (7, 5): 5, (8, 2): 1} + +def gate(idx1, idx2): + """ + the path can not pass from idx2 to idx1, and the value in idx1 must be greater than the value in idx2 + + Args: + idx1 (tuple): tuple representing the indices of cell 1 + idx2 (tuple): tuple representing the indices of cell 2 + + Returns: + cpmpy constraint enforcing the gate rule + """ + r1, c1 = idx1 + r2, c2 = idx2 + return cp.all([cells[r1,c1] > cells[r2,c2], path[r1,c1] - 1 != path[r2,c2]]) + +def get_reachable_neighbours(row, column): + """ + from a cell get the indices of its reachable neighbours + + Args: + row (int): row index + column (int): column index + + Returns: + list: list of tuples representing the indices of reachable neighbours + """ + reachable_neighbours = [] + if row != 0: + if H_WALLS[row-1, column] == 0: + reachable_neighbours.append([row - 1, column]) + if column != 0: + if C_WALLS[row-1, column-1] == 0: + reachable_neighbours.append([row - 1, column - 1]) + if column != 8: + if C_WALLS[row-1, column] == 0: + reachable_neighbours.append([row - 1, column + 1]) + if row != 8: + if H_WALLS[row, column] == 0: + reachable_neighbours.append([row + 1, column]) + if column != 0: + if C_WALLS[row, column-1] == 0: + reachable_neighbours.append([row + 1, column - 1]) + if column != 8: + if C_WALLS[row, column] == 0: + reachable_neighbours.append([row + 1, column + 1]) + if column != 0: + if V_WALLS[row, column-1] == 0: + reachable_neighbours.append([row, column - 1]) + if column != 8: + if V_WALLS[row, column] == 0: + reachable_neighbours.append([row, column + 1]) + return reachable_neighbours + +def path_valid(path): + """ + Add constraints to ensure the path is valid according to the walls and emitters. + Enforce Sequence values. + + Args: + path (cp.intvar): cpmpy variable representing the path indices + + Returns: + list: list of cpmpy constraints enforcing the path validity rules + """ + constraints = [] + for r in range(path.shape[0]): + for c in range(path.shape[0]): + neighbours = get_reachable_neighbours(r, c) + non_emitter_neighbours = [n for n in neighbours if tuple(n) not in EMITTERS] + emitter_neighbours = [n for n in neighbours if tuple(n) in EMITTERS] + if (r,c) == (2,8): + # The path starts on emitter. It doesn't have any previous cells so the first 3 vectors in the sequence must be fully -1. As for the the last 3, they are taken from the neighbour. Vice versa also applies. + constraints.append(cp.all([cp.all(sequence[r,c,:3].flatten() == -1)])) # no previous cells in sequence + constraints.append(cp.any([cp.all([path[nr,nc] == 2, # next cell must be the second on the path + sequence[r,c,3,0] == cells[nr,nc], # the immediate next cell value must be that of the neighbour + sequence[r,c,4,0] == nr, # the immediate next cell row must be that of the neighbour + sequence[r,c,5,0] == nc, # the immediate next cell column must be that of the neighbour + cp.all(sequence[r,c,3,1:] == sequence[nr,nc,3,:19]), # the rest of the sequence vectors must be carried over from the neighbour + cp.all(sequence[r,c,4,1:] == sequence[nr,nc,4,:19]), # the rest of the sequence vectors must be carried over from the neighbour + cp.all(sequence[r,c,5,1:] == sequence[nr,nc,5,:19]), # the rest of the sequence vectors must be carried over from the neighbour + sequence[nr,nc,0,0] == cells[r,c], # the immediate previous cell value of the neighbour must be that of the current cell + sequence[nr,nc,1,0] == r, # the immediate previous cell row of the neighbour must be that of the current cell + sequence[nr,nc,2,0] == c, # the immediate previous cell column of the neighbour must be that of the current cell + cp.all(sequence[nr,nc,0,1:] == sequence[r,c,0,:19]), # the rest of the sequence vectors must be carried over from the current cell + cp.all(sequence[nr,nc,1,1:] == sequence[r,c,1,:19]), # the rest of the sequence vectors must be carried over from the current cell + cp.all(sequence[nr,nc,2,1:] == sequence[r,c,2,:19]) # the rest of the sequence vectors must be carried over from the current cell + ]) for nr, nc in neighbours])) + elif (r,c) == (6,6): + # nothing comes after this emitter on the path + constraints.append(cp.all([cp.all(sequence[r,c,3:].flatten() == -1)])) + constraints.append(path[r,c] == cp.max(path)) + else: + # for any pathcell, the next pathcell must always be reachable + constraints.append((path[r,c] != 0).implies(cp.any([path[neighbour[0], neighbour[1]] == path[r,c] + 1 for neighbour in neighbours]))) + if (r,c) not in EMITTERS: + # carry over sequence values from non-emitter to non-emitter + constraints.append(cp.all([((path[r,c] != 0) & (path[r,c] + 1 == path[nr,nc])).implies( # non-emitter neighbour that is next on path + cp.all([sequence[r,c,3,0] == cells[nr,nc], # the immediate next cell value must be that of the neighbour + sequence[r,c,4,0] == nr, # the immediate next cell row must be that of the neighbour + sequence[r,c,5,0] == nc, # the immediate next cell column must be that of the neighbour + cp.all(sequence[r,c,3,1:] == sequence[nr,nc,3,:19]), # the rest of the sequence vectors must be carried over from the neighbour + cp.all(sequence[r,c,4,1:] == sequence[nr,nc,4,:19]), # the rest of the sequence vectors must be carried over from the neighbour + cp.all(sequence[r,c,5,1:] == sequence[nr,nc,5,:19]), # the rest of the sequence vectors must be carried over from the neighbour + sequence[nr,nc,0,0] == cells[r,c], # the immediate previous cell value of the neighbour must be that of the current cell + sequence[nr,nc,1,0] == r, # the immediate previous cell row of the neighbour must be that of the current cell + sequence[nr,nc,2,0] == c, # the immediate previous cell column of the neighbour must be that of the current cell + cp.all(sequence[nr,nc,0,1:] == sequence[r,c,0,:19]), # the rest of the sequence vectors must be carried over from the current cell + cp.all(sequence[nr,nc,1,1:] == sequence[r,c,1,:19]), # the rest of the sequence vectors must be carried over from the current cell + cp.all(sequence[nr,nc,2,1:] == sequence[r,c,2,:19]) # the rest of the sequence vectors must be carried over from the current cell + ])) for nr,nc in non_emitter_neighbours])) + # carry over sequence values from non-emitter to emitter + constraints.append(cp.all([((path[r,c] != 0) & (path[r,c] + 1 == path[nr,nc])).implies( # emitter neighbour that is next on path + cp.all([sequence[r,c,3,0] == cells[nr,nc], # the immediate next cell value must be that of the neighbour + sequence[r,c,4,0] == nr, # the immediate next cell row must be that of the neighbour + sequence[r,c,5,0] == nc, # the immediate next cell column must be that of the neighbour + cp.all(sequence[r,c,3,1:] == -1), # no continuation after emitter + cp.all(sequence[r,c,4,1:] == -1), # no continuation after emitter + cp.all(sequence[r,c,5,1:] == -1), # no continuation after emitter + sequence[nr,nc,0,0] == cells[r,c], # the immediate previous cell value of the neighbour must be that of the current cell + sequence[nr,nc,1,0] == r, # the immediate previous cell row of the neighbour must be that of the current cell + sequence[nr,nc,2,0] == c, # the immediate previous cell column of the neighbour must be that of the current cell + cp.all(sequence[nr,nc,0,1:] == sequence[r,c,0,:19]), # the rest of the sequence vectors must be carried over from the current cell + cp.all(sequence[nr,nc,1,1:] == sequence[r,c,1,:19]), # the rest of the sequence vectors must be carried over from the current cell + cp.all(sequence[nr,nc,2,1:] == sequence[r,c,2,:19]) # the rest of the sequence vectors must be carried over from the current cell + ])) for nr,nc in emitter_neighbours])) + else: + # carry over sequence values from emitter to non-emitter + constraints.append(cp.all([((path[r,c] != 0) & (path[r,c] + 1 == path[nr,nc])).implies( # non-emitter neighbour that is next on path + cp.all([sequence[r,c,3,0] == cells[nr,nc], # the immediate next cell value must be that of the neighbour + sequence[r,c,4,0] == nr, # the immediate next cell row must be that of the neighbour + sequence[r,c,5,0] == nc, # the immediate next cell column must be that of the neighbour + cp.all(sequence[r,c,3,1:] == sequence[nr,nc,3,:19]), # the rest of the sequence vectors must be carried over from the neighbour + cp.all(sequence[r,c,4,1:] == sequence[nr,nc,4,:19]), # the rest of the sequence vectors must be carried over from the neighbour + cp.all(sequence[r,c,5,1:] == sequence[nr,nc,5,:19]), # the rest of the sequence vectors must be carried over from the neighbour + sequence[nr,nc,0,0] == cells[r,c], # the immediate previous cell value of the neighbour must be that of the current cell + sequence[nr,nc,1,0] == r, # the immediate previous cell row of the neighbour must be that of the current cell + sequence[nr,nc,2,0] == c, # the immediate previous cell column of the neighbour must be that of the current cell + cp.all(sequence[nr,nc,0,1:] == -1), # no continuation before emitter + cp.all(sequence[nr,nc,1,1:] == -1), # no continuation before emitter + cp.all(sequence[nr,nc,2,1:] == -1) # no continuation before emitter + ])) for nr,nc in non_emitter_neighbours])) + # carry over sequence values from emitter to emitter + constraints.append(cp.all([((path[r,c] != 0) & (path[r,c] + 1 == path[nr,nc])).implies( # emitter neighbour that is next on path + cp.all([sequence[r,c,3,0] == cells[nr,nc], # the immediate next cell value must be that of the neighbour + sequence[r,c,4,0] == nr, # the immediate next cell row must be that of the neighbour + sequence[r,c,5,0] == nc, # the immediate next cell column must be that of the neighbour + cp.all(sequence[r,c,3,1:] == -1), # no continuation after emitter + cp.all(sequence[r,c,4,1:] == -1), # no continuation after emitter + cp.all(sequence[r,c,5,1:] == -1), # no continuation after emitter + sequence[nr,nc,0,0] == cells[r,c], # the immediate previous cell value of the neighbour must be that of the current cell + sequence[nr,nc,1,0] == r, # the immediate previous cell row of the neighbour must be that of the current cell + sequence[nr,nc,2,0] == c, # the immediate previous cell column of the neighbour must be that of the current cell + cp.all(sequence[nr,nc,0,1:] == -1), # no continuation before emitter + cp.all(sequence[nr,nc,1,1:] == -1), # no continuation before emitter + cp.all(sequence[nr,nc,2,1:] == -1) # no continuation before emitter + ])) for nr,nc in emitter_neighbours])) + + # for any non-pathcell, its sequence must be fully -1 + constraints.append((path[r,c] == 0).implies(cp.all(sequence[r,c] == -1))) + + # if the path moves diagonally, it induces a C_WALL + for nr, nc in neighbours: + if abs(nr - r) == 1 and abs(nc - c) == 1: + wall_r = min(r, nr) + wall_c = min(c, nc) + constraints.append((cp.all([path[r,c] != 0, path[r,c] + 1 == path[nr,nc]])).implies(inducers[wall_r, wall_c] == path[r,c])) # this forces no diagonal crossings + return constraints + +def same_region(r1, c1, r2, c2): + """ + check if two cells are in the same 3x3 region + + Args: + r1 (int): row index of cell 1 + c1 (int): column index of cell 1 + r2 (int): row index of cell 2 + c2 (int): column index of cell 2 + + Returns: + cpmpy constraint enforcing the same region rule; will evaluate to BoolVal(True) if both cells are in the same 3x3 region, else BoolVal(False) + """ + return cp.all([(r1 // 3 == r2 // 3), (c1 // 3 == c2 // 3)]) + +def renban(array, rs, cs): + """ + the line must form a set of consecutive non repeating digits + + Args: + array (cp.intvar): cpmpy variable representing the cells in the line segment + rs (list): list of row indices for the cells in the line segment + cs (list): list of column indices for the cells in the line segment + + Returns: + list: list of cpmpy constraints enforcing the renban rule + """ + cons = [] + renban_emitters = [k for k, v in EMITTERS.items() if v == 1] + + for i in range(len(array) - 1): + cons.append((cp.all([array[i] != -1, array[i+1] == -1])).implies(cp.all([cp.any([rs[i] != er, cs[i] != ec]) for (er, ec) in renban_emitters]))) # if the next cell is -1, the current cell can't be a renban emitter + cons.append(cp.all([cp.AllDifferentExceptN(array, -1), cp.max(array) - cp.min([a + 10*(a == -1) for a in array]) + 1 == len(array) - cp.Count(array, -1)])) + return cons + +def nabner(array, rs, cs): + """ + no two digits are consecutive and no digit repeats + + Args: + array (cp.intvar): cpmpy variable representing the cells in the line segment + rs (list): list of row indices for the cells in the line segment + cs (list): list of column indices for the cells in the line segment + + Returns: + list: list of cpmpy constraints enforcing the nabner rule + """ + cons = [] + nabner_emitters = [k for k, v in EMITTERS.items() if v == 2] + + for i in range(len(array) - 1): + cons.append((cp.all([array[i] != -1, array[i+1] == -1])).implies(cp.all([cp.any([rs[i] != er, cs[i] != ec]) for (er, ec) in nabner_emitters]))) # if the next cell is -1, the current cell can't be a nabner emitter + + for i in range(len(array) - 1): + cons.append((array[i] != -1).implies(cp.all([cp.abs(array[i] - array[j]) > 1 for j in range(i+1,len(array))]))) + cons.append(cp.AllDifferentExceptN(array, -1)) + return cons + +def modular(array, rs, cs): + """ + every set of 3 consecutive digits must have a different value mod 3 + + Args: + array (cp.intvar): cpmpy variable representing the cells in the line segment + rs (list): list of row indices for the cells in the line segment + cs (list): list of column indices for the cells in the line segment + + Returns: + list: list of cpmpy constraints enforcing the modular rule + """ + cons = [] + modular_emitters = [k for k, v in EMITTERS.items() if v == 3] + + for i in range(len(array) - 1): + cons.append((cp.all([array[i] != -1, array[i+1] == -1])).implies(cp.all([cp.any([rs[i] != er, cs[i] != ec]) for (er, ec) in modular_emitters]))) # if the next cell is -1, the current cell can't be a modular emitter + arr = cp.intvar(-1, 2, shape=len(array)) + cons.append(cp.all([cp.all(arr == (array % 3)), cp.all([cp.AllDifferentExceptN(arr[i:i+3], -1) for i in range(len(arr) - 2)])])) + return cons + +def entropy(array, rs, cs): + """ + every set of 3 consecutive digit must have 1 low digit (1-3) and 1 middle digit (4-6) and 1 high digit (7-9) + + Args: + array (cp.intvar): cpmpy variable representing the cells in the line segment + rs (list): list of row indices for the cells in the line segment + cs (list): list of column indices for the cells in the line segment + + Returns: + list: list of cpmpy constraints enforcing the entropy rule + """ + cons = [] + entropy_emitters = [k for k, v in EMITTERS.items() if v == 4] + + for i in range(len(array) - 1): + cons.append((cp.all([array[i] != -1, array[i+1] == -1])).implies(cp.all([cp.any([rs[i] != er, cs[i] != ec]) for (er, ec) in entropy_emitters]))) # if the next cell is -1, the current cell can't be an entropy emitter + cons.append(cp.all([cp.AllDifferentExceptN([(a+2) // 3 for a in array[i:i+3]], 0) for i in range(len(array) - 2)])) + return cons + +def region_sum(array, rs, cs, order): + """ + box borders divide the line into segments of the same sum + + Args: + array (cp.intvar): cpmpy variable representing the cells in the line segment + rs (list): list of row indices for the cells in the line segment + cs (list): list of column indices for the cells in the line segment + order (string): order of the line segment (before or after emitter) + + Returns: + list: list of cpmpy constraints enforcing the region sum rule + """ + running_sums = cp.intvar(-1, 45, shape=len(array), name=f"running_sums_{rs[0]}_{cs[0]}_{order}") + region_sum = cp.intvar(1, 45, name=f"region_sum_{rs[0]}_{cs[0]}_{order}") + cons = [] + region_sum_emitters = [k for k, v in EMITTERS.items() if v == 5] + + cons.append(region_sum == running_sums[0]) + cons.append(running_sums[-1] == array[-1]) + for i in range(len(array)-1): + cons.append((cp.all([array[i+1] != -1, ~same_region(rs[i], cs[i], rs[i+1], cs[i+1])])).implies(cp.all([running_sums[i] == array[i], region_sum == running_sums[i+1]]))) + cons.append((cp.all([(array[i+1] == -1), (array[i] != -1)])).implies(running_sums[i] == array[i])) + cons.append((cp.all([array[i+1] != -1, same_region(rs[i], cs[i], rs[i+1], cs[i+1])])).implies(cp.all([running_sums[i] == running_sums[i+1] + array[i]]))) + cons.append((array[i] == -1).implies(running_sums[i] == array[i])) + # the last cell can not be another region_sum emitter + cons.append((cp.all([array[i] != -1, array[i+1] == -1])).implies(cp.all([cp.any([rs[i] != er, cs[i] != ec]) for (er, ec) in region_sum_emitters]))) # if the next cell is -1, the current cell can't be a region_sum emitter + cons.append(cp.Count(running_sums, region_sum) >= 2) # at least two segments + return cons + +def ten_sum(array, rs, cs, order): + """ + the line can be divided into one or more non-overlapping segments that each sum to 10 + + Args: + array (cp.intvar): cpmpy variable representing the cells in the line segment + rs (list): list of row indices for the cells in the line segment + cs (list): list of column indices for the cells in the line segment + order (string): order of the line segment (before or after emitter) + + Returns: + list: list of cpmpy constraints enforcing the ten sum rule + """ + running_sums = cp.intvar(-1, 45, shape=len(array), name=f"ten_running_sums_{rs[0]}_{cs[0]}_{order}") + splits = cp.boolvar(shape=len(array)-1, name=f"splits_{rs[0]}_{cs[0]}_{order}") + region_sum = 10 + cons = [] + ten_sum_emitters = [k for k, v in EMITTERS.items() if v == 6] + + cons.append(region_sum == running_sums[0]) + cons.append(running_sums[-1] == array[-1]) + for i in range(len(array)-1): + cons.append((cp.all([array[i+1] != -1, splits[i]])).implies(cp.all([running_sums[i] == array[i], region_sum == running_sums[i+1]]))) + cons.append((cp.all([(array[i+1] == -1), (array[i] != -1)])).implies(running_sums[i] == array[i])) + cons.append((cp.all([array[i+1] != -1, ~splits[i]])).implies(cp.all([running_sums[i] == running_sums[i+1] + array[i]]))) + cons.append((cp.all([array[i] != -1, array[i+1] == -1])).implies(cp.all([cp.any([rs[i] != er, cs[i] != ec]) for (er, ec) in ten_sum_emitters]))) # if the next cell is -1, the current cell can't be a ten_sum emitter + return cons + +def activate_lines(sequence): + """ + Iterate over all emitters and activate their respective constraints based on if they are on the path + + Args: + sequence (cp.intvar): cpmpy variable representing the sequence of cells (values, rows, columns) before and after each cell + + Returns: + list: list of cpmpy constraints enforcing the emitter rules + """ + constraints = [] + + for er,ec in EMITTERS: + line = EMITTERS[(er,ec)] + before = np.concatenate(([cells[er,ec]], sequence[er,ec,0,:])) # before emitter + after = np.concatenate(([cells[er,ec]], sequence[er,ec,3,:])) # after emitter + before_rs = np.concatenate(([er], sequence[er,ec,1,:])) # before emitter rows + before_cs = np.concatenate(([ec], sequence[er,ec,2,:])) # before emitter columns + after_rs = np.concatenate(([er], sequence[er,ec,4,:])) # after emitter rows + after_cs = np.concatenate(([ec], sequence[er,ec,5,:])) # after emitter columns + + cons_before = [] + cons_after = [] + + if line == 1: + cons_before = renban(before, before_rs, before_cs) + cons_after = renban(after, after_rs, after_cs) + elif line == 2: + cons_before = nabner(before, before_rs, before_cs) + cons_after = nabner(after, after_rs, after_cs) + elif line == 3: + cons_before = modular(before, before_rs, before_cs) + cons_after = modular(after, after_rs, after_cs) + elif line == 4: + cons_before = entropy(before, before_rs, before_cs) + cons_after = entropy(after, after_rs, after_cs) + elif line == 5: + cons_before = region_sum(before, before_rs, before_cs, "before") + cons_after = region_sum(after, after_rs, after_cs, "after") + elif line == 6: + cons_before = ten_sum(before, before_rs, before_cs, "before") + cons_after = ten_sum(after, after_rs, after_cs, "after") + for c in cons_before: + constraints.append((path[er,ec] > 1).implies(c)) + for c in cons_after: + constraints.append((cp.all([0 < path[er,ec],path[er,ec] < cp.max(path)])).implies(c)) + return constraints + +m = cp.Model( + + # path givens + path[2,8] == 1, + cp.max(path) == path[6,6], + + # no duplicate path indices + cp.AllDifferentExcept0(path), + + # gates + gate((0,4), (1,4)), + gate((2,3), (2,2)), + gate((6,6), (7,6)), + + # emitter constraints + activate_lines(sequence), + # general path constraints + path_valid(path) +) + +def regroup_to_blocks(grid): + """ + Regroup the 9x9 grid into its 3x3 blocks. + + Args: + grid (cp.intvar): A 9x9 grid of integer variables. + + Returns: + list: A list of 9 lists, each containing the elements of a 3x + """ + # Create an empty list to store the blocks + blocks = [[] for _ in range(9)] + + for row_index in range(9): + for col_index in range(9): + # Determine which block the current element belongs to + block_index = (row_index // 3) * 3 + (col_index // 3) + # Add the element to the appropriate block + blocks[block_index].append(grid[row_index][col_index]) + + return blocks + + +blocks = regroup_to_blocks(cells) + +for i in range(cells.shape[0]): + m += cp.AllDifferent(cells[i,:]) + m += cp.AllDifferent(cells[:,i]) + m += cp.AllDifferent(blocks[i]) + +def print_grid(grid): + """Print the start grid with walls and emitters.""" + for r in range(grid.shape[0]*2+1): + row = "" + if r == 0 or r == grid.shape[0]*2: + for c in range(grid.shape[1]*2+1): + if c % 2 == 0: + row += "+" + else: + row += "---" + elif r % 2 == 0: + for c in range(grid.shape[1]*2+1): + if c == 0 or c == grid.shape[1]*2: + row += "+" + elif c % 2 == 0: + if C_WALLS[r//2 - 1, c//2 - 1] == 1: + row += "+" + else: + row += " " + else: + if H_WALLS[r//2 - 1, c//2] == 1: + row += "---" + else: + row += " " + else: + for c in range(grid.shape[1]*2+1): + if c == 0 or c == grid.shape[1]*2: + row += "|" + elif c % 2 == 0: + if V_WALLS[r//2, c//2 - 1] == 1: + row += "|" + else: + row += " " + else: + if (r//2, c//2) not in EMITTERS: + row += " " + else: + row += " " + str(EMITTERS[(r//2, c//2)]) + " " + print(row) + print("") + +print("The puzzle is:") +print_grid(cells) + +print("Number of constraints:", len(m.constraints)) + +sol = m.solve() + +print("The solution is:") +print(cells.value()) +print("The path is (0 if not on the path):") +print(path.value()) + +assert (cells.value() == [[9, 7, 3, 5, 6, 4, 2, 8, 1], + [1, 6, 5, 8, 3, 2, 9, 7, 4], + [8, 4, 2, 9, 1, 7, 5, 6, 3], + [3, 8, 4, 2, 9, 6, 7, 1, 5], + [7, 1, 9, 3, 5, 8, 6, 4, 2], + [2, 5, 6, 4, 7, 1, 3, 9, 8], + [6, 2, 1, 7, 4, 3, 8, 5, 9], + [4, 9, 8, 6, 2, 5, 1, 3, 7], + [5, 3, 7, 1, 8, 9, 4, 2, 6]]).all() + +assert (path.value() == [[ 0, 0, 0, 16, 15, 14, 0, 0, 0], + [ 0, 18, 17, 0, 12, 13, 0, 0, 0], + [20, 19, 0, 0, 11, 0, 0, 0, 1], + [21, 0, 0, 0, 10, 0, 0, 0, 2], + [22, 0, 0, 0, 9, 0, 5, 4, 3], + [23, 0, 0, 0, 8, 6, 0, 0, 0], + [24, 0, 0, 0, 31, 7, 36, 35, 0], + [25, 0, 29, 30, 0, 32, 34, 0, 0], + [26, 27, 28, 0, 0, 33, 0, 0, 0]]).all() \ No newline at end of file diff --git a/examples/sudoku_zeroville.py b/examples/sudoku_zeroville.py new file mode 100644 index 000000000..7fea587b8 --- /dev/null +++ b/examples/sudoku_zeroville.py @@ -0,0 +1,227 @@ +import cpmpy as cp +import numpy as np + +""" +Puzzle source: https://logic-masters.de/Raetselportal/Raetsel/zeigen.php?id=000ONO + +Rules: + +- Place six 3x2 boxes into the grid. They can be placed either horizontally or vertically, but cannot overlap. + +- Place the digits 1-6 in each box so that these digits do not repeat in a row, column or box. + Any cell outside a box contains a zero. + +The following line rules apply to all digits along a line, (even the zeroes!) + + - (RED) ALTERNATING PARITY: Adjacent digits on a red line contain one even digit and one odd digit. + - (TURQUOISE) SAME DIFFERENCE: Adjacent digits on a turquoise line have the same difference, to be determined. + - (YELLOW) PALINDROME: A yellow line reads the same in either direction. + - (GREEN) GERMAN WHISPER: Adjacent digits on a green line have a difference of at least FIVE. + - (DARK BLUE) REGION SUM: Box borders divide the darker blue line into segments. + The digits on each segment have the same sum. + - (LAVENDER) ZIPPER: Two digits that are an equal distance from the central spot on a lavender line + sum to the digit on that central spot. + - (GREY) SLOW THERMO: Starting at the end with the bulb and moving along it, + digits on a slow thermo always increase or stay the same (but never decrease.) +""" + +# w, h of block +w, h = 3, 2 + +# sudoku cells +cells = cp.intvar(0,6, shape=(9,9)) + +# orientations of blocks +orientations = cp.boolvar(shape=6) + +# starts and ends +starts = cp.intvar(0, 7, shape=(6, 2)) +ends = cp.intvar(2, 9, shape=(6, 2)) +# block shapes +blocks = cp.intvar(2, 3, shape=(6, 2)) + +# intvars for cell to block mapping +cell_blocks = cp.intvar(0, 6, shape=(9, 9)) + +def same_difference(array): + """ + adjacent cells on the line must all have the same difference + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + + Returns: + cpmpy constraint enforcing the same difference rule + """ + diff = cp.intvar(0,6, shape=1) + return cp.all([abs(a-b) == diff for a,b in zip(array, array[1:])]) + +def zipper(array): + """ + equidistant cells from the middle, sum to the value in the value in the middle + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + + Returns: + cpmpy constraint enforcing the zipper rule + """ + assert len(array) % 2 == 1 + mid = len(array) // 2 + return cp.all([array[i] + array[len(array)-1-i] == array[mid] for i in range(mid)]) + +def parity(array): + """ + adjacent cells on the line must have different parity + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + + Returns: + cpmpy constraint enforcing the parity rule + """ + return cp.all([a % 2 != b % 2 for a,b in zip(array, array[1:])]) + +def whisper(array): + """ + adjacent cells on the line must have a difference of at least five (5) + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + + Returns: + cpmpy constraint enforcing the whisper rule + """ + return cp.all([abs(a-b) >= 5 for a,b in zip(array, array[1:])]) + +def palindrome(array): + """ + the line must read the same forwards and backwards + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + + Returns: + cpmpy constraint enforcing the palindrome rule + """ + return cp.all([array[i] == array[len(array)-1-i] for i in range(len(array)//2)]) + +def slow_thermo(array): + """ + digits on a slow thermo must increase or stay equal + + Args: + array (cp.intvar): cpmpy variable representing the cells on the line + + Returns: + cpmpy constraint enforcing the slow thermo rule + """ + return cp.all([a <= b for a,b in zip(array, array[1:])]) + +def region_sum(args): + """ + box borders divide the line into segments of the same sum + use cell_blocks vars, track sum of segment until adjacent digits belong to different blocks + + Args: + args (list): list of tuples representing the (row, col) indices of the cells on the line + + Returns: + cpmpy constraint enforcing the region sum rule + """ + # the input in this case is actually the indices which can be used both for the cells vars and the cell_blocks vars + + running_sums = cp.intvar(0, 21, shape=len(args)) + region_sum = cp.intvar(0, 21) + cons = [] + cons.append(region_sum == running_sums[0]) + cons.append(running_sums[-1] == cells[args[-1,0], args[-1,1]]) + for i in range(len(args)-1): + cons.append((cell_blocks[args[i, 0], args[i, 1]] != cell_blocks[args[i+1, 0], args[i+1, 1]]).implies(cp.all([running_sums[i] == cells[args[i, 0], args[i, 1]], region_sum == running_sums[i+1]]))) + cons.append((cell_blocks[args[i, 0], args[i, 1]] == cell_blocks[args[i+1, 0], args[i+1, 1]]).implies(cp.all([running_sums[i] == running_sums[i+1] + cells[args[i,0], args[i,1]]]))) + + return cp.all(cons) + + +m = cp.Model( + # add line constraints + parity(cells[0,:3]), + same_difference(np.concatenate((cells[0,3:5], [cells[1,5]], cells[0,6:8]))), + zipper(np.concatenate((cells[3,5:], cells[2::-1,8]))), + parity(np.array([cells[1,6], cells[2,6], cells[2,7]])), + whisper(np.array([cells[1,3], cells[2,2], cells[3,3], cells[2,3]])), + palindrome(np.array([cells[5,3], cells[6,3], cells[6,4]])), + palindrome(np.array([cells[5,4], cells[4,4], cells[4,5]])), + slow_thermo(np.concatenate((cells[8,2::-1], cells[7:4:-1,0], [cells[5,1]], [cells[4,2]], [cells[3,2]]))), + slow_thermo(np.array([cells[6,2], cells[7,3], cells[8,4]])), + zipper(np.array([cells[5,6], cells[6,7], cells[7,6]])), + same_difference(np.array([cells[4,8], cells[5,7], cells[6,8]])), + same_difference(cells[8,6:]), + region_sum(np.array([(1,0), (2,0), (3,0), (3,1), (2,1), (1,1)])) +) + +for i in range(cells.shape[0]): + m += cp.AllDifferentExcept0(cells[i,:]) + m += cp.AllDifferentExcept0(cells[:,i]) + +for i in range(6): + # block orientation + m += (orientations[i] == 0).implies(blocks[i,0] == w) + m += (orientations[i] == 1).implies(blocks[i,0] == h) + m += (orientations[i] == 0).implies(blocks[i,1] == h) + m += (orientations[i] == 1).implies(blocks[i,1] == w) + # block starts and ends + m += starts[i,0] + blocks[i,0] == ends[i,0] + m += starts[i,1] + blocks[i,1] == ends[i,1] + +# block constraints +# Create mapping of value, block to unique ID, then all different. This also ensures there is just 6 cells for each block +m += cp.AllDifferentExceptN([cell_blocks[i,j]*6 + cells[i,j]-1 for i in range(9) for j in range(9)], -1) + +# Use start and end to restrict cell_blocks values +for i in range(9): + for j in range(9): + for b in range(6): + in_block = cp.boolvar() + in_block = cp.all([ + cp.all([i >= starts[b,0], i < ends[b,0]]), + cp.all([j >= starts[b,1], j < ends[b,1]])] + ) + + # this in also enforces the no_overlap2d constraint in a non-global way, but it is useful to actually have the mapping + m += (in_block).implies(cp.all([cell_blocks[i,j] == b+1, cells[i,j] != 0])) + m += (~in_block).implies(cell_blocks[i,j] != b+1) + m += (cell_blocks[i,j] == 0).implies(cells[i,j] == 0) + + +for i in range(len(starts)-1): + r1, c1 = starts[i] + for j in range(i+1, len(starts)): + r2, c2 = starts[j] + m += (r1 < r2) | (cp.all([r1 == r2, c1 < c2])) # symmetry breaking, slow + +sol = m.solve() +print("The solution is:") +print(cells.value()) +print("The blocks are mapped like this:") +print(cell_blocks.value()) + +assert (cells.value() == [[2, 5, 0, 4, 3, 6, 1, 0, 0], + [3, 1, 0, 5, 6, 2, 4, 0, 0], + [4, 6, 0, 1, 2, 3, 5, 0, 0], + [5, 2, 4, 6, 1, 0, 0, 0, 0], + [6, 4, 3, 2, 5, 0, 0, 0, 0], + [1, 3, 0, 0, 0, 0, 2, 4, 0], + [0, 0, 0, 0, 0, 0, 6, 5, 0], + [0, 0, 0, 0, 0, 0, 3, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0]]).all() + +assert (cell_blocks.value() == [[1, 1, 0, 2, 2, 3, 3, 0, 0], + [1, 1, 0, 2, 2, 3, 3, 0, 0], + [1, 1, 0, 2, 2, 3, 3, 0, 0], + [4, 4, 5, 5, 5, 0, 0, 0, 0], + [4, 4, 5, 5, 5, 0, 0, 0, 0], + [4, 4, 0, 0, 0, 0, 6, 6, 0], + [0, 0, 0, 0, 0, 0, 6, 6, 0], + [0, 0, 0, 0, 0, 0, 6, 6, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0]]).all() \ No newline at end of file diff --git a/tests/test_examples.py b/tests/test_examples.py index 1db1a819a..af79604e1 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -24,7 +24,9 @@ SKIPPED_EXAMPLES = [ "ocus_explanations.py", # waiting for issues to be resolved - "psplib.py" # randomly fails on github due to file creation + "psplib.py", # randomly fails on github due to file creation + "sudoku_chaos_killer.py", # too slow on github actions + "sudoku_schrodingers_rat.py", # too slow on github actions ] SKIP_MIP = ['npuzzle.py', 'tst_likevrp.py', 'sudoku_', 'pareto_optimal.py',