diff --git a/bsym/configuration.py b/bsym/configuration.py index c4479f6..bc2ea44 100644 --- a/bsym/configuration.py +++ b/bsym/configuration.py @@ -185,7 +185,7 @@ def tolist(self) -> list[int]: Returns: (List) """ - return list(self.vector) + return self.vector.tolist() # type: ignore[no-any-return] def pprint(self) -> None: print(" ".join([str(e) for e in self.tolist()])) diff --git a/bsym/configuration_space.py b/bsym/configuration_space.py index 3a34cc4..c79df0c 100644 --- a/bsym/configuration_space.py +++ b/bsym/configuration_space.py @@ -121,6 +121,59 @@ def unique_configurations(self, ) return self.enumerate_configurations(generator, verbose=verbose) + def random_unique_configurations( + self, + site_distribution: dict[int, int], + n: int, + sampling: str = 'degeneracy_weighted', + seed: int | None = None, + ) -> 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. + + Returns: + List of n unique Configuration objects with count attributes set. + + Raises: + ValueError: If sampling is not 'degeneracy_weighted' or 'uniform'. + """ + 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] = [] + + while len(unique_configs) < n: + config = self._generate_random_configuration(site_distribution, rng) + config_hash = config.as_bytes() + + if config_hash in seen: + continue + + equivalents = config.get_byte_equivalents(self.symmetry_group) + degeneracy = len(equivalents) + + if sampling == 'uniform': + if rng.random() >= 1.0 / degeneracy: + continue + + seen.update(equivalents) + config.count = degeneracy + unique_configs.append(config) + + return unique_configs + def unique_colourings(self, colours, verbose=False): """ Find the symmetry inequivalent colourings for a given number of 'colours'. @@ -221,6 +274,38 @@ def unique_configurations_by_composition(self, 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) def apply_species_mapping(config, mapping_vector): """ @@ -273,3 +358,20 @@ def permutation_as_config_number(p): tot *= 10 tot += num return tot + +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) diff --git a/bsym/interface/pymatgen.py b/bsym/interface/pymatgen.py index 2047791..8472b02 100644 --- a/bsym/interface/pymatgen.py +++ b/bsym/interface/pymatgen.py @@ -496,4 +496,63 @@ def unique_structure_substitutions_by_composition( results[composition_tuple] = structures - return results \ No newline at end of file + return results + +def random_unique_structure_substitutions( + structure, + to_substitute, + site_distribution, + n, + sampling='degeneracy_weighted', + seed=None, + atol=1e-5, +): + """ + Generate n random symmetry-unique structures by substituting sites in a pymatgen structure. + + Args: + structure (pymatgen.Structure): The parent structure. + to_substitute (str): Atom label for the sites to be substituted. + site_distribution (dict): Dictionary mapping species to counts, e.g. {'O': 8, 'F': 8}. + n (int): Number of unique structures to generate. + sampling (str): 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 (int, optional): Random seed for reproducibility. + atol (float): Tolerance factor for coordinate mapping. Default=1e-5. + + Returns: + list[Structure]: A list of n unique Structure objects. Each has a + `number_of_equivalent_configurations` attribute. + """ + site_substitution_index = list(structure.indices_from_symbol(to_substitute)) + + config_space = configuration_space_from_structure( + structure, + subset=site_substitution_index, + atol=atol + ) + + numeric_site_distribution, numeric_site_mapping = parse_site_distribution( + site_distribution + ) + + configurations = config_space.random_unique_configurations( + site_distribution=numeric_site_distribution, + n=n, + sampling=sampling, + seed=seed, + ) + + unique_structures = [] + for config in configurations: + species_for_sites = [numeric_site_mapping[i] for i in config.tolist()] + new_structure = new_structure_from_substitution( + structure, + site_substitution_index, + species_for_sites + ) + new_structure.number_of_equivalent_configurations = config.count + unique_structures.append(new_structure) + + return unique_structures \ No newline at end of file diff --git a/bsym/symmetry_group.py b/bsym/symmetry_group.py index 9fcb2cf..c9ffd1e 100644 --- a/bsym/symmetry_group.py +++ b/bsym/symmetry_group.py @@ -14,17 +14,17 @@ class SymmetryGroup: e.g.:: - SymmetryGroup( symmetry_operations=[ s1, s2, s3 ] ) + SymmetryGroup( symmetry_operations=[s1, s2, s3]) where `s1`, `s2`, and `s3` are :any:`SymmetryOperation` objects. :any:`SymmetryGroup` objects can also be created from files using the class methods:: - SymmetryGroup.read_from_file( filename ) + SymmetryGroup.read_from_file(filename) and:: - SymmetryGroup.read_from_file_with_labels( filename ) + SymmetryGroup.read_from_file_with_labels(filename) """ class_str = 'SymmetryGroup' @@ -74,8 +74,8 @@ def unique_index_mappings(self) -> NDArray[np.int_]: return self._unique_mappings def operate_on(self, - configuration: Configuration, - minimal_set: bool=False) -> list[Configuration]: + configuration: Configuration, + minimal_set: bool=False) -> list[Configuration]: """ Returns a list of Configurations generated by applying every symmetry operation in this symmetry group. diff --git a/bsym/version.py b/bsym/version.py index 8c0d5d5..9aa3f90 100644 --- a/bsym/version.py +++ b/bsym/version.py @@ -1 +1 @@ -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index f74f931..6ea3456 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -12,3 +12,4 @@ These guides show you how to solve common crystallographic problems using the `` fixed_composition varying_composition multi_level_disorder + random_sampling diff --git a/docs/source/user_guide/random_sampling.ipynb b/docs/source/user_guide/random_sampling.ipynb new file mode 100644 index 0000000..8cd7a3d --- /dev/null +++ b/docs/source/user_guide/random_sampling.ipynb @@ -0,0 +1,373 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Random Sampling of Configurations\n", + "\n", + "When the number of possible configurations is very large, full enumeration may be computationally prohibitive. In these cases, you can generate a random sample of symmetry-inequivalent configurations instead.\n", + "\n", + "This guide covers:\n", + "- Generating random unique configurations\n", + "- Sampling modes: `degeneracy_weighted` vs `uniform`\n", + "- Reproducibility with seeds\n", + "- Working with pymatgen structures" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## When to Use Random Sampling\n", + "\n", + "Full enumeration finds *all* symmetry-inequivalent configurations, which is ideal when:\n", + "- You need a complete set for exhaustive calculations\n", + "- The configuration space is small enough to enumerate\n", + "\n", + "Random sampling is useful when:\n", + "- Full enumeration would take too long or use too much memory\n", + "- You only need a representative subset (e.g., for machine learning training data)\n", + "- You want to explore a large configuration space without exhaustive enumeration" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Basic Usage\n", + "\n", + "### With Pymatgen Structures\n", + "\n", + "For crystallographic applications, use `random_unique_structure_substitutions()`:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generated 10 unique structures\n", + "Structure 0: 32 equivalent configurations\n", + "Structure 1: 16 equivalent configurations\n", + "Structure 2: 64 equivalent configurations\n", + "Structure 3: 64 equivalent configurations\n", + "Structure 4: 64 equivalent configurations\n", + "Structure 5: 32 equivalent configurations\n", + "Structure 6: 64 equivalent configurations\n", + "Structure 7: 32 equivalent configurations\n", + "Structure 8: 64 equivalent configurations\n", + "Structure 9: 64 equivalent configurations\n" + ] + } + ], + "source": [ + "from pymatgen.core import Structure, Lattice\n", + "from bsym.interface.pymatgen import random_unique_structure_substitutions\n", + "import numpy as np\n", + "\n", + "# Create a 4x4 square lattice\n", + "coords = np.array([[0.0, 0.0, 0.0]])\n", + "lattice = Lattice.from_parameters(a=1.0, b=1.0, c=1.0, alpha=90, beta=90, gamma=90)\n", + "unit_cell = Structure(lattice, ['Li'], coords)\n", + "parent_structure = unit_cell * [4, 4, 1]\n", + "\n", + "# Generate 10 random unique structures with 4 Na substitutions\n", + "# (There are 33 unique configurations for this composition)\n", + "random_structures = random_unique_structure_substitutions(\n", + " parent_structure,\n", + " 'Li',\n", + " {'Na': 4, 'Li': 12},\n", + " n=10,\n", + " seed=42\n", + ")\n", + "\n", + "print(f\"Generated {len(random_structures)} unique structures\")\n", + "for i, struct in enumerate(random_structures):\n", + " print(f\"Structure {i}: {struct.number_of_equivalent_configurations} equivalent configurations\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Abstract Configuration Space\n", + "\n", + "You can also use `random_unique_configurations()` directly on a `ConfigurationSpace`:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generated 5 unique configurations\n", + "[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0]: degeneracy = 4\n", + "[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1]: degeneracy = 16\n", + "[0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1]: degeneracy = 32\n", + "[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1]: degeneracy = 32\n", + "[0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0]: degeneracy = 16\n" + ] + } + ], + "source": [ + "from bsym.interface.pymatgen import configuration_space_from_structure\n", + "\n", + "config_space = configuration_space_from_structure(parent_structure)\n", + "\n", + "# Generate 5 random unique configurations\n", + "random_configs = config_space.random_unique_configurations(\n", + " site_distribution={1: 4, 0: 12}, # 4 occupied, 12 vacant\n", + " n=5,\n", + " seed=42\n", + ")\n", + "\n", + "print(f\"Generated {len(random_configs)} unique configurations\")\n", + "for config in random_configs:\n", + " print(f\"{config.tolist()}: degeneracy = {config.count}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Sampling Modes\n", + "\n", + "Two sampling modes are available, controlled by the `sampling` parameter:\n", + "\n", + "### `degeneracy_weighted` (default)\n", + "\n", + "Each configuration in the full (unsymmetrised) space has equal probability of being selected. This means equivalence classes with higher degeneracy are more likely to be sampled.\n", + "\n", + "This mode is appropriate when:\n", + "- You want sampling that reflects the statistical weight of configurations\n", + "- High-degeneracy configurations are more \"important\" for your application\n", + "- You're doing thermodynamic sampling where degeneracy matters\n", + "\n", + "### `uniform`\n", + "\n", + "Each equivalence class has equal probability of being selected, regardless of degeneracy. This uses rejection sampling internally.\n", + "\n", + "This mode is appropriate when:\n", + "- You want equal representation of all unique configurations\n", + "- You're building a diverse training set\n", + "- Degeneracy should not influence selection" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Degeneracy-weighted sampling:\n", + " Mean degeneracy: 52.5\n", + "\n", + "Uniform sampling:\n", + " Mean degeneracy: 41.0\n" + ] + } + ], + "source": [ + "# Compare sampling modes\n", + "n_samples = 50\n", + "\n", + "# Degeneracy-weighted sampling\n", + "weighted_degeneracies = []\n", + "for i in range(n_samples):\n", + " configs = config_space.random_unique_configurations(\n", + " site_distribution={1: 4, 0: 12},\n", + " n=1,\n", + " sampling='degeneracy_weighted',\n", + " seed=i\n", + " )\n", + " weighted_degeneracies.append(configs[0].count)\n", + "\n", + "# Uniform sampling\n", + "uniform_degeneracies = []\n", + "for i in range(n_samples):\n", + " configs = config_space.random_unique_configurations(\n", + " site_distribution={1: 4, 0: 12},\n", + " n=1,\n", + " sampling='uniform',\n", + " seed=i\n", + " )\n", + " uniform_degeneracies.append(configs[0].count)\n", + "\n", + "print(\"Degeneracy-weighted sampling:\")\n", + "print(f\" Mean degeneracy: {np.mean(weighted_degeneracies)}\")\n", + "print()\n", + "print(\"Uniform sampling:\")\n", + "print(f\" Mean degeneracy: {np.mean(uniform_degeneracies)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With `degeneracy_weighted` sampling, the mean degeneracy of sampled configurations is higher because high-degeneracy configurations are more likely to be selected." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reproducibility\n", + "\n", + "Use the `seed` parameter to get reproducible results:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Results are identical: True\n" + ] + } + ], + "source": [ + "# Same seed produces same results\n", + "configs_1 = config_space.random_unique_configurations(\n", + " site_distribution={1: 4, 0: 12},\n", + " n=3,\n", + " seed=12345\n", + ")\n", + "\n", + "configs_2 = config_space.random_unique_configurations(\n", + " site_distribution={1: 4, 0: 12},\n", + " n=3,\n", + " seed=12345\n", + ")\n", + "\n", + "print(\"Results are identical:\", all(\n", + " c1.tolist() == c2.tolist() \n", + " for c1, c2 in zip(configs_1, configs_2)\n", + "))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Performance Considerations\n", + "\n", + "### When Random Sampling May Be Slow\n", + "\n", + "Performance may degrade when:\n", + "- `n` approaches the total number of unique configurations (many rejections)\n", + "- Using `uniform` sampling with highly variable degeneracies (rejection sampling overhead)\n", + "- The symmetry group is very large (computing equivalents is expensive)\n", + "\n", + "### Tips\n", + "\n", + "1. If you need most or all unique configurations, use full enumeration instead\n", + "2. For `uniform` sampling, be aware that low-degeneracy configurations require more attempts to find\n", + "3. If sampling seems to hang, you may be requesting more configurations than exist" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example: Generating Training Data\n", + "\n", + "A common use case is generating diverse training data for machine learning:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generated 20 training structures\n", + "All structures have composition: Li8 Na8\n" + ] + } + ], + "source": [ + "# Generate diverse structures for ML training\n", + "# Using Na=8, Li=8 which has 153 unique configurations\n", + "training_structures = random_unique_structure_substitutions(\n", + " parent_structure,\n", + " 'Li',\n", + " {'Na': 8, 'Li': 8},\n", + " n=20,\n", + " sampling='uniform', # Equal representation of all unique configs\n", + " seed=42\n", + ")\n", + "\n", + "print(f\"Generated {len(training_structures)} training structures\")\n", + "print(f\"All structures have composition: {training_structures[0].composition.reduced_formula}\")\n", + "\n", + "# These can be exported for DFT calculations\n", + "# for i, struct in enumerate(training_structures):\n", + "# struct.to(filename=f'training_{i:03d}.cif', fmt='cif')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## API Reference\n", + "\n", + "### `ConfigurationSpace.random_unique_configurations`\n", + "\n", + "```python\n", + "config_space.random_unique_configurations(\n", + " site_distribution, # dict mapping species labels to counts\n", + " n, # number of configurations to generate\n", + " sampling='degeneracy_weighted', # or 'uniform'\n", + " seed=None, # random seed for reproducibility\n", + ")\n", + "```\n", + "\n", + "### `random_unique_structure_substitutions`\n", + "\n", + "```python\n", + "random_unique_structure_substitutions(\n", + " structure, # parent pymatgen Structure\n", + " to_substitute, # species label to substitute (e.g., 'Li')\n", + " site_distribution, # dict mapping species to counts (e.g., {'Na': 4, 'Li': 12})\n", + " n, # number of structures to generate\n", + " sampling='degeneracy_weighted', # or 'uniform'\n", + " seed=None, # random seed for reproducibility\n", + " atol=1e-5, # tolerance for coordinate mapping\n", + ")\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/tests/integration_tests/test_configuration_space.py b/tests/integration_tests/test_configuration_space.py new file mode 100644 index 0000000..b959989 --- /dev/null +++ b/tests/integration_tests/test_configuration_space.py @@ -0,0 +1,126 @@ +import unittest +from bsym import ConfigurationSpace, SymmetryGroup, SymmetryOperation + + +class TestRandomUniqueConfigurations(unittest.TestCase): + """Integration tests for ConfigurationSpace.random_unique_configurations.""" + + def setUp(self): + """Set up C4v symmetry group and configuration space.""" + e = SymmetryOperation.from_vector([1, 2, 3, 4], label='E') + c4 = SymmetryOperation.from_vector([2, 3, 4, 1], label='C4') + c4_inv = SymmetryOperation.from_vector([4, 1, 2, 3], label='C4i') + c2 = SymmetryOperation.from_vector([3, 4, 1, 2], label='C2') + sigma_x = SymmetryOperation.from_vector([4, 3, 2, 1], label='s_x') + sigma_y = SymmetryOperation.from_vector([2, 1, 4, 3], label='s_y') + sigma_ac = SymmetryOperation.from_vector([1, 4, 3, 2], label='s_ac') + sigma_bd = SymmetryOperation.from_vector([3, 2, 1, 4], label='s_bd') + + self.c4v = SymmetryGroup([e, c4, c4_inv, c2, sigma_x, sigma_y, sigma_ac, sigma_bd]) + + self.config_space = ConfigurationSpace( + objects=['a', 'b', 'c', 'd'], + symmetry_group=self.c4v + ) + + def test_returned_configurations_are_mutually_inequivalent(self): + """ + Test that all returned configurations are symmetry-inequivalent + to each other. + """ + result = self.config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=2, + seed=42, + ) + + for i, config_i in enumerate(result): + for j, config_j in enumerate(result): + if i >= j: + continue + self.assertFalse( + config_i.is_equivalent_to(config_j, self.c4v.symmetry_operations), + f"Configuration {i} and {j} are equivalent" + ) + + def test_same_seed_produces_same_results(self): + """ + Test that using the same seed produces identical results. + """ + result_1 = self.config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=2, + seed=42, + ) + + result_2 = self.config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=2, + seed=42, + ) + + self.assertEqual(len(result_1), len(result_2)) + for config_1, config_2 in zip(result_1, result_2): + self.assertEqual(config_1.tolist(), config_2.tolist()) + + def test_degeneracies_are_correct(self): + """ + Test that returned configurations have correct degeneracy values. + """ + # For C4v with {1: 2, 0: 2}, there are exactly 2 unique configurations: + # - adjacent sites: degeneracy 4 + # - diagonal sites: degeneracy 2 + result = self.config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=2, + seed=42, + ) + + degeneracies = sorted([config.count for config in result]) + self.assertEqual(degeneracies, [2, 4]) + + def test_uniform_sampling_distribution_differs_from_degeneracy_weighted(self): + """ + Test that uniform sampling produces a different distribution + than degeneracy_weighted sampling. + + With degeneracy_weighted, high-degeneracy configurations are more + likely to be sampled. With uniform, all equivalence classes have + equal probability. + """ + n_samples = 100 + + # Count how often we get the high-degeneracy (adjacent) configuration + # vs low-degeneracy (diagonal) configuration + + degeneracy_weighted_high_count = 0 + for i in range(n_samples): + result = self.config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=1, + sampling='degeneracy_weighted', + seed=i, + ) + if result[0].count == 4: # High degeneracy (adjacent) + degeneracy_weighted_high_count += 1 + + uniform_high_count = 0 + for i in range(n_samples): + result = self.config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=1, + sampling='uniform', + seed=i, + ) + if result[0].count == 4: # High degeneracy (adjacent) + uniform_high_count += 1 + + # With degeneracy_weighted: P(adjacent) = 4/6 ≈ 0.67 + # With uniform: P(adjacent) = 0.5 + # So degeneracy_weighted should have more high-degeneracy samples + + self.assertGreater( + degeneracy_weighted_high_count, + uniform_high_count, + "degeneracy_weighted should favour high-degeneracy configurations" + ) \ No newline at end of file diff --git a/tests/integration_tests/test_interface_pymatgen.py b/tests/integration_tests/test_interface_pymatgen.py index 19b1df4..5040523 100644 --- a/tests/integration_tests/test_interface_pymatgen.py +++ b/tests/integration_tests/test_interface_pymatgen.py @@ -3,15 +3,18 @@ import numpy as np from pymatgen.core.lattice import Lattice from pymatgen.core.structure import Molecule, Structure -from bsym.interface.pymatgen import (unique_symmetry_operations_as_vectors_from_structure, - space_group_from_structure, - parse_site_distribution, - unique_structure_substitutions, - new_structure_from_substitution, - configuration_space_from_structure, - space_group_symbol_from_structure, - configuration_space_from_molecule, - unique_structure_substitutions_by_composition) +from bsym.interface.pymatgen import ( + unique_symmetry_operations_as_vectors_from_structure, + space_group_from_structure, + parse_site_distribution, + unique_structure_substitutions, + new_structure_from_substitution, + configuration_space_from_structure, + space_group_symbol_from_structure, + configuration_space_from_molecule, + unique_structure_substitutions_by_composition, + random_unique_structure_substitutions +) from itertools import permutations from bsym import ( SymmetryOperation, @@ -256,6 +259,103 @@ def test_unique_structure_substitutions_by_composition_binary_on_square(self): # Verify total unique configurations total_unique = sum(len(configs) for configs in results.values()) self.assertEqual(total_unique, 6) + +class TestRandomUniqueStructureSubstitutions(unittest.TestCase): + """Integration tests for random_unique_structure_substitutions.""" + + def setUp(self): + """Set up a simple 4x4 square lattice structure.""" + coords = np.array([[0.0, 0.0, 0.0]]) + atom_list = ['Li'] + lattice = Lattice.from_parameters(a=1.0, b=1.0, c=1.0, alpha=90, beta=90, gamma=90) + self.parent_structure = Structure(lattice, atom_list, coords) * [4, 4, 1] + + def test_returns_n_unique_structures(self): + """Test that the correct number of structures is returned.""" + result = random_unique_structure_substitutions( + self.parent_structure, + 'Li', + {'Na': 2, 'Li': 14}, + n=3, + seed=42 + ) + + self.assertEqual(len(result), 3) + + def test_returned_structures_are_mutually_inequivalent(self): + """Test that returned structures are symmetry-inequivalent.""" + result = random_unique_structure_substitutions( + self.parent_structure, + 'Li', + {'Na': 2, 'Li': 14}, + n=5, + seed=42 + ) + + # Each structure should have Na at different relative positions + na_positions = [] + for struct in result: + na_indices = struct.indices_from_symbol('Na') + na_coords = tuple(sorted([tuple(struct[i].frac_coords) for i in na_indices])) + na_positions.append(na_coords) + + # All Na position sets should be unique + self.assertEqual(len(na_positions), len(set(na_positions))) + + def test_same_seed_produces_same_results(self): + """Test that using the same seed produces identical results.""" + result_1 = random_unique_structure_substitutions( + self.parent_structure, + 'Li', + {'Na': 2, 'Li': 14}, + n=3, + seed=42 + ) + + result_2 = random_unique_structure_substitutions( + self.parent_structure, + 'Li', + {'Na': 2, 'Li': 14}, + n=3, + seed=42 + ) + + self.assertEqual(len(result_1), len(result_2)) + for s1, s2 in zip(result_1, result_2): + na_idx_1 = list(s1.indices_from_symbol('Na')) + na_idx_2 = list(s2.indices_from_symbol('Na')) + self.assertEqual(na_idx_1, na_idx_2) + + def test_structures_have_correct_composition(self): + """Test that returned structures have the requested composition.""" + result = random_unique_structure_substitutions( + self.parent_structure, + 'Li', + {'Na': 2, 'Mg': 1, 'Li': 13}, + n=3, + seed=42 + ) + + for struct in result: + composition = struct.composition.as_dict() + self.assertEqual(composition['Na'], 2) + self.assertEqual(composition['Mg'], 1) + self.assertEqual(composition['Li'], 13) + + def test_structures_have_degeneracy_attribute(self): + """Test that returned structures have number_of_equivalent_configurations set.""" + result = random_unique_structure_substitutions( + self.parent_structure, + 'Li', + {'Na': 2, 'Li': 14}, + n=3, + seed=42 + ) + + for struct in result: + self.assertTrue(hasattr(struct, 'number_of_equivalent_configurations')) + self.assertIsInstance(struct.number_of_equivalent_configurations, int) + self.assertGreater(struct.number_of_equivalent_configurations, 0) if __name__ == '__main__': unittest.main() diff --git a/tests/unit_tests/test_configuration_space.py b/tests/unit_tests/test_configuration_space.py index f767a11..19994fc 100644 --- a/tests/unit_tests/test_configuration_space.py +++ b/tests/unit_tests/test_configuration_space.py @@ -547,6 +547,234 @@ def test_enumerate_configurations_sets_correct_counts(self): # [1,0,0] has 2 equivalents: [1,0,0] and [0,1,0] self.assertEqual(unique_configs[0].count, 2) + +class TestConfigurationSpaceRandomUniqueConfigurations(unittest.TestCase): + """Tests for ConfigurationSpace.random_unique_configurations and helpers.""" + + def test_random_unique_configurations_returns_n_configurations(self): + """ + Test that random_unique_configurations returns n configurations + when n unique configurations are found. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + + mock_config_1 = Mock(spec=Configuration) + mock_config_1.as_bytes.return_value = b'config1' + mock_config_1.get_byte_equivalents.return_value = {b'config1'} + + mock_config_2 = Mock(spec=Configuration) + mock_config_2.as_bytes.return_value = b'config2' + mock_config_2.get_byte_equivalents.return_value = {b'config2'} + + with patch.object(config_space, '_generate_random_configuration', + side_effect=[mock_config_1, mock_config_2]): + result = config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=2, + sampling='degeneracy_weighted', + ) + + self.assertEqual(len(result), 2) + + def test_random_unique_configurations_skips_equivalent_configurations(self): + """ + Test that configurations equivalent to already-seen configurations + are skipped. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + + mock_config_a = Mock(spec=Configuration) + mock_config_a.as_bytes.return_value = b'config_a' + mock_config_a.get_byte_equivalents.return_value = {b'config_a', b'config_b'} + + mock_config_b = Mock(spec=Configuration) + mock_config_b.as_bytes.return_value = b'config_b' # Equivalent to config_a + + mock_config_c = Mock(spec=Configuration) + mock_config_c.as_bytes.return_value = b'config_c' + mock_config_c.get_byte_equivalents.return_value = {b'config_c'} + + with patch.object(config_space, '_generate_random_configuration', + side_effect=[mock_config_a, mock_config_b, mock_config_c]): + result = config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=2, + sampling='degeneracy_weighted', + ) + + self.assertEqual(len(result), 2) + self.assertIn(mock_config_a, result) + self.assertIn(mock_config_c, result) + self.assertNotIn(mock_config_b, result) + + def test_random_unique_configurations_sets_count_attribute(self): + """ + Test that returned configurations have their count attribute + set to the number of equivalent configurations. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + + mock_config = Mock(spec=Configuration) + mock_config.as_bytes.return_value = b'config' + mock_config.get_byte_equivalents.return_value = {b'equiv_1', b'equiv_2'} + + with patch.object(config_space, '_generate_random_configuration', + return_value=mock_config): + result = config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=1, + sampling='degeneracy_weighted', + ) + + self.assertEqual(result[0].count, 2) + + def test_random_unique_configurations_raises_for_invalid_sampling(self): + """ + Test that an invalid sampling value raises ValueError. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + + with self.assertRaises(ValueError): + config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=1, + sampling='invalid_option', + ) + + def test_random_unique_configurations_passes_seed_to_random_generator(self): + """ + Test that the seed parameter is used to initialise the random generator. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + + mock_config = Mock(spec=Configuration) + mock_config.as_bytes.return_value = b'config' + mock_config.get_byte_equivalents.return_value = {b'config'} + + with patch('bsym.configuration_space.np.random.default_rng') as mock_rng_constructor: + mock_rng = Mock() + mock_rng_constructor.return_value = mock_rng + + with patch.object(config_space, '_generate_random_configuration', + return_value=mock_config): + config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=1, + sampling='degeneracy_weighted', + seed=42, + ) + + mock_rng_constructor.assert_called_once_with(42) + + def test_random_unique_configurations_uniform_rejects_based_on_degeneracy(self): + """ + Test that uniform sampling rejects configurations with probability + proportional to their degeneracy. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + + mock_config = Mock(spec=Configuration) + mock_config.as_bytes.return_value = b'config' + mock_config.get_byte_equivalents.return_value = {b'equiv_1', b'equiv_2'} # degeneracy = 2 + + mock_rng = Mock() + mock_rng.random.return_value = 0.3 # < 0.5 (1/degeneracy = 1/2), so should accept + + with patch('bsym.configuration_space.np.random.default_rng', return_value=mock_rng): + with patch.object(config_space, '_generate_random_configuration', + return_value=mock_config): + result = config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=1, + sampling='uniform', + ) + + self.assertEqual(len(result), 1) + + def test_random_unique_configurations_uniform_rejection_does_not_add_to_seen(self): + """ + Test that when uniform sampling rejects a configuration, + it is not added to the seen set (can be found again later). + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + + mock_config = Mock(spec=Configuration) + mock_config.as_bytes.return_value = b'config' + mock_config.get_byte_equivalents.return_value = {b'equiv_1', b'equiv_2'} # degeneracy = 2 + + mock_rng = Mock() + mock_rng.random.side_effect = [0.7, 0.3] # First reject, then accept + + with patch('bsym.configuration_space.np.random.default_rng', return_value=mock_rng): + with patch.object(config_space, '_generate_random_configuration', + return_value=mock_config) as mock_generate: + result = config_space.random_unique_configurations( + site_distribution={1: 2, 0: 2}, + n=1, + sampling='uniform', + ) + + self.assertEqual(mock_generate.call_count, 2) + + self.assertEqual(len(result), 1) + + def test_generate_random_configuration_returns_configuration_with_correct_distribution(self): + """ + Test that _generate_random_configuration returns a Configuration + with the correct count of each species. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + mock_rng = Mock() + + with patch('bsym.configuration_space._select_random_indices', + return_value=np.array([0, 1])): + result = config_space._generate_random_configuration( + site_distribution={1: 2, 0: 2}, + rng=mock_rng, + ) + + self.assertIsInstance(result, Configuration) + result_list = result.tolist() + self.assertEqual(result_list.count(0), 2) + self.assertEqual(result_list.count(1), 2) + + + def test_generate_random_configuration_uses_select_random_indices(self): + """ + Test that _generate_random_configuration uses _select_random_indices + to select positions for each species. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + mock_rng = Mock() + + with patch('bsym.configuration_space._select_random_indices', + return_value=np.array([1, 3])) as mock_select: + result = config_space._generate_random_configuration( + site_distribution={1: 2, 0: 2}, + rng=mock_rng, + ) + + mock_select.assert_called_once() + self.assertEqual(result.tolist(), [0, 1, 0, 1]) + + + def test_generate_random_configuration_passes_rng_to_select_random_indices(self): + """ + Test that _generate_random_configuration passes the rng + to _select_random_indices. + """ + config_space = ConfigurationSpace(objects=[1, 2, 3, 4]) + mock_rng = Mock() + + with patch('bsym.configuration_space._select_random_indices', + return_value=np.array([0, 1])) as mock_select: + config_space._generate_random_configuration( + site_distribution={1: 2, 0: 2}, + rng=mock_rng, + ) + + args, _ = mock_select.call_args + self.assertIs(args[2], mock_rng) if __name__ == '__main__': diff --git a/tests/unit_tests/test_interface_pymatgen.py b/tests/unit_tests/test_interface_pymatgen.py index de9dfa2..ec78b7c 100644 --- a/tests/unit_tests/test_interface_pymatgen.py +++ b/tests/unit_tests/test_interface_pymatgen.py @@ -4,17 +4,20 @@ from pymatgen.core.lattice import Lattice from pymatgen.core.structure import Molecule, Structure from pymatgen.core.operations import SymmOp -from bsym.interface.pymatgen import (unique_symmetry_operations_as_vectors_from_structure, - space_group_from_structure, - parse_site_distribution, - unique_structure_substitutions, - new_structure_from_substitution, - configuration_space_from_structure, - space_group_symbol_from_structure, - configuration_space_from_molecule, - structure_cartesian_coordinates_mapping, - molecule_cartesian_coordinates_mapping, - unique_structure_substitutions_by_composition) +from bsym.interface.pymatgen import ( + unique_symmetry_operations_as_vectors_from_structure, + space_group_from_structure, + parse_site_distribution, + unique_structure_substitutions, + new_structure_from_substitution, + configuration_space_from_structure, + space_group_symbol_from_structure, + configuration_space_from_molecule, + structure_cartesian_coordinates_mapping, + molecule_cartesian_coordinates_mapping, + unique_structure_substitutions_by_composition, + random_unique_structure_substitutions +) from itertools import permutations from bsym import SymmetryOperation, Configuration, SpaceGroup, PointGroup, ConfigurationSpace @@ -232,6 +235,178 @@ def test_unique_structure_substitutions_by_composition_passes_through_parameters self.assertEqual(call_kwargs['verbose'], True) self.assertEqual(call_kwargs['show_progress'], 'notebook') +class TestRandomUniqueStructureSubstitutions(unittest.TestCase): + """Tests for random_unique_structure_substitutions.""" + + @patch('bsym.interface.pymatgen.configuration_space_from_structure') + @patch('bsym.interface.pymatgen.new_structure_from_substitution') + def test_calls_configuration_space_from_structure_correctly( + self, mock_new_structure, mock_config_space_from_structure + ): + """Test that configuration space is created with correct parameters.""" + mock_structure = Mock(spec=Structure) + mock_structure.indices_from_symbol = Mock(return_value=[0, 1, 2, 3]) + + mock_config_space = Mock(spec=ConfigurationSpace) + mock_config_space.random_unique_configurations = Mock(return_value=[]) + mock_config_space_from_structure.return_value = mock_config_space + + random_unique_structure_substitutions( + mock_structure, + 'X', + {'Li': 2, 'Na': 2}, + n=5, + atol=1e-6 + ) + + mock_config_space_from_structure.assert_called_once_with( + mock_structure, + subset=[0, 1, 2, 3], + atol=1e-6 + ) + + @patch('bsym.interface.pymatgen.configuration_space_from_structure') + @patch('bsym.interface.pymatgen.new_structure_from_substitution') + def test_converts_site_distribution_to_numeric( + self, mock_new_structure, mock_config_space_from_structure + ): + """Test that species names are converted to numeric indices.""" + mock_structure = Mock(spec=Structure) + mock_structure.indices_from_symbol = Mock(return_value=[0, 1, 2, 3]) + + mock_config_space = Mock(spec=ConfigurationSpace) + mock_config_space.random_unique_configurations = Mock(return_value=[]) + mock_config_space_from_structure.return_value = mock_config_space + + random_unique_structure_substitutions( + mock_structure, + 'X', + {'Li': 2, 'Na': 2}, + n=5 + ) + + call_kwargs = mock_config_space.random_unique_configurations.call_args[1] + self.assertEqual(call_kwargs['site_distribution'], {0: 2, 1: 2}) + + @patch('bsym.interface.pymatgen.configuration_space_from_structure') + @patch('bsym.interface.pymatgen.new_structure_from_substitution') + def test_passes_parameters_to_random_unique_configurations( + self, mock_new_structure, mock_config_space_from_structure + ): + """Test that n, sampling, and seed are passed through correctly.""" + mock_structure = Mock(spec=Structure) + mock_structure.indices_from_symbol = Mock(return_value=[0, 1, 2, 3]) + + mock_config_space = Mock(spec=ConfigurationSpace) + mock_config_space.random_unique_configurations = Mock(return_value=[]) + mock_config_space_from_structure.return_value = mock_config_space + + random_unique_structure_substitutions( + mock_structure, + 'X', + {'Li': 2, 'Na': 2}, + n=10, + sampling='uniform', + seed=42 + ) + + call_kwargs = mock_config_space.random_unique_configurations.call_args[1] + self.assertEqual(call_kwargs['n'], 10) + self.assertEqual(call_kwargs['sampling'], 'uniform') + self.assertEqual(call_kwargs['seed'], 42) + + @patch('bsym.interface.pymatgen.configuration_space_from_structure') + @patch('bsym.interface.pymatgen.new_structure_from_substitution') + def test_converts_configurations_to_structures( + self, mock_new_structure, mock_config_space_from_structure + ): + """Test that configurations are converted to Structure objects.""" + mock_structure = Mock(spec=Structure) + mock_structure.indices_from_symbol = Mock(return_value=[0, 1, 2]) + + mock_config = Mock(spec=Configuration) + mock_config.tolist = Mock(return_value=[0, 1, 0]) # Li, Na, Li + mock_config.count = 3 + + mock_config_space = Mock(spec=ConfigurationSpace) + mock_config_space.random_unique_configurations = Mock(return_value=[mock_config]) + mock_config_space_from_structure.return_value = mock_config_space + + mock_new_struct = Mock(spec=Structure) + mock_new_structure.return_value = mock_new_struct + + random_unique_structure_substitutions( + mock_structure, + 'X', + {'Li': 2, 'Na': 1}, + n=1 + ) + + mock_new_structure.assert_called_once_with( + mock_structure, + [0, 1, 2], + ['Li', 'Na', 'Li'] + ) + + @patch('bsym.interface.pymatgen.configuration_space_from_structure') + @patch('bsym.interface.pymatgen.new_structure_from_substitution') + def test_sets_number_of_equivalent_configurations( + self, mock_new_structure, mock_config_space_from_structure + ): + """Test that degeneracy is set on returned structures.""" + mock_structure = Mock(spec=Structure) + mock_structure.indices_from_symbol = Mock(return_value=[0, 1, 2]) + + mock_config = Mock(spec=Configuration) + mock_config.tolist = Mock(return_value=[0, 1, 0]) + mock_config.count = 6 + + mock_config_space = Mock(spec=ConfigurationSpace) + mock_config_space.random_unique_configurations = Mock(return_value=[mock_config]) + mock_config_space_from_structure.return_value = mock_config_space + + mock_new_struct = Mock(spec=Structure) + mock_new_structure.return_value = mock_new_struct + + result = random_unique_structure_substitutions( + mock_structure, + 'X', + {'Li': 2, 'Na': 1}, + n=1 + ) + + self.assertEqual(result[0].number_of_equivalent_configurations, 6) + + @patch('bsym.interface.pymatgen.configuration_space_from_structure') + def test_returns_correct_number_of_structures( + self, mock_config_space_from_structure + ): + """Test that the correct number of structures is returned.""" + mock_structure = Mock(spec=Structure) + mock_structure.indices_from_symbol = Mock(return_value=[0, 1, 2, 3]) + + mock_configs = [] + for i in range(5): + mock_config = Mock(spec=Configuration) + mock_config.tolist = Mock(return_value=[0, 0, 1, 1]) + mock_config.count = 2 + mock_configs.append(mock_config) + + mock_config_space = Mock(spec=ConfigurationSpace) + mock_config_space.random_unique_configurations = Mock(return_value=mock_configs) + mock_config_space_from_structure.return_value = mock_config_space + + with patch('bsym.interface.pymatgen.new_structure_from_substitution') as mock_new: + mock_new.return_value = Mock(spec=Structure) + result = random_unique_structure_substitutions( + mock_structure, + 'X', + {'Li': 2, 'Na': 2}, + n=5 + ) + + self.assertEqual(len(result), 5) + if __name__ == '__main__': unittest.main()