# puzzlegen.core.batch_generator

View Source

      1import datetime
      2import logging
      3import csv
      4import os
      5import shutil
      6import base64
      7import sys
      8from pathlib import Path
      9from matplotlib.backends.backend_pdf import PdfPages
     10import matplotlib.pyplot as plt
     11from puzzlegen.core.bfs_solver import BFSSolver
     12from puzzlegen.core.grid_initializer import GridInitializer
     13from puzzlegen.core.puzzle_logic import PuzzleLogic
     14from tqdm import tqdm
     15from .utils import print_framed
     16logging.getLogger().setLevel(logging.INFO)
     17logger = logging.getLogger(__name__)
     18
     19class PuzzleBatchGenerator:
     20    """
     21    Generate and manage batches of puzzles by iterating over ranges of parameters.
     22
     23    This class systematically generates puzzles for each integer value within the given
     24    ranges of block counts and color counts. For each combination, it attempts to generate
     25    puzzles solvable within the specified move limit.
     26
     27    It provides functionality to:
     28      - Filter out puzzles unsolvable within the move constraints.
     29      - Collect statistics about solvable and unsolvable puzzles across the batch.
     30      - Export puzzles visually as PDFs and metadata as CSV files.
     31      - Display summary charts (bar and pie) to analyze batch characteristics.
     32
     33    Args:
     34
     35        blocks_range (tuple[int, int]): Inclusive range of block counts to iterate through (min_blocks, max_blocks).
     36
     37        colors_range (tuple[int, int]): Inclusive range of color counts to iterate through (min_colors, max_colors).
     38
     39        colors_blocks (list[str]): List of available colors for blocks.
     40
     41        nb_moves (int): Maximum number of moves allowed for the optimal solution.
     42
     43        batch_grid_size (tuple[int, int]): Grid size for each puzzle as (rows, columns).
     44
     45        batch_stack_probability (float): Probability to stack blocks vertically during puzzle generation.
     46
     47        blocks_gap (int, optional): Maximum allowed gap between blocks during generation (e.g., 1 means at most one empty cell). Default is 1.
     48
     49
     50    Attributes:
     51
     52        puzzle_batch (dict[int, list]): Dictionary mapping move counts to lists of generated puzzles.
     53
     54        nb_solvables (int): Number of puzzles solvable within the move limit.
     55
     56        nb_unsolvables (int): Number of puzzles not solvable within the move limit.
     57
     58        stats (dict[str, int]):
     59            Counts of puzzles solvable in 1 up to `nb_moves` moves.
     60            Keys are of the form 'solvable_in_X_moves' mapping to counts.
     61            Example: {'solvable_in_1_moves': 3, 'solvable_in_2_moves': 4, ...}
     62
     63        csv_data (dict): Data formatted for CSV export.
     64    """
     65
     66    def __init__(self, blocks_range, colors_range, colors_blocks, nb_moves, batch_grid_size, nb_attempts=5, batch_stack_probability=0.75, blocks_gap=1):
     67        self.blocks_range = blocks_range
     68        self.colors_range = colors_range
     69        self.colors = colors_blocks
     70        self.nb_moves = nb_moves
     71        self.puzzle_batch = {}
     72        self.nb_solvables = 0
     73        self.nb_unsolvables = 0
     74        self.stats = {}
     75        self.csv_data = {}
     76        self.batch_grid_size = batch_grid_size
     77        self.nb_attempts = nb_attempts
     78        self.batch_stack_probability = batch_stack_probability
     79        self.blocks_gap = blocks_gap
     80
     81    def generate_puzzles(self):
     82        """
     83        Generate puzzles for all combinations of block and color counts within the specified ranges.
     84
     85        For each combination:
     86        - Attempts up to `nb_attempts` times to create a puzzle solvable within `nb_moves`.
     87        - Stores solvable puzzles grouped by the number of moves required to solve.
     88        - Updates statistics and prepares data for CSV export.
     89
     90        Returns:
     91            dict: Dictionary of generated puzzles grouped by move counts,
     92                keys formatted as 'solvable_in_X_moves'.
     93        """
     94
     95        print_framed([
     96            "Generating a batch of puzzles with the following parameters:",
     97            f"- Block count range: {self.blocks_range}",
     98            f"- Color count range: {self.colors_range}",
     99            f"- Color palette: {self.colors}",
    100            f"- Max number of moves: {self.nb_moves}",
    101            f"- Grid size: {self.batch_grid_size}",
    102            f"- Stack probability: {self.batch_stack_probability}"
    103        ])
    104
    105        iterated_colors = []
    106        puzzle_batch = {}
    107        csv_data = {
    108            "cubes": [],
    109            "positions": [],
    110            "colors": [],
    111            "moves": []
    112        }
    113        stack_probability = self.batch_stack_probability
    114        blocks_gap = self.blocks_gap
    115
    116        total_combinations = 0
    117        for nb_colors in range(self.colors_range[0], self.colors_range[1] + 1):
    118            iterated_colors = self.colors[:nb_colors]
    119            for nb_blocks in range(self.blocks_range[0], self.blocks_range[1] + 1):
    120                if len(iterated_colors) * 3 > nb_blocks:
    121                    continue
    122                total_combinations += 1
    123
    124        for i in range(1, self.nb_moves+1):
    125            key = 'solvable_in_' + str(i) + '_moves'
    126            puzzle_batch[key] = []
    127
    128        with tqdm(total=total_combinations, desc="🧩 Puzzle Batch Generation – Overall Progress") as pbar:
    129            for nb_colors in range(self.colors_range[0], self.colors_range[1] + 1):
    130                iterated_colors = self.colors[:nb_colors]
    131                for nb_blocks in range(self.blocks_range[0], self.blocks_range[1]+1):
    132                    if len(iterated_colors)*3 > nb_blocks:
    133                        continue
    134                    else:
    135                        if nb_colors == 1:
    136                            grid_size = (nb_blocks+1, nb_blocks+1)
    137                        else:
    138                            grid_size = self.batch_grid_size
    139                        is_solvable = False
    140                        logger.info(f"Generating puzzle for {nb_blocks} blocks, colors: {iterated_colors}, grid size: {grid_size}")
    141                        nb_attempts_ = 0
    142                        while not(is_solvable) and nb_attempts_ < self.nb_attempts:
    143                            grid = GridInitializer(grid_size, nb_blocks, iterated_colors, self.nb_moves, stack_probability, blocks_gap)
    144                            grid.initialize_grid()
    145                            solver = BFSSolver(grid, PuzzleLogic())
    146                            is_solvable, solution = solver.perform_all_blocks_moves(display_progress=False)
    147                            nb_attempts_ = nb_attempts_ + 1
    148                        if is_solvable:
    149                            round = solution["rounds_number_history"][-2]
    150                            key = 'solvable_in_' + str(round) + '_moves'
    151                            puzzle_batch[key] = puzzle_batch[key] + [(solution, grid_size)]
    152
    153                            positions_list = []
    154                            colors_list = []
    155
    156                            init_pos = solution['set_blocks_history'][0]
    157                            for position, block in init_pos.items():
    158                                positions_list.append(position)
    159                                color = block.get_color()
    160                                colors_list.append(color)
    161
    162                            csv_data["cubes"] = csv_data["cubes"] + [nb_blocks]
    163                            csv_data["colors"] = csv_data["colors"] + [colors_list]
    164                            csv_data["positions"] = csv_data["positions"] + [positions_list]
    165                            csv_data["moves"] = csv_data["moves"] + [round]
    166                            self.nb_solvables = self.nb_solvables + 1
    167                        else:
    168                            self.nb_unsolvables = self.nb_unsolvables + 1
    169                    pbar.update(1)
    170
    171        self.puzzle_batch = puzzle_batch
    172        self.csv_data = csv_data
    173        print("Batch generation completed.")
    174        return self.puzzle_batch
    175
    176
    177
    178    def print_and_save_batch(self, filename=None):
    179        """
    180        Print and save all generated puzzles as a PDF file.
    181
    182        - Visualizes each puzzle's solution history.
    183        - Saves all figures to a single PDF.
    184        - Also displays summary charts (bar and pie).
    185        """
    186        for move in sorted(self.puzzle_batch.keys()):
    187          list_solved_puzzles = self.puzzle_batch[move]
    188          for i in range(len(list_solved_puzzles)):
    189            solved_puzzle = list_solved_puzzles[i][0]
    190            grid_size = list_solved_puzzles[i][1]
    191            BFSSolver.print_history(solved_puzzle, grid_size, False)
    192        self.print_charts(False)
    193        if filename is None:
    194            filename = datetime.datetime.now().strftime("%d_%m_%Y_%H_%M_%S")+'_puzzle_generation.pdf'
    195        self.save_multi_image(filename)
    196
    197    def save_multi_image(self, filename):
    198        """
    199        Save all open matplotlib figures to a single PDF file.
    200
    201        Args:
    202            filename (str): Name of the PDF file to save.
    203        """
    204        pp = PdfPages(filename)
    205        fig_nums = plt.get_fignums()
    206        figs = [plt.figure(n) for n in fig_nums]
    207        for fig in figs:
    208          fig.savefig(pp, format='pdf')
    209        pp.close()
    210        self.save_file(filename)
    211        print("🖼️  Close the figure window to continue...")
    212        plt.show()
    213
    214    def compute_stats(self):
    215        """
    216        Compute and update statistics for the generated puzzle batch.
    217
    218        Populates the `stats` attribute with counts of puzzles solvable in each number of moves.
    219        """
    220        for move in sorted(self.puzzle_batch.keys()):
    221          self.stats[move] = len(self.puzzle_batch[move])
    222        print("stats: ", self.stats)
    223
    224    def set_batch(self, batch):
    225        """
    226        Set the current batch of puzzles manually.
    227
    228        Args:
    229            batch (dict): Dictionary of puzzles grouped by move count.
    230        """
    231        self.puzzle_batch = batch
    232
    233    def print_charts(self, show):
    234        """
    235        Display bar and pie charts summarizing the batch statistics.
    236
    237        Args:
    238            show (bool): If True, display the charts.
    239        """
    240        labels = list(self.stats.keys()) + ['unsolvable']
    241        sizes = list(self.stats.values()) + [self.nb_unsolvables]
    242
    243        fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
    244
    245        x = range(len(labels))
    246        ax1.bar(x, sizes)
    247        ax1.set_title('Number of Puzzles Generated (Bar Chart)', pad=20)
    248        ax1.set_xticks(x)
    249        ax1.set_xticklabels(labels, rotation=45, ha='right')
    250        ax1.tick_params(axis='x', labelsize=8)
    251
    252        wedges, texts, autotexts = ax2.pie(
    253            sizes,
    254            labels=None,
    255            autopct=lambda p: '{:.2f}%({:.0f})'.format(p, (p/100)*sum(sizes)),
    256            startangle=90,
    257            wedgeprops=dict(edgecolor='w')
    258        )
    259        ax2.set_title('Number of Puzzles Generated (Pie Chart)', pad=20)
    260        ax2.axis('equal')
    261
    262        for i, autotext in enumerate(autotexts):
    263            if autotext.get_text().startswith("0.00%"):
    264                x, y = autotext.get_position()
    265                autotext.set_position((x * 1.3, y * 1.3))
    266
    267        ax2.legend(wedges, labels, title="Legend", loc="center left", bbox_to_anchor=(1, 0.5), fontsize=9)
    268
    269        fig.tight_layout()
    270
    271        if show:
    272            print("🖼️  Close the figure window to continue...")
    273            plt.show()
    274
    275    def save_results_as_csv(self, filename=None):
    276        """
    277        Save the batch data as a CSV file.
    278
    279        The CSV contains, for each puzzle:
    280          - Number of blocks
    281          - Colors used
    282          - Initial block positions
    283          - Number of moves to solve
    284
    285        Args:
    286            filename (str, optional): CSV filename. Defaults to a timestamped filename.
    287        """
    288        if filename is None:
    289            filename = str(datetime.datetime.now()) + '_puzzle_generation.csv'
    290        fieldnames = list(self.csv_data.keys())
    291        with open(filename, "w", newline="") as csvfile:
    292            writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
    293            writer.writeheader()
    294            for i in range(len(self.csv_data["cubes"])):
    295                writer.writerow({fieldname: self.csv_data[fieldname][i] for fieldname in fieldnames})
    296        self.save_file(filename)
    297
    298
    299    def save_file(self, filename):
    300        """
    301        Save or provide a download link for the file depending on the execution environment.
    302
    303        Supports:
    304        - Google Colab: triggers browser download.
    305        - Jupyter Notebook/Lab: shows a clickable download link.
    306        - Script or other environments: saves file in 'outputs' directory.
    307
    308        Args:
    309            filename (str): Name of the file to save or offer for download.
    310        """
    311        try:
    312            ipython_shell = get_ipython().__class__.__name__
    313        except NameError:
    314            ipython_shell = None
    315        if 'google.colab' in sys.modules:
    316            from google.colab import files
    317            files.download(filename)
    318            print(f"File downloaded in browser (Colab): {filename}")
    319        elif ipython_shell in ['ZMQInteractiveShell']:
    320            from IPython.display import display, HTML
    321            with open(filename, "rb") as f:
    322                data = f.read()
    323            b64 = base64.b64encode(data).decode()
    324            download_link = f'<a download="{os.path.basename(filename)}" href="data:application/octet-stream;base64,{b64}" target="_blank">Click here to download {filename}</a>'
    325            display(HTML(download_link))
    326            print("File ready for browser download (Jupyter).")
    327        else:
    328            outputs_dir = Path(__file__).parent.parent.parent.parent / "outputs"
    329            outputs_dir.mkdir(parents=True, exist_ok=True)
    330            destination = outputs_dir / Path(filename).name
    331            shutil.move(filename, destination)
    332            print(f"File saved locally at: {destination}")

