Source code for aenet.geometry.transformations.cell
"""Cell (lattice) transformations.
This module contains transformations that primarily modify the unit cell
(lattice vectors) of a periodic
:class:`~aenet.geometry.structure.AtomicStructure`.
Notes
-----
All transformations in this module require periodic boundary conditions
(``structure.pbc`` must be True).
"""
import logging
from collections.abc import Iterator
from typing import Optional
import numpy as np
from .. import utils as geom_utils
from ..structure import AtomicStructure
from .base import TransformationABC
logger = logging.getLogger(__name__)
[docs]
class CellVolumeTransformation(TransformationABC):
"""Uniformly scale unit cell vectors.
This transformation generates structures with different volumes by
uniformly scaling the lattice vectors. Fractional coordinates remain
unchanged, Cartesian coordinates are recomputed from the updated
cell, and copied energy/force labels are cleared because they are
no longer valid for the deformed geometry.
Physical/engineering meaning
----------------------------
This is a hydrostatic (isotropic) scaling of the lattice. In a
thermodynamic context it resembles sampling different volumes (e.g.,
equation of state fits), but without relaxing internal degrees of
freedom. It can be used, for example, to calculate the bulk modulus.
Parameters
----------
min_percent : float, optional
Minimum percentage change from original lattice scaling
(default: -5.0)
max_percent : float, optional
Maximum percentage change from original lattice scaling
(default: 5.0)
steps : int, optional
Number of scaling steps (default: 5)
"""
[docs]
def __init__(
self,
min_percent: float = -5.0,
max_percent: float = 5.0,
steps: int = 5,
):
if steps <= 0:
raise ValueError(f"steps must be positive, got {steps}")
if min_percent >= max_percent:
raise ValueError(
f"min_percent ({min_percent}) must be less than "
f"max_percent ({max_percent})"
)
self.min_percent = min_percent
self.max_percent = max_percent
self.steps = steps
[docs]
def apply_transformation(
self,
structure: AtomicStructure,
**kwargs,
) -> Iterator[AtomicStructure]:
"""Apply uniform volume scaling to structure.
Yields structures with different cell volumes while preserving
fractional coordinates.
"""
if not structure.pbc:
raise ValueError(
"CellVolumeTransformation requires periodic structure"
)
def _gen() -> Iterator[AtomicStructure]:
min_scale = 1.0 + self.min_percent / 100.0
max_scale = 1.0 + self.max_percent / 100.0
scale_factors = np.linspace(min_scale, max_scale, self.steps)
for scale in scale_factors:
scaled = structure.copy()
scaled.update_cell(
structure.avec[-1] * scale,
preserve="fractional",
)
yield scaled
return _gen()
[docs]
class CellTransformationMatrix(TransformationABC):
"""General integer cell/basis transformation using a 3x3 matrix.
This wraps :func:`aenet.geometry.utils.transform_cell`.
The transformation matrix ``T`` is applied as::
A' = T · A
where rows of ``A`` are the lattice vectors. Atomic coordinates are
transformed accordingly and redundant periodic images are created
(or removed) so that the atom count is consistent with the volume
scaling factor.
Purpose
-------
This is a *change of lattice basis* that does not actually alter
the atomic structure, but rather how it is represented. This is
useful for generating:
- different supercell shapes for the same primitive cell
- commensurate cells for defects, phonons, or disordered sampling
- as a first step when constructing surface slabs from bulk structures
Parameters
----------
T : array_like shape (3, 3)
Transformation matrix. In practice this should be integer-valued
for supercell / basis transformations.
sort : int or None, optional
Sorting behavior passed to ``utils.transform_cell`` (default: 2).
``2`` sorts by fractional z, then by type. ``None`` disables the
coordinate-based sort (type sort still happens).
Notes
-----
This currently relies on utilities that expect *fractional*
coordinates. We convert AtomicStructure Cartesian coordinates to
fractional, apply the transformation, then convert back to Cartesian.
"""
[docs]
def __init__(self, T, sort: Optional[int] = 2):
self.T = np.array(T, dtype=float)
if self.T.shape != (3, 3):
raise ValueError(f"T must be shape (3,3), got {self.T.shape}")
self.sort = sort
[docs]
def apply_transformation(
self,
structure: AtomicStructure,
**kwargs,
) -> Iterator[AtomicStructure]:
"""Apply cell basis transformation to structure.
Yields a single transformed structure with new cell and coordinates.
"""
if not structure.pbc:
raise ValueError(
"CellTransformationMatrix requires a periodic structure"
)
# AtomicStructure stores Cartesian coordinates; utils.transform_cell
# expects fractional coordinates.
avec = structure.avec[-1]
frac = structure.coords[-1] @ structure.bvec[-1]
# utils.transform_cell expects types in sortable form; AtomicStructure
# stores .types as list/ndarray.
types = np.asarray(structure.types)
avec_T, coords_frac_T, types_T = geom_utils.transform_cell(
avec=avec,
coords=frac,
types=types,
T=self.T,
sort=self.sort,
)
transformed = structure.copy()
transformed.avec[-1] = np.array(avec_T, dtype=float)
transformed.bvec[-1] = np.linalg.inv(transformed.avec[-1])
transformed.types = list(types_T)
transformed.coords[-1] = np.array(coords_frac_T) @ transformed.avec[-1]
def _gen() -> Iterator[AtomicStructure]:
yield transformed
return _gen()