Skip to content

Commit

Permalink
Add two-sided evolutionary block packer, for #59.
Browse files Browse the repository at this point in the history
  • Loading branch information
donkirkby committed Feb 4, 2025
1 parent 9998f7b commit 1b82044
Show file tree
Hide file tree
Showing 9 changed files with 654 additions and 83 deletions.
9 changes: 9 additions & 0 deletions four_letter_blocks/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ def shape_rotation_names() -> typing.List[str]:
names.sort()
return names

@property
def rotated_shape(self) -> str:
shape = self.shape
assert shape is not None

if shape != 'O':
shape += str(self.shape_rotation)
return shape

@property
def face_colour(self):
return self.squares[0].face_colour
Expand Down
36 changes: 31 additions & 5 deletions four_letter_blocks/block_packer.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,15 @@ def display(self, state: np.ndarray | None = None) -> str:
for c in row)
for row in state)

def sort_blocks(self):
def sort_blocks(self,
block_nums: typing.Sequence[int] | None = None) -> None:
if block_nums is None:
block_nums = range(2, 256)
block_nums_iter = iter(block_nums)
assert self.state is not None
state = np.zeros(self.state.shape, np.uint8)
gap_spaces = self.state == 1
state += gap_spaces
next_block = 2
for row in range(state.shape[0]):
for col in range(state.shape[1]):
new_block = state[row, col]
Expand All @@ -219,8 +223,8 @@ def sort_blocks(self):
continue
# noinspection PyUnresolvedReferences
block_spaces = (self.state == old_block).astype(np.uint8)
next_block = next(block_nums_iter)
state += next_block * block_spaces
next_block += 1
self.state = state

def create_blocks(self) -> typing.Iterable[Block]:
Expand Down Expand Up @@ -257,7 +261,7 @@ def create_block(self, block_num):
block = Block(*squares)
return block