logger = <Logger puzzlegen.core.batch_generator (INFO)>
class PuzzleBatchGenerator: View Source

     20class PuzzleBatchGenerator:
     21    """
     22    Generate and manage batches of puzzles by iterating over ranges of parameters.
     23
     24    This class systematically generates puzzles for each integer value within the given
     25    ranges of block counts and color counts. For each combination, it attempts to generate
     26    puzzles solvable within the specified move limit.
     27
     28    It provides functionality to:
     29      - Filter out puzzles unsolvable within the move constraints.
     30      - Collect statistics about solvable and unsolvable puzzles across the batch.
     31      - Export puzzles visually as PDFs and metadata as CSV files.
     32      - Display summary charts (bar and pie) to analyze batch characteristics.
     33
     34    Args:
     35
     36        blocks_range (tuple[int, int]): Inclusive range of block counts to iterate through (min_blocks, max_blocks).
     37
     38        colors_range (tuple[int, int]): Inclusive range of color counts to iterate through (min_colors, max_colors).
     39
     40        colors_blocks (list[str]): List of available colors for blocks.
     41
     42        nb_moves (int): Maximum number of moves allowed for the optimal solution.
     43
     44        batch_grid_size (tuple[int, int]): Grid size for each puzzle as (rows, columns).
     45
     46        batch_stack_probability (float): Probability to stack blocks vertically during puzzle generation.
     47
     48        blocks_gap (int, optional): Maximum allowed gap between blocks during generation (e.g., 1 means at most one empty cell). Default is 1.
     49
     50
     51    Attributes:
     52
     53        puzzle_batch (dict[int, list]): Dictionary mapping move counts to lists of generated puzzles.
     54
     55        nb_solvables (int): Number of puzzles solvable within the move limit.
     56
     57        nb_unsolvables (int): Number of puzzles not solvable within the move limit.
     58
     59        stats (dict[str, int]):
     60            Counts of puzzles solvable in 1 up to `nb_moves` moves.
     61            Keys are of the form 'solvable_in_X_moves' mapping to counts.
     62            Example: {'solvable_in_1_moves': 3, 'solvable_in_2_moves': 4, ...}
     63
     64        csv_data (dict): Data formatted for CSV export.
     65    """
     66
     67    def __init__(self, blocks_range, colors_range, colors_blocks, nb_moves, batch_grid_size, nb_attempts=5, batch_stack_probability=0.75, blocks_gap=1):
     68        self.blocks_range = blocks_range
     69        self.colors_range = colors_range
     70        self.colors = colors_blocks
     71        self.nb_moves = nb_moves
     72        self.puzzle_batch = {}
     73        self.nb_solvables = 0
     74        self.nb_unsolvables = 0
     75        self.stats = {}
     76        self.csv_data = {}
     77        self.batch_grid_size = batch_grid_size
     78        self.nb_attempts = nb_attempts
     79        self.batch_stack_probability = batch_stack_probability
     80        self.blocks_gap = blocks_gap
     81
     82    def generate_puzzles(self):
     83        """
     84        Generate puzzles for all combinations of block and color counts within the specified ranges.
     85
     86        For each combination:
     87        - Attempts up to `nb_attempts` times to create a puzzle solvable within `nb_moves`.
     88        - Stores solvable puzzles grouped by the number of moves required to solve.
     89        - Updates statistics and prepares data for CSV export.
     90
     91        Returns:
     92            dict: Dictionary of generated puzzles grouped by move counts,
     93                keys formatted as 'solvable_in_X_moves'.
     94        """
     95
     96        print_framed([
     97            "Generating a batch of puzzles with the following parameters:",
     98            f"- Block count range: {self.blocks_range}",
     99            f"- Color count range: {self.colors_range}",
    100            f"- Color palette: {self.colors}",
    101            f"- Max number of moves: {self.nb_moves}",
    102            f"- Grid size: {self.batch_grid_size}",
    103            f"- Stack probability: {self.batch_stack_probability}"
    104        ])
    105
    106        iterated_colors = []
    107        puzzle_batch = {}
    108        csv_data = {
    109            "cubes": [],
    110            "positions": [],
    111            "colors": [],
    112            "moves": []
    113        }
    114        stack_probability = self.batch_stack_probability
    115        blocks_gap = self.blocks_gap
    116
    117        total_combinations = 0
    118        for nb_colors in range(self.colors_range[0], self.colors_range[1] + 1):
    119            iterated_colors = self.colors[:nb_colors]
    120            for nb_blocks in range(self.blocks_range[0], self.blocks_range[1] + 1):
    121                if len(iterated_colors) * 3 > nb_blocks:
    122                    continue
    123                total_combinations += 1
    124
    125        for i in range(1, self.nb_moves+1):
    126            key = 'solvable_in_' + str(i) + '_moves'
    127            puzzle_batch[key] = []
    128
    129        with tqdm(total=total_combinations, desc="🧩 Puzzle Batch Generation – Overall Progress") as pbar:
    130            for nb_colors in range(self.colors_range[0], self.colors_range[1] + 1):
    131                iterated_colors = self.colors[:nb_colors]
    132                for nb_blocks in range(self.blocks_range[0], self.blocks_range[1]+1):
    133                    if len(iterated_colors)*3 > nb_blocks:
    134                        continue
    135                    else:
    136                        if nb_colors == 1:
    137                            grid_size = (nb_blocks+1, nb_blocks+1)
    138                        else:
    139                            grid_size = self.batch_grid_size
    140                        is_solvable = False
    141                        logger.info(f"Generating puzzle for {nb_blocks} blocks, colors: {iterated_colors}, grid size: {grid_size}")
    142                        nb_attempts_ = 0
    143                        while not(is_solvable) and nb_attempts_ < self.nb_attempts:
    144                            grid = GridInitializer(grid_size, nb_blocks, iterated_colors, self.nb_moves, stack_probability, blocks_gap)
    145                            grid.initialize_grid()
    146                            solver = BFSSolver(grid, PuzzleLogic())
    147                            is_solvable, solution = solver.perform_all_blocks_moves(display_progress=False)
    148                            nb_attempts_ = nb_attempts_ + 1
    149                        if is_solvable:
    150                            round = solution["rounds_number_history"][-2]
    151                            key = 'solvable_in_' + str(round) + '_moves'
    152                            puzzle_batch[key] = puzzle_batch[key] + [(solution, grid_size)]
    153
    154                            positions_list = []
    155                            colors_list = []
    156
    157                            init_pos = solution['set_blocks_history'][0]
    158                            for position, block in init_pos.items():
    159                                positions_list.append(position)
    160                                color = block.get_color()
    161                                colors_list.append(color)
    162
    163                            csv_data["cubes"] = csv_data["cubes"] + [nb_blocks]
    164                            csv_data["colors"] = csv_data["colors"] + [colors_list]
    165                            csv_data["positions"] = csv_data["positions"] + [positions_list]
    166                            csv_data["moves"] = csv_data["moves"] + [round]
    167                            self.nb_solvables = self.nb_solvables + 1
    168                        else:
    169                            self.nb_unsolvables = self.nb_unsolvables + 1
    170                    pbar.update(1)
    171
    172        self.puzzle_batch = puzzle_batch
    173        self.csv_data = csv_data
    174        print("Batch generation completed.")
    175        return self.puzzle_batch
    176
    177
    178
    179    def print_and_save_batch(self, filename=None):
    180        """
    181        Print and save all generated puzzles as a PDF file.
    182
    183        - Visualizes each puzzle's solution history.
    184        - Saves all figures to a single PDF.
    185        - Also displays summary charts (bar and pie).
    186        """
    187        for move in sorted(self.puzzle_batch.keys()):
    188          list_solved_puzzles = self.puzzle_batch[move]
    189          for i in range(len(list_solved_puzzles)):
    190            solved_puzzle = list_solved_puzzles[i][0]
    191            grid_size = list_solved_puzzles[i][1]
    192            BFSSolver.print_history(solved_puzzle, grid_size, False)
    193        self.print_charts(False)
    194        if filename is None:
    195            filename = datetime.datetime.now().strftime("%d_%m_%Y_%H_%M_%S")+'_puzzle_generation.pdf'
    196        self.save_multi_image(filename)
    197
    198    def save_multi_image(self, filename):
    199        """
    200        Save all open matplotlib figures to a single PDF file.
    201
    202        Args:
    203            filename (str): Name of the PDF file to save.
    204        """
    205        pp = PdfPages(filename)
    206        fig_nums = plt.get_fignums()
    207        figs = [plt.figure(n) for n in fig_nums]
    208        for fig in figs:
    209          fig.savefig(pp, format='pdf')
    210        pp.close()
    211        self.save_file(filename)
    212        print("🖼️  Close the figure window to continue...")
    213        plt.show()
    214
    215    def compute_stats(self):
    216        """
    217        Compute and update statistics for the generated puzzle batch.
    218
    219        Populates the `stats` attribute with counts of puzzles solvable in each number of moves.
    220        """
    221        for move in sorted(self.puzzle_batch.keys()):
    222          self.stats[move] = len(self.puzzle_batch[move])
    223        print("stats: ", self.stats)
    224
    225    def set_batch(self, batch):
    226        """
    227        Set the current batch of puzzles manually.
    228
    229        Args:
    230            batch (dict): Dictionary of puzzles grouped by move count.
    231        """
    232        self.puzzle_batch = batch
    233
    234    def print_charts(self, show):
    235        """
    236        Display bar and pie charts summarizing the batch statistics.
    237
    238        Args:
    239            show (bool): If True, display the charts.
    240        """
    241        labels = list(self.stats.keys()) + ['unsolvable']
    242        sizes = list(self.stats.values()) + [self.nb_unsolvables]
    243
    244        fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
    245
    246        x = range(len(labels))
    247        ax1.bar(x, sizes)
    248        ax1.set_title('Number of Puzzles Generated (Bar Chart)', pad=20)
    249        ax1.set_xticks(x)
    250        ax1.set_xticklabels(labels, rotation=45, ha='right')
    251        ax1.tick_params(axis='x', labelsize=8)
    252
    253        wedges, texts, autotexts = ax2.pie(
    254            sizes,
    255            labels=None,
    256            autopct=lambda p: '{:.2f}%({:.0f})'.format(p, (p/100)*sum(sizes)),
    257            startangle=90,
    258            wedgeprops=dict(edgecolor='w')
    259        )
    260        ax2.set_title('Number of Puzzles Generated (Pie Chart)', pad=20)
    261        ax2.axis('equal')
    262
    263        for i, autotext in enumerate(autotexts):
    264            if autotext.get_text().startswith("0.00%"):
    265                x, y = autotext.get_position()
    266                autotext.set_position((x * 1.3, y * 1.3))
    267
    268        ax2.legend(wedges, labels, title="Legend", loc="center left", bbox_to_anchor=(1, 0.5), fontsize=9)
    269
    270        fig.tight_layout()
    271
    272        if show:
    273            print("🖼️  Close the figure window to continue...")
    274            plt.show()
    275
    276    def save_results_as_csv(self, filename=None):
    277        """
    278        Save the batch data as a CSV file.
    279
    280        The CSV contains, for each puzzle:
    281          - Number of blocks
    282          - Colors used
    283          - Initial block positions
    284          - Number of moves to solve
    285
    286        Args:
    287            filename (str, optional): CSV filename. Defaults to a timestamped filename.
    288        """
    289        if filename is None:
    290            filename = str(datetime.datetime.now()) + '_puzzle_generation.csv'
    291        fieldnames = list(self.csv_data.keys())
    292        with open(filename, "w", newline="") as csvfile:
    293            writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
    294            writer.writeheader()
    295            for i in range(len(self.csv_data["cubes"])):
    296                writer.writerow({fieldname: self.csv_data[fieldname][i] for fieldname in fieldnames})
    297        self.save_file(filename)
    298
    299
    300    def save_file(self, filename):
    301        """
    302        Save or provide a download link for the file depending on the execution environment.
    303
    304        Supports:
    305        - Google Colab: triggers browser download.
    306        - Jupyter Notebook/Lab: shows a clickable download link.
    307        - Script or other environments: saves file in 'outputs' directory.
    308
    309        Args:
    310            filename (str): Name of the file to save or offer for download.
    311        """
    312        try:
    313            ipython_shell = get_ipython().__class__.__name__
    314        except NameError:
    315            ipython_shell = None
    316        if 'google.colab' in sys.modules:
    317            from google.colab import files
    318            files.download(filename)
    319            print(f"File downloaded in browser (Colab): {filename}")
    320        elif ipython_shell in ['ZMQInteractiveShell']:
    321            from IPython.display import display, HTML
    322            with open(filename, "rb") as f:
    323                data = f.read()
    324            b64 = base64.b64encode(data).decode()
    325            download_link = f'<a download="{os.path.basename(filename)}" href="data:application/octet-stream;base64,{b64}" target="_blank">Click here to download {filename}</a>'
    326            display(HTML(download_link))
    327            print("File ready for browser download (Jupyter).")
    328        else:
    329            outputs_dir = Path(__file__).parent.parent.parent.parent / "outputs"
    330            outputs_dir.mkdir(parents=True, exist_ok=True)
    331            destination = outputs_dir / Path(filename).name
    332            shutil.move(filename, destination)
    333            print(f"File saved locally at: {destination}")

