from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
from typing import TYPE_CHECKING, Any
import json
if TYPE_CHECKING:
from bsym.symmetry_operation import SymmetryOperation
[docs]
class Configuration:
"""
A :any:`Configuration` describes a specific arrangement of objects in the vector space of possible positions.
Objects are represented by integers, with indistinguishable objects denoted by identical integers.
Internally, the configuration is stored as a ``numpy.int8`` array.
Attributes:
count (int): If symmetry-inequivalent configurations have been generated for a `configuration space`,
this records the number of configurations equivalent to this one.
Value at initialisation is ``None``.
lowest_numeric_representation (int): If the numeric representations for the set of equivalent
configurations are calculated, this can be used to store the lowest valued numeric
representation, for use as a simple hash.
Example:
>>> Configuration([1, 1, 0])
Configuration([1, 1, 0])
"""
def __init__(self, vector: list[int] | NDArray[np.int_]) -> None:
self.count: int | None = None
self.lowest_numeric_representation: int | None = None
self.vector: np.ndarray = np.asarray(vector, dtype=np.int8)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Configuration):
return NotImplemented
return np.array_equal(self.vector, other.vector)
def __hash__(self) -> int:
return hash(self.vector.tobytes())
[docs]
def matches(self, test_configuration: Configuration) -> bool:
"""
Test whether this configuration is equal to another configuration.
Args:
test_configuration (:any:`Configuration`): The configuration to compare against.
Returns:
(bool): True | False.
"""
if not isinstance(test_configuration, Configuration):
raise TypeError
return self == test_configuration
[docs]
def is_equivalent_to(
self,
test_configuration: Configuration,
symmetry_operations: list[SymmetryOperation],
) -> bool:
"""
Test whether this configuration is equivalent to another configuration
under one or more of a set of symmetry operations.
Args:
test_configuration (Configuration): The configuration to compare against.
symmetry_operations (list(SymmetryOperation): A list of SymmetryOperation objects.
Returns:
(bool): True | False
"""
for symmetry_operation in symmetry_operations:
if symmetry_operation.operate_on(self) == test_configuration:
return True
else:
return False
[docs]
def is_in_list(self, the_list: list[Configuration]) -> bool:
"""
Test whether this configuration is in a list of configurations.
Args:
list (list(bsym.Comfiguration)): A list of Configuration instances.
Returns:
(bool): True | False
"""
return next((True for c in the_list if self.matches(c)), False)
[docs]
def has_equivalent_in_list(
self,
the_list: list[Configuration],
symmetry_operations: list[SymmetryOperation],
) -> bool:
"""
Test whether this configuration is equivalent by symmetry to one or more
in a list of configurations.
Args:
list (list(bsym.Configuration)): A list of :any:`Configuration` instances.
symmetry_operations (list(bsym.SymmetryOperation)): A list of :any:`SymmetryOperation` objects.
Returns:
(bool): True | False
"""
return next(
(True for c in the_list if self.is_equivalent_to(c, symmetry_operations)),
False,
)
[docs]
def set_lowest_numeric_representation(
self, symmetry_operations: list[SymmetryOperation]
) -> None:
"""
Sets `self.lowest_numeric_representation` to the lowest value numeric representation of this configuration when permutated under a set of symmetry operations.
Args:
symmetry_operations (list): A list of :any:`SymmetryOperation` instances.
Returns:
None
"""
self.lowest_numeric_representation = min(
[
symmetry_operation.operate_on(self).as_number
for symmetry_operation in symmetry_operations
]
)
[docs]
def numeric_equivalents(self,
symmetry_operations: list[SymmetryOperation]) -> list[int]:
"""
Returns a list of all symmetry equivalent configurations generated by a set of symmetry operations
with each configuration given in numeric representation.
Args:
symmetry_operations (list): A list of :any:`SymmetryOperation` instances.
Returns:
(list(int)): A list of numbers representing each equivalent configuration.
"""
return [
symmetry_operation.operate_on(self).as_number
for symmetry_operation in symmetry_operations
]
@property
def as_number(self) -> int:
"""
A numeric representation of this configuration.
Examples:
>>> c = Configuration([1, 2, 0])
>>> c.as_number
120
"""
return as_number(self.vector)
[docs]
@classmethod
def from_tuple(cls, configuration_tuple):
"""
Create a Configuration from a tuple.
Configurations are stored as int8 arrays, supporting species labels 0-255.
Args:
configuration_tuple: Tuple of configuration values (0-255).
Returns:
Configuration: New configuration object.
"""
return cls(np.array(configuration_tuple, dtype=np.int8))
[docs]
def tolist(self) -> list[int]:
"""
Returns the configuration data as a list.
Args:
None
Returns:
(List)
"""
return self.vector.tolist() # type: ignore[no-any-return]
[docs]
def pprint(self) -> None:
print(" ".join([str(e) for e in self.tolist()]))
[docs]
def position(self, label: int) -> list[int]:
"""
Returns the vector indices where elements are equal to `label`.
Args:
label (int): The label used to select the vector positions.
Returns:
(list): A list of all positions that match `label`.
"""
return [i for i, x in enumerate(self.tolist()) if x == label]
def __repr__(self) -> str:
to_return = "Configuration({})\n".format(self.vector)
return to_return
[docs]
def map_objects(self, objects: list) -> dict[int, Any]:
"""
Returns a dict of objects sorted according to this configuration.
Args:
objects [list]: A list of objects.
Returns:
sorted_objects [dict]: A dictionary of sorted objects.
"""
if len(objects) != len(self.vector):
raise ValueError
sorted_objects = {}
for key in set(self.vector):
sorted_objects[key] = [o for k, o in zip(self.vector, objects) if k == key]
return sorted_objects
[docs]
@staticmethod
def tuple_to_bytes(tup: tuple) -> bytes:
"""
Convert configuration tuple to bytes.
Used for initial permutation checking in enumerate_configurations.
Args:
tup: Configuration as tuple.
Returns:
bytes: Byte representation for hashing.
"""
return np.array(tup, dtype=np.int8).tobytes()
[docs]
@staticmethod
def array_to_bytes(arr: np.ndarray) -> bytes:
"""
Convert configuration array to bytes.
Assumes array is already int8. Used for transformations.
Args:
arr: Configuration as int8 numpy array.
Returns:
bytes: Byte representation for hashing.
"""
return arr.tobytes()
[docs]
def as_bytes(self) -> bytes:
"""Get byte representation of this configuration."""
return Configuration.array_to_bytes(self.vector)
[docs]
def get_byte_equivalents(self, symmetry_group) -> set[bytes]:
"""Get byte representations of all symmetry-equivalent configurations."""
transformed_vectors = self.vector[symmetry_group.unique_index_mappings]
byte_equivalents = set(
Configuration.array_to_bytes(vec) for vec in transformed_vectors
)
return byte_equivalents
[docs]
def to_dict(self) -> dict:
"""Convert to JSON-serialisable dictionary.
Returns:
Dictionary with 'vector' key containing the configuration as a list.
"""
return {'vector': self.tolist()}
[docs]
@classmethod
def from_dict(cls, d: dict) -> 'Configuration':
"""Create Configuration from dictionary.
Args:
d: Dictionary with 'vector' key containing configuration values.
Returns:
New Configuration instance.
"""
return cls(d['vector'])
[docs]
def as_number(a: list[int] | NDArray[np.int_]) -> int:
tot = 0
for num in a:
tot *= 10
tot += int(num)
return tot
[docs]
def save_configurations(configurations: list[Configuration], filename: str) -> None:
"""Save configurations to a JSON file.
Args:
configurations: List of Configuration objects to save.
filename: Path to output file.
"""
data = [config.to_dict() for config in configurations]
with open(filename, 'w') as f:
json.dump(data, f)
[docs]
def load_configurations(filename: str) -> list[Configuration]:
"""Load configurations from a JSON file.
Args:
filename: Path to input file.
Returns:
List of Configuration objects.
"""
with open(filename) as f:
data = json.load(f)
return [Configuration.from_dict(d) for d in data]