def fill(self, shape_counts: typing.Counter[str]) -> bool:
def fill(self, shape_counts: typing.Counter[str] | None = None) -> bool:
""" Fill in the current state with the given shapes.
Cycles through the available shapes in shape_counts, and tries them in
Expand All @@ -273,7 +277,8 @@ def fill(self, shape_counts: typing.Counter[str]) -> bool:
:param shape_counts: number of blocks of each shape, disables rotation
if any of the shapes contain a letter and rotation number. Adjusted
to remaining counts, if self.are_partials_saved is True.
to remaining counts, if self.are_partials_saved is True. If None,
then calls calculate_max_shape_counts().
:return: True, if all requested shapes have been placed, or if no gaps
are left, otherwise False.
"""
Expand All @@ -287,6 +292,8 @@ def fill(self, shape_counts: typing.Counter[str]) -> bool:
best_state = None
assert self.state is not None
start_state = self.state
if shape_counts is None:
shape_counts = self.calculate_max_shape_counts()
if not sum(shape_counts.values()):
# Nothing to add!
best_state = start_state
Expand Down Expand Up @@ -453,6 +460,25 @@ def place_block(self,
target += block_num * block
yield new_state

def remove_block(self, row: int, col: int) -> str:
""" Remove a block from the current state.
:param row: row to remove the block from
:param col: column to remove the block from
:return: the shape of the removed block
:raises ValueError: if there is no block at that position"""
assert self.state is not None
block_num = self.state[row, col]
if block_num <= 1:
raise ValueError(f'No block at ({row}, {col}).')
block = self.create_block(block_num)
shape = block.shape
if shape != 'O':
shape += str(block.shape_rotation)
self.state[self.state == block_num] = 0

return shape

def count_filled_rows(self):
filled = np.nonzero(self.state > self.GAP)
if not filled[0].size:
Expand Down
84 changes: 67 additions & 17 deletions four_letter_blocks/double_block_packer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import typing
from collections import Counter
from collections import Counter, defaultdict

import numpy as np

Expand All @@ -8,21 +8,27 @@


class DoubleBlockPacker:
def __init__(self, front_text: str, back_text: str, tries: int) -> None:
front_lines = front_text.splitlines()
width = len(front_lines[0])
height = len(front_lines)

self.front_packer = BlockPacker(width,
height,
start_text=front_text)
def __init__(self,
front_text: str | None = None,
back_text: str | None = None,
tries: int = -1,
start_state: np.ndarray | None = None) -> None:
if start_state is None:
front_state = back_state = None
else:
full_height = start_state.shape[0]
front_state = start_state[:full_height//2]
back_state = start_state[full_height//2:]
self.front_packer = BlockPacker(start_text=front_text,
start_state=front_state)
self.front_packer.force_fours = True
self.width = self.front_packer.width
self.height = self.front_packer.height
front_unused = np.count_nonzero(
self.front_packer.state == BlockPacker.UNUSED)

self.back_packer = BlockPacker(width,
height,
start_text=back_text)
self.back_packer = BlockPacker(start_text=back_text,
start_state=back_state)
self.back_packer.force_fours = True
back_unused = np.count_nonzero(
self.back_packer.state == BlockPacker.UNUSED)
Expand All @@ -33,17 +39,27 @@ def __init__(self, front_text: str, back_text: str, tries: int) -> None:
self.front_shape_counts = self.front_packer.calculate_max_shape_counts()
self.tries = tries
self.is_full = False
self.are_slots_shuffled = False
self.needed_block_count = front_unused // 4

def fill(self) -> bool:
@property
def state(self):
return np.concatenate((self.front_packer.state, self.back_packer.state))

def fill(self, shape_counts: dict[str, np.ndarray] | None = None) -> bool:
""" Fill both front and back with the same block shapes and rotations.
:return: True if no gaps remain, False otherwise.
"""
if shape_counts is None:
front_shape_counts = self.front_shape_counts
else:
front_shape_counts = shape_counts
if self.tries == 0:
# print('0 tries left.')
return False
self.tries -= 1
if self.tries > 0:
self.tries -= 1
width = self.front_packer.width
height = self.front_packer.height
flipped_shape_names = flipped_shapes()
Expand Down Expand Up @@ -87,10 +103,10 @@ def fill(self) -> bool:
shape_scores: typing.Counter[str] = Counter()
for shape, slot_count in slot_counts.items():
if is_front_first:
target_count = self.front_shape_counts[shape]
target_count = front_shape_counts[shape]
else:
front_shape = flipped_shape_names[shape]
target_count = self.front_shape_counts[front_shape]
target_count = front_shape_counts[front_shape]
if target_count == 0:
continue
# noinspection PyTypeChecker
Expand All @@ -101,6 +117,10 @@ def fill(self) -> bool:
start_state2 = packer2.state
assert start_state2 is not None
next_block = packer1.find_next_block()
if self.are_slots_shuffled:
rng = np.random.default_rng()
else:
rng = None
for shape1, _score in shape_scores.most_common():
shape2 = flipped_shape_names[shape1]
masks1 = all_masks[shape1]
Expand All @@ -112,6 +132,8 @@ def fill(self) -> bool:
mins1)
if all_coords1.size == 0:
continue
if rng is not None:
rng.shuffle(all_coords1)
slots2_masked = masks2[shape2_slots].any(axis=0)[:width, :height]
slots2_coverage = slots2_masked * coverage2
uncovered2 = slots2_coverage == 0
Expand Down Expand Up @@ -156,7 +178,7 @@ def fill(self) -> bool:
# f'index1 ({slot_row1}, {slot_col1}), '
# f'index2 ({slot_row2}, {slot_col2})')
# print(self.display())
self.is_full = self.fill()
self.is_full = self.fill(front_shape_counts)
if self.is_full:
# print('Full!')
return True
Expand All @@ -168,6 +190,20 @@ def fill(self) -> bool:
# print('Tried all minimum slots.')
return False

def remove_block(self, row: int, col: int) -> str:
""" Remove a block from the current state.
:param row: row to remove the block from
:param col: column to remove the block from
:return: the shape of the removed block
:raises ValueError: if there is no block at that position"""
block_num = self.state[row, col]
shape = self.front_packer.remove_block(row, col)
back_block = self.back_packer.create_block(block_num)
back_square = back_block.squares[0]
self.back_packer.remove_block(back_square.y, back_square.x)
return shape

@staticmethod
def find_slot_coords(shape_slots, masks, min_coverages):
all_coords = np.ndarray((0, 2), dtype=int)
Expand All @@ -183,6 +219,20 @@ def sort_blocks(self):
self.front_packer.sort_blocks()
self.back_packer.sort_blocks()

front_blocks = defaultdict(list)
for block_num, block in self.front_packer.create_blocks_with_block_num():
shape = block.rotated_shape
front_blocks[shape].append(block_num)

flipped_shape_names = flipped_shapes()
block_nums = []
for block_num, block in self.back_packer.create_blocks_with_block_num():
back_shape = block.rotated_shape
front_shape = flipped_shape_names[back_shape]
back_block_num = front_blocks[front_shape].pop(0)
block_nums.append(back_block_num)
self.back_packer.sort_blocks(block_nums)

def display(self) -> str:
front_display = self.front_packer.display()
back_display = self.back_packer.display()
Expand Down
55 changes: 55 additions & 0 deletions four_letter_blocks/double_evo_packer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import typing

import numpy as np

from four_letter_blocks.double_block_packer import DoubleBlockPacker
from four_letter_blocks.evo_packer import (EvoPacker, PackingFitnessCalculator,
FitnessScore)


class DoubleEvoPacker(EvoPacker):
def __init__(self,
front_text: str,
back_text: str,
tries: int = -1,
start_state: np.ndarray | None = None) -> None:
start_packer = DoubleBlockPacker(front_text,
back_text,
tries,
start_state)
super().__init__(start_state=start_packer.state[0:start_packer.height],
tries=tries)
self.state = start_packer.state
self.front_shape_counts = start_packer.front_shape_counts

def setup(self,
shape_counts: typing.Counter[str] | None = None,
fitness_calculator: PackingFitnessCalculator | None = None) -> None:
if fitness_calculator is None:
fitness_calculator = DoublePackingFitnessCalculator()
if shape_counts is None:
shape_counts = self.front_shape_counts.copy()
super().setup(shape_counts, fitness_calculator)

def create_init_params(self, shape_counts):
init_params = super().create_init_params(shape_counts)
init_params['packer_class'] = DoubleBlockPacker
return init_params

def display(self, state: np.ndarray | None = None) -> str:
if state is None:
state = self.state
packer = DoubleBlockPacker(start_state=state)
return packer.display()


class DoublePackingFitnessCalculator(PackingFitnessCalculator):
def calculate_from_state(self, state) -> FitnessScore:
full_height = state.shape[0]
height = full_height // 2
front_state = state[:height]
back_state = state[height:]
front_score = super().calculate_from_state(front_state)
back_score = super().calculate_from_state(back_state)
front_score.warning_count += back_score.warning_count
return front_score
Loading

0 comments on commit 1b82044

Please sign in to comment.