Generate and manage batches of puzzles by iterating over ranges of parameters.

This class systematically generates puzzles for each integer value within the given ranges of block counts and color counts. For each combination, it attempts to generate puzzles solvable within the specified move limit.

It provides functionality to:

- Filter out puzzles unsolvable within the move constraints.
- Collect statistics about solvable and unsolvable puzzles across the batch.
- Export puzzles visually as PDFs and metadata as CSV files.
- Display summary charts (bar and pie) to analyze batch characteristics.

Args:

    blocks_range (tuple[int, int]): Inclusive range of block counts to iterate through (min_blocks, max_blocks).

    colors_range (tuple[int, int]): Inclusive range of color counts to iterate through (min_colors, max_colors).

    colors_blocks (list[str]): List of available colors for blocks.

    nb_moves (int): Maximum number of moves allowed for the optimal solution.

    batch_grid_size (tuple[int, int]): Grid size for each puzzle as (rows, columns).

    batch_stack_probability (float): Probability to stack blocks vertically during puzzle generation.

    blocks_gap (int, optional): Maximum allowed gap between blocks during generation (e.g., 1 means at most one empty cell). Default is 1.

Attributes:

    puzzle_batch (dict[int, list]): Dictionary mapping move counts to lists of generated puzzles.

    nb_solvables (int): Number of puzzles solvable within the move limit.

    nb_unsolvables (int): Number of puzzles not solvable within the move limit.

    stats (dict[str, int]):
        Counts of puzzles solvable in 1 up to `nb_moves` moves.
        Keys are of the form 'solvable_in_X_moves' mapping to counts.
        Example: {'solvable_in_1_moves': 3, 'solvable_in_2_moves': 4, ...}

    csv_data (dict): Data formatted for CSV export.

