Source code for bsym.configuration_space

from bsym.permutations import flatten_list, unique_permutations, number_of_unique_permutations
from bsym.partitions import compute_mapping_vector
from bsym import Configuration, SymmetryGroup, SymmetryOperation
from bsym.partitions import generate_partitions, satisfies_bounds
import numpy as np
from itertools import combinations_with_replacement
from collections import Counter
from tqdm import tqdm
from tqdm.auto import tqdm as tqdm_auto
from typing import Iterator


[docs] class ConfigurationSpace: def __init__(self, objects: list, symmetry_group: SymmetryGroup | None = None) -> None: """ Create a :any:`ConfigurationSpace` object. Args: objects (list): The set of objects that define the vector space of this configuration space. symmetry_group (:any:`SymmetryGroup`): The set of symmetry operations describing the symmetries of this configuration space. Returns: None """ # Check that all properties have compatible dimensions self.dim = len(objects) self.objects = objects if symmetry_group: for so in symmetry_group.symmetry_operations: if so.matrix.shape[0] != self.dim: raise ValueError self.symmetry_group = symmetry_group else: self.symmetry_group = SymmetryGroup( symmetry_operations=[ SymmetryOperation(np.identity(self.dim, dtype=int), label='E') ] ) def __repr__(self) -> str: to_return: str to_return = "ConfigurationSpace\n" to_return += self.objects.__repr__() + "\n" to_return += "\n".join(self.symmetry_group.__repr__().split("\n")[1:]) return to_return
[docs] def enumerate_configurations(self, generator, verbose=False): """ Find all symmetry inequivalent configurations within the set produced by `generator`. Args: generator (:obj:`generator`): Generator object, that yields the configurations to search through. verbose (opt:default=False): Print verbose output. Returns: unique_configurations (list): A list of :any:`Configuration` objects, for each symmetry inequivalent configuration. """ seen = set() unique_configurations = [] using_tqdm = hasattr(generator, 'postfix') for new_permutation in generator: perm_as_bytes = Configuration.tuple_to_bytes(new_permutation) if perm_as_bytes not in seen: config = Configuration.from_tuple(new_permutation) byte_equivalents = config.get_byte_equivalents(self.symmetry_group) config.count = len(byte_equivalents) seen.update(byte_equivalents) unique_configurations.append(config) if using_tqdm: generator.set_postfix(found=len(unique_configurations)) if verbose: print('unique configurations: {} / {}'.format(len(unique_configurations), len(seen))) return unique_configurations
[docs] def unique_configurations(self, site_distribution, verbose=False, show_progress=False): """ Find the symmetry inequivalent configurations for a given population of objects. Args: site_distribution (dict): A dictionary that defines the number of each object to be arranged in this system. e.g. for a system with four sites, with two occupied (denoted `1`) and two unoccupied (denoted `0`):: { 1: 2, 0: 2 } verbose (opt:default=False): Print verbose output. show_progress (opt:default=False): Show a progress bar. Setting to `True` gives a simple progress bar. Setting to `"notebook"` gives a Jupyter notebook compatible progress bar. Returns: unique_configurations (list): A list of :any:`Configuration` objects, for each symmetry inequivalent configuration. """ s = flatten_list([[key] * site_distribution[key] for key in site_distribution]) total_permutations = number_of_unique_permutations(s) if verbose: print('total number of sites: ' + str( sum( site_distribution.values()))) print('using {:d} symmetry operations.'.format( len( self.symmetry_group.symmetry_operations))) print('evaluating {:d} unique permutations.'.format( total_permutations)) generator: Iterator[tuple[int, ...]] = unique_permutations(s) if show_progress: TqdmClass = tqdm_auto if show_progress == 'notebook' else tqdm generator = TqdmClass( generator, total=total_permutations, unit=' permutations', mininterval=0.1 ) return self.enumerate_configurations(generator, verbose=verbose)
[docs] def random_unique_configurations( self, site_distribution: dict[int, int], n: int, sampling: str = 'degeneracy_weighted', seed: int | None = None, exclude: list[Configuration] | None = None, max_attempts: int = 1000, ) -> list[Configuration]: """Generate n random symmetry-inequivalent configurations. Args: site_distribution: Dictionary mapping species labels to counts. n: Number of unique configurations to generate. sampling: Sampling method. Either 'degeneracy_weighted' (default) or 'uniform'. 'degeneracy_weighted' samples configurations with probability proportional to their degeneracy. 'uniform' samples uniformly over equivalence classes. seed: Random seed for reproducibility. exclude: List of configurations to exclude. Any configuration equivalent to one in this list will not be returned. max_attempts: Maximum number of consecutive failed attempts before raising RuntimeError. Default is 1000. Returns: List of n unique Configuration objects with count attributes set. Raises: ValueError: If sampling is not 'degeneracy_weighted' or 'uniform'. RuntimeError: If the configuration space appears exhausted. """ if sampling not in ('degeneracy_weighted', 'uniform'): raise ValueError( f"sampling must be 'degeneracy_weighted' or 'uniform', got '{sampling}'" ) rng = np.random.default_rng(seed) seen: set[bytes] = set() unique_configs: list[Configuration] = [] # Pre-populate seen with excluded configurations if exclude is not None: for config in exclude: seen.update(config.get_byte_equivalents(self.symmetry_group)) consecutive_failures = 0 while len(unique_configs) < n: if consecutive_failures >= max_attempts: raise RuntimeError( f"Failed to find a new unique configuration after " f"{max_attempts} consecutive attempts. " f"Found {len(unique_configs)} of {n} requested." ) config = self._generate_random_configuration(site_distribution, rng) config_hash = config.as_bytes() if config_hash in seen: consecutive_failures += 1 continue equivalents = config.get_byte_equivalents(self.symmetry_group) degeneracy = len(equivalents) if sampling == 'uniform': if rng.random() >= 1.0 / degeneracy: # Rejection by acceptance test is not a failure to find # a novel configuration, so don't increment the counter. continue seen.update(equivalents) config.count = degeneracy unique_configs.append(config) consecutive_failures = 0 return unique_configs
[docs] def unique_colourings(self, colours, verbose=False): """ Find the symmetry inequivalent colourings for a given number of 'colours'. Args: colours (list): A list of each object that may be arranged zero or more times in this system. verbose (opt:default=False): Print verbose output. Returns: unique_colours (list): A list of :any:`Configuration` objects, for each symmetry inequivalent colouring. """ generator = colourings_generator( colours, self.dim ) return self.enumerate_configurations( generator, verbose=verbose )
[docs] def unique_configurations_by_composition(self, n_species: int, bounds: dict[int, tuple[int|None, int|None]] | None = None, verbose: bool = False, show_progress: bool | str = False ) -> dict[tuple[int, ...], list[Configuration]]: """Find symmetry-inequivalent configurations for all possible compositions. Enumerates integer partitions of the number of sites into ``n_species`` parts. For each partition, the canonical permutation undergoes full symmetry analysis; non-canonical permutations are obtained by relabelling species. Args: n_species: Number of distinct species. bounds: Optional occupancy bounds per species index. Keys are species indices, values are (min, max) tuples. ``None`` in either position means unbounded. verbose: Print verbose output. show_progress: Show a progress bar. ``True`` for a terminal bar, ``"notebook"`` for Jupyter. Returns: A dictionary mapping composition tuples to lists of :any:`Configuration` objects. Keys are tuples like ``(2, 1, 1)`` where each element gives the count of the corresponding species. """ n_sites = self.dim all_partitions = generate_partitions(n_sites, n_species) # Initialize progress bar without pre-counting (avoids iterator exhaustion) if show_progress: TqdmClass = tqdm_auto if show_progress == 'notebook' else tqdm progress_bar = TqdmClass( desc="Compositions", unit=" compositions", mininterval=0.1 ) results = {} partitions_analyzed = 0 for partition in all_partitions: canonical = partition # Get all permutations for this partition all_perms = list(unique_permutations(partition)) # Filter by bounds valid_perms = [] for perm in all_perms: composition_dict = {i: count for i, count in enumerate(perm)} if bounds is None or satisfies_bounds(composition_dict, bounds): valid_perms.append(perm) if not valid_perms: continue partitions_analyzed += 1 if verbose and not show_progress: print(f"Processing partition {partition}...") # Build site_distribution for canonical site_distribution = { species: count for species, count in enumerate(canonical) if count > 0 } # Analyze canonical - pass through show_progress canonical_configs = self.unique_configurations( site_distribution=site_distribution, verbose=False, show_progress=show_progress # Changed: pass through ) if verbose and not show_progress: print(f" Found {len(canonical_configs)} unique configurations") # Add results for each valid permutation for perm in valid_perms: if perm == canonical: results[canonical] = canonical_configs else: mapping = compute_mapping_vector(canonical, perm) relabeled = [apply_species_mapping(config, mapping) for config in canonical_configs] results[perm] = relabeled if show_progress: progress_bar.update(1) if show_progress: progress_bar.close() if verbose: print(f"\nSummary:") print(f" Analyzed {partitions_analyzed} partitions") print(f" Generated {len(results)} compositions") print(f" Total unique configurations: {sum(len(configs) for configs in results.values())}") return results
def _generate_random_configuration( self, site_distribution: dict[int, int], rng: np.random.Generator, ) -> Configuration: """Generate a random configuration with the given site distribution. Args: site_distribution: Dictionary mapping species labels to counts. rng: Random number generator. Returns: A random Configuration with the specified distribution. """ n_sites = sum(site_distribution.values()) config = np.empty(n_sites, dtype=int) available_indices = np.arange(n_sites) # Process all but the last species species_list = list(site_distribution.items()) for species, count in species_list[:-1]: selected = _select_random_indices(available_indices, count, rng) config[selected] = species # Remove selected indices from available available_indices = np.setdiff1d(available_indices, selected) # Last species gets remaining indices last_species, _ = species_list[-1] config[available_indices] = last_species return Configuration(config)
[docs] def apply_species_mapping(config, mapping_vector): """ Apply species permutation to a Configuration. Creates a new Configuration where each species index is remapped according to the mapping vector. This is used to generate configurations for non-canonical compositions by relabeling species from canonical composition results. Args: config (Configuration): Configuration object with species indices. mapping_vector (list[int]): 0-indexed list where mapping_vector[i] gives the new species index for current species i. e.g., [1, 0] swaps species 0 and 1. Returns: Configuration: New Configuration with relabeled species. The count (degeneracy) is preserved from the original configuration. Example: >>> config = Configuration([0, 0, 1]) >>> config.count = 2 >>> mapping = [1, 0] # Swap species 0 and 1 >>> result = apply_species_mapping(config, mapping) >>> list(result) [1, 1, 0] >>> result.count 2 """ mapping_arr = np.array(mapping_vector, dtype=np.int8) new_config = Configuration(mapping_arr[config.vector]) new_config.count = config.count return new_config
[docs] def colourings_generator( colours, dim ): for s in combinations_with_replacement( colours, dim ): for new_permutation in unique_permutations( s ): yield new_permutation
def _select_random_indices( available_indices: np.ndarray, count: int, rng: np.random.Generator, ) -> np.ndarray: """Select count random indices from available_indices. Args: available_indices: Array of indices to select from. count: Number of indices to select. rng: Random number generator. Returns: Array of selected indices. """ return rng.choice(available_indices, size=count, replace=False)