"""Configuration for cluster-on-surface (adsorbate + slab) workflows."""
from __future__ import annotations
from dataclasses import dataclass
from ase import Atoms
from scgo.initialization.initialization_config import CONNECTIVITY_FACTOR
from scgo.surface.pbc import normalize_slab_pbc
from scgo.utils.logging import get_logger
from scgo.utils.validation import validate_positive
logger = get_logger(__name__)
[docs]
@dataclass(frozen=True)
class SurfaceSystemConfig:
"""Describe a fixed slab plus a movable adsorbate cluster for GA.
Atom ordering in combined systems must be ``slab`` atoms first, then the
``len(composition)`` adsorbate atoms (matching ASE GA patches: ``n_top``
trailing atoms are optimized). Pass the same instance to TS search
(``get_ts_search_params(..., surface_config=...)`` or
``run_ts_search(..., ts_params=..., system_type=..., surface_config=...)``) so NEB uses the
identical slab ``FixAtoms`` policy as local relaxation.
At runtime, :func:`scgo.surface.validation.validate_surface_config_slab_prefix`
checks that combined systems still begin with ``slab``'s symbols in order.
**Slab motion during local relaxation** (three common modes; ``L`` is the
number of distinct slab coordinate layers along ``surface_normal_axis``):
================================ ============================================
Intent Settings
================================ ============================================
Full slab frozen ``fix_all_slab_atoms=True`` (default)
Frozen except top N slab layers ``fix_all_slab_atoms=False`` and either
``n_relax_top_slab_layers=N``, or
``n_fix_bottom_slab_layers=L - N``
Nothing on the slab frozen ``fix_all_slab_atoms=False``,
``n_fix_bottom_slab_layers=None``,
``n_relax_top_slab_layers=None``
================================ ============================================
For a typical slab with vacuum along ``z``, the adsorbate sits on the
high-``z`` side; fixing the bottom ``L - N`` distinct layers is the
same as leaving only the top ``N`` layers free to relax.
Do not set ``n_relax_top_slab_layers`` together with
``n_fix_bottom_slab_layers``, or together with ``fix_all_slab_atoms=True``.
"""
slab: Atoms
adsorption_height_min: float = 1.2
adsorption_height_max: float = 3.0
surface_normal_axis: int = 2
fix_all_slab_atoms: bool = True
n_fix_bottom_slab_layers: int | None = None
n_relax_top_slab_layers: int | None = None
comparator_use_mic: bool = False
cluster_init_vacuum: float = 8.0
init_mode: str = "smart"
max_placement_attempts: int = 200
structure_connectivity_factor: float = CONNECTIVITY_FACTOR
def __post_init__(self) -> None:
# Copy slab so post-init pbc adjustments do not mutate a shared Atoms.
object.__setattr__(self, "slab", self.slab.copy())
slab = self.slab
if self.surface_normal_axis not in (0, 1, 2):
raise ValueError("surface_normal_axis must be 0, 1, or 2")
validate_positive(
"adsorption_height_min", self.adsorption_height_min, strict=True
)
validate_positive(
"adsorption_height_max", self.adsorption_height_max, strict=True
)
validate_positive(
"structure_connectivity_factor",
self.structure_connectivity_factor,
strict=True,
)
if self.adsorption_height_min > self.adsorption_height_max:
raise ValueError(
"adsorption_height_min must be <= adsorption_height_max, "
f"got {self.adsorption_height_min} and {self.adsorption_height_max}"
)
if len(slab) == 0:
raise ValueError("slab must contain at least one atom")
if not any(slab.pbc):
raise ValueError("Slab must have at least one periodic dimension.")
normalize_slab_pbc(slab, surface_normal_axis=self.surface_normal_axis)
vacuum_length = slab.cell.lengths()[self.surface_normal_axis]
if vacuum_length < 10.0:
logger.warning(
f"Slab vacuum size ({vacuum_length:.2f} A) on axis {self.surface_normal_axis} "
"might be too small to prevent periodic interaction.",
)
if (
self.n_fix_bottom_slab_layers is not None
and self.n_fix_bottom_slab_layers < 1
):
raise ValueError("n_fix_bottom_slab_layers must be >= 1 when set")
if (
self.n_relax_top_slab_layers is not None
and self.n_relax_top_slab_layers < 1
):
raise ValueError("n_relax_top_slab_layers must be >= 1 when set")
if self.fix_all_slab_atoms and self.n_relax_top_slab_layers is not None:
raise ValueError(
"n_relax_top_slab_layers is incompatible with fix_all_slab_atoms=True"
)
if (
self.n_fix_bottom_slab_layers is not None
and self.n_relax_top_slab_layers is not None
):
raise ValueError(
"set at most one of n_fix_bottom_slab_layers and "
"n_relax_top_slab_layers"
)
[docs]
def make_surface_config(
slab: Atoms,
*,
adsorption_height_min: float = 2.0,
adsorption_height_max: float = 3.5,
fix_all_slab_atoms: bool = True,
comparator_use_mic: bool = True,
max_placement_attempts: int = 500,
) -> SurfaceSystemConfig:
"""Build a :class:`SurfaceSystemConfig` from an arbitrary ASE slab."""
return SurfaceSystemConfig(
slab=slab.copy(),
adsorption_height_min=adsorption_height_min,
adsorption_height_max=adsorption_height_max,
fix_all_slab_atoms=fix_all_slab_atoms,
comparator_use_mic=comparator_use_mic,
max_placement_attempts=max_placement_attempts,
)
[docs]
def describe_surface_config(cfg: SurfaceSystemConfig) -> str:
"""Summarize key surface/deposition fields for logging and provenance."""
return (
f"SurfaceSystemConfig(n_slab={len(cfg.slab)}, "
f"adsorption_height=({cfg.adsorption_height_min}, {cfg.adsorption_height_max}), "
f"surface_normal_axis={cfg.surface_normal_axis}, "
f"fix_all_slab_atoms={cfg.fix_all_slab_atoms}, "
f"n_fix_bottom_slab_layers={cfg.n_fix_bottom_slab_layers}, "
f"n_relax_top_slab_layers={cfg.n_relax_top_slab_layers}, "
f"comparator_use_mic={cfg.comparator_use_mic}, "
f"cluster_init_vacuum={cfg.cluster_init_vacuum}, init_mode={cfg.init_mode!r}, "
f"max_placement_attempts={cfg.max_placement_attempts})"
)