PuzzleBatchGenerator( blocks_range, colors_range, colors_blocks, nb_moves, batch_grid_size, nb_attempts=5, batch_stack_probability=0.75, blocks_gap=1) View Source

    67    def __init__(self, blocks_range, colors_range, colors_blocks, nb_moves, batch_grid_size, nb_attempts=5, batch_stack_probability=0.75, blocks_gap=1):
    68        self.blocks_range = blocks_range
    69        self.colors_range = colors_range
    70        self.colors = colors_blocks
    71        self.nb_moves = nb_moves
    72        self.puzzle_batch = {}
    73        self.nb_solvables = 0
    74        self.nb_unsolvables = 0
    75        self.stats = {}
    76        self.csv_data = {}
    77        self.batch_grid_size = batch_grid_size
    78        self.nb_attempts = nb_attempts
    79        self.batch_stack_probability = batch_stack_probability
    80        self.blocks_gap = blocks_gap

blocks_range
colors_range
colors
nb_moves
puzzle_batch
nb_solvables
nb_unsolvables
stats
csv_data
batch_grid_size
nb_attempts
batch_stack_probability
blocks_gap
def generate_puzzles(self): View Source

     82    def generate_puzzles(self):
     83        """
     84        Generate puzzles for all combinations of block and color counts within the specified ranges.
     85
     86        For each combination:
     87        - Attempts up to `nb_attempts` times to create a puzzle solvable within `nb_moves`.
     88        - Stores solvable puzzles grouped by the number of moves required to solve.
     89        - Updates statistics and prepares data for CSV export.
     90
     91        Returns:
     92            dict: Dictionary of generated puzzles grouped by move counts,
     93                keys formatted as 'solvable_in_X_moves'.
     94        """
     95
     96        print_framed([
     97            "Generating a batch of puzzles with the following parameters:",
     98            f"- Block count range: {self.blocks_range}",
     99            f"- Color count range: {self.colors_range}",
    100            f"- Color palette: {self.colors}",
    101            f"- Max number of moves: {self.nb_moves}",
    102            f"- Grid size: {self.batch_grid_size}",
    103            f"- Stack probability: {self.batch_stack_probability}"
    104        ])
    105
    106        iterated_colors = []
    107        puzzle_batch = {}
    108        csv_data = {
    109            "cubes": [],
    110            "positions": [],
    111            "colors": [],
    112            "moves": []
    113        }
    114        stack_probability = self.batch_stack_probability
    115        blocks_gap = self.blocks_gap
    116
    117        total_combinations = 0
    118        for nb_colors in range(self.colors_range[0], self.colors_range[1] + 1):
    119            iterated_colors = self.colors[:nb_colors]
    120            for nb_blocks in range(self.blocks_range[0], self.blocks_range[1] + 1):
    121                if len(iterated_colors) * 3 > nb_blocks:
    122                    continue
    123                total_combinations += 1
    124
    125        for i in range(1, self.nb_moves+1):
    126            key = 'solvable_in_' + str(i) + '_moves'
    127            puzzle_batch[key] = []
    128
    129        with tqdm(total=total_combinations, desc="🧩 Puzzle Batch Generation – Overall Progress") as pbar:
    130            for nb_colors in range(self.colors_range[0], self.colors_range[1] + 1):
    131                iterated_colors = self.colors[:nb_colors]
    132                for nb_blocks in range(self.blocks_range[0], self.blocks_range[1]+1):
    133                    if len(iterated_colors)*3 > nb_blocks:
    134                        continue
    135                    else:
    136                        if nb_colors == 1:
    137                            grid_size = (nb_blocks+1, nb_blocks+1)
    138                        else:
    139                            grid_size = self.batch_grid_size
    140                        is_solvable = False
    141                        logger.info(f"Generating puzzle for {nb_blocks} blocks, colors: {iterated_colors}, grid size: {grid_size}")
    142                        nb_attempts_ = 0
    143                        while not(is_solvable) and nb_attempts_ < self.nb_attempts:
    144                            grid = GridInitializer(grid_size, nb_blocks, iterated_colors, self.nb_moves, stack_probability, blocks_gap)
    145                            grid.initialize_grid()
    146                            solver = BFSSolver(grid, PuzzleLogic())
    147                            is_solvable, solution = solver.perform_all_blocks_moves(display_progress=False)
    148                            nb_attempts_ = nb_attempts_ + 1
    149                        if is_solvable:
    150                            round = solution["rounds_number_history"][-2]
    151                            key = 'solvable_in_' + str(round) + '_moves'
    152                            puzzle_batch[key] = puzzle_batch[key] + [(solution, grid_size)]
    153
    154                            positions_list = []
    155                            colors_list = []
    156
    157                            init_pos = solution['set_blocks_history'][0]
    158                            for position, block in init_pos.items():
    159                                positions_list.append(position)
    160                                color = block.get_color()
    161                                colors_list.append(color)
    162
    163                            csv_data["cubes"] = csv_data["cubes"] + [nb_blocks]
    164                            csv_data["colors"] = csv_data["colors"] + [colors_list]
    165                            csv_data["positions"] = csv_data["positions"] + [positions_list]
    166                            csv_data["moves"] = csv_data["moves"] + [round]
    167                            self.nb_solvables = self.nb_solvables + 1
    168                        else:
    169                            self.nb_unsolvables = self.nb_unsolvables + 1
    170                    pbar.update(1)
    171
    172        self.puzzle_batch = puzzle_batch
    173        self.csv_data = csv_data
    174        print("Batch generation completed.")
    175        return self.puzzle_batch

