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)