Generate puzzles for all combinations of block and color counts within the specified ranges.

For each combination:

- Attempts up to `nb_attempts` times to create a puzzle solvable within `nb_moves`.
- Stores solvable puzzles grouped by the number of moves required to solve.
- Updates statistics and prepares data for CSV export.

Returns: dict: Dictionary of generated puzzles grouped by move counts, keys formatted as 'solvable_in_X_moves'.

def print_and_save_batch(self, filename=None): View Source

    179    def print_and_save_batch(self, filename=None):
    180        """
    181        Print and save all generated puzzles as a PDF file.
    182
    183        - Visualizes each puzzle's solution history.
    184        - Saves all figures to a single PDF.
    185        - Also displays summary charts (bar and pie).
    186        """
    187        for move in sorted(self.puzzle_batch.keys()):
    188          list_solved_puzzles = self.puzzle_batch[move]
    189          for i in range(len(list_solved_puzzles)):
    190            solved_puzzle = list_solved_puzzles[i][0]
    191            grid_size = list_solved_puzzles[i][1]
    192            BFSSolver.print_history(solved_puzzle, grid_size, False)
    193        self.print_charts(False)
    194        if filename is None:
    195            filename = datetime.datetime.now().strftime("%d_%m_%Y_%H_%M_%S")+'_puzzle_generation.pdf'
    196        self.save_multi_image(filename)

Print and save all generated puzzles as a PDF file.

- Visualizes each puzzle's solution history.
- Saves all figures to a single PDF.
- Also displays summary charts (bar and pie).

def save_multi_image(self, filename): View Source

    198    def save_multi_image(self, filename):
    199        """
    200        Save all open matplotlib figures to a single PDF file.
    201
    202        Args:
    203            filename (str): Name of the PDF file to save.
    204        """
    205        pp = PdfPages(filename)
    206        fig_nums = plt.get_fignums()
    207        figs = [plt.figure(n) for n in fig_nums]
    208        for fig in figs:
    209          fig.savefig(pp, format='pdf')
    210        pp.close()
    211        self.save_file(filename)
    212        print("🖼️  Close the figure window to continue...")
    213        plt.show()

Save all open matplotlib figures to a single PDF file.

Args: filename (str): Name of the PDF file to save.

def compute_stats(self): View Source

    215    def compute_stats(self):
    216        """
    217        Compute and update statistics for the generated puzzle batch.
    218
    219        Populates the `stats` attribute with counts of puzzles solvable in each number of moves.
    220        """
    221        for move in sorted(self.puzzle_batch.keys()):
    222          self.stats[move] = len(self.puzzle_batch[move])
    223        print("stats: ", self.stats)

Compute and update statistics for the generated puzzle batch.

Populates the `stats` attribute with counts of puzzles solvable in each number of moves.

def set_batch(self, batch): View Source

    225    def set_batch(self, batch):
    226        """
    227        Set the current batch of puzzles manually.
    228
    229        Args:
    230            batch (dict): Dictionary of puzzles grouped by move count.
    231        """
    232        self.puzzle_batch = batch

Set the current batch of puzzles manually.

Args: batch (dict): Dictionary of puzzles grouped by move count.

def print_charts(self, show): View Source

    234    def print_charts(self, show):
    235        """
    236        Display bar and pie charts summarizing the batch statistics.
    237
    238        Args:
    239            show (bool): If True, display the charts.
    240        """
    241        labels = list(self.stats.keys()) + ['unsolvable']
    242        sizes = list(self.stats.values()) + [self.nb_unsolvables]
    243
    244        fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
    245
    246        x = range(len(labels))
    247        ax1.bar(x, sizes)
    248        ax1.set_title('Number of Puzzles Generated (Bar Chart)', pad=20)
    249        ax1.set_xticks(x)
    250        ax1.set_xticklabels(labels, rotation=45, ha='right')
    251        ax1.tick_params(axis='x', labelsize=8)
    252
    253        wedges, texts, autotexts = ax2.pie(
    254            sizes,
    255            labels=None,
    256            autopct=lambda p: '{:.2f}%({:.0f})'.format(p, (p/100)*sum(sizes)),
    257            startangle=90,
    258            wedgeprops=dict(edgecolor='w')
    259        )
    260        ax2.set_title('Number of Puzzles Generated (Pie Chart)', pad=20)
    261        ax2.axis('equal')
    262
    263        for i, autotext in enumerate(autotexts):
    264            if autotext.get_text().startswith("0.00%"):
    265                x, y = autotext.get_position()
    266                autotext.set_position((x * 1.3, y * 1.3))
    267
    268        ax2.legend(wedges, labels, title="Legend", loc="center left", bbox_to_anchor=(1, 0.5), fontsize=9)
    269
    270        fig.tight_layout()
    271
    272        if show:
    273            print("🖼️  Close the figure window to continue...")
    274            plt.show()

Display bar and pie charts summarizing the batch statistics.

Args: show (bool): If True, display the charts.

def save_results_as_csv(self, filename=None): View Source

    276    def save_results_as_csv(self, filename=None):
    277        """
    278        Save the batch data as a CSV file.
    279
    280        The CSV contains, for each puzzle:
    281          - Number of blocks
    282          - Colors used
    283          - Initial block positions
    284          - Number of moves to solve
    285
    286        Args:
    287            filename (str, optional): CSV filename. Defaults to a timestamped filename.
    288        """
    289        if filename is None:
    290            filename = str(datetime.datetime.now()) + '_puzzle_generation.csv'
    291        fieldnames = list(self.csv_data.keys())
    292        with open(filename, "w", newline="") as csvfile:
    293            writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
    294            writer.writeheader()
    295            for i in range(len(self.csv_data["cubes"])):
    296                writer.writerow({fieldname: self.csv_data[fieldname][i] for fieldname in fieldnames})
    297        self.save_file(filename)

Save the batch data as a CSV file.

The CSV contains, for each puzzle:

- Number of blocks
- Colors used
- Initial block positions
- Number of moves to solve

Args: filename (str, optional): CSV filename. Defaults to a timestamped filename.

def save_file(self, filename): View Source

    300    def save_file(self, filename):
    301        """
    302        Save or provide a download link for the file depending on the execution environment.
    303
    304        Supports:
    305        - Google Colab: triggers browser download.
    306        - Jupyter Notebook/Lab: shows a clickable download link.
    307        - Script or other environments: saves file in 'outputs' directory.
    308
    309        Args:
    310            filename (str): Name of the file to save or offer for download.
    311        """
    312        try:
    313            ipython_shell = get_ipython().__class__.__name__
    314        except NameError:
    315            ipython_shell = None
    316        if 'google.colab' in sys.modules:
    317            from google.colab import files
    318            files.download(filename)
    319            print(f"File downloaded in browser (Colab): {filename}")
    320        elif ipython_shell in ['ZMQInteractiveShell']:
    321            from IPython.display import display, HTML
    322            with open(filename, "rb") as f:
    323                data = f.read()
    324            b64 = base64.b64encode(data).decode()
    325            download_link = f'<a download="{os.path.basename(filename)}" href="data:application/octet-stream;base64,{b64}" target="_blank">Click here to download {filename}</a>'
    326            display(HTML(download_link))
    327            print("File ready for browser download (Jupyter).")
    328        else:
    329            outputs_dir = Path(__file__).parent.parent.parent.parent / "outputs"
    330            outputs_dir.mkdir(parents=True, exist_ok=True)
    331            destination = outputs_dir / Path(filename).name
    332            shutil.move(filename, destination)
    333            print(f"File saved locally at: {destination}")

Save or provide a download link for the file depending on the execution environment.

Supports:

- Google Colab: triggers browser download.
- Jupyter Notebook/Lab: shows a clickable download link.
- Script or other environments: saves file in 'outputs' directory.

Args: filename (str): Name of the file to save or offer for download.