from ..Primatom import GAM_Atom , GAM_Bond
import numpy as np
from ase import Atoms
from ase.build import bulk
from ase.cluster.icosahedron import Icosahedron
from ase.cluster.wulff import wulff_construction
from ase.visualize import view
import matplotlib.pyplot as plt
from ase.io import write
from ase.io.pov import get_bondpairs
import random
from typing import List, Tuple, Dict, Optional, Union , Any
from ase.atom import Atom
from ase.data import atomic_numbers, covalent_radii
from ase.constraints import FixAtoms
import itertools
import copy
[docs]
class Nano_ZeroD_Builder:
"""
High-level atomistic builder for 0D nanostructures.
The `Nano_ZeroD_Builder` class provides a unified interface for constructing
atomistic models of zero-dimensional (0D) nanostructures, including:
- **Quantum dots** (semiconducting nanocrystals with quantum confinement)
- **Nanoclusters** (small metallic or covalent atomic clusters)
- **Nanoparticles** (larger crystalline particles, optionally faceted using Wulff construction)
This class supports both geometric and crystallographic control, allowing
for the creation of realistic nanoscale systems based on their lattice type,
surface facets, and atomic arrangement. The resulting structures can be used
as input for DFT, MD, or ML simulations.
Features
--------
- Build 0D nanostructures from bulk crystals or predefined motifs
- Support for FCC, BCC, HCP, diamond, zincblende, and related lattices
- Integration with ASE (Atomic Simulation Environment)
- Alloy, defect, and strain engineering
- Ligand surface functionalization
- Core–shell nanoparticle generation
- Randomized variation generation for dataset expansion
Parameters
----------
material : str
Chemical symbol of the primary element (e.g., 'Au', 'CdSe').
structure_type : {'quantum_dot', 'nanocluster', 'nanoparticle'}
Type of nanostructure to build.
size : float
Size parameter — interpreted as:
- Radius (Ã…) for quantum dots or nanoparticles
- Number of atomic shells for nanoclusters
crystal_structure : str, optional
Underlying lattice type. Defaults to `'fcc'`.
lattice_constant : float, optional
Lattice constant in Ångströms. Required for bulk-based generation.
vacuum : float, optional
Vacuum spacing around the nanostructure (Ã…). Default is 10.0 Ã….
surfaces : list of tuple[int], optional
Miller indices of the surfaces for Wulff construction (only for nanoparticles).
surface_energies : list of float, optional
Surface energies (J/m²) corresponding to `surfaces`.
noshells : int, optional
Number of shells for nanocluster structures (if not using `size`).
radius_type : {'sphere'}, optional
Defines the nanoparticle geometry. Currently only `'sphere'` is supported.
**kwargs : dict
Additional keyword arguments passed to ASE structure constructors.
Attributes
----------
ASE_atoms : ase.Atoms
The underlying ASE `Atoms` object representing the structure.
atoms : list[GAM_Atom]
A list of GAM_Atom objects for GAM-Lab compatibility.
bonds : list[GAM_Bond]
List of identified bonds (if applicable).
meta : dict
Metadata dictionary containing build parameters and history.
structure_type : str
Type of structure ('quantum_dot', 'nanocluster', 'nanoparticle').
radius_type : str
Shape type used for spherical generation.
Methods
-------
create_alloy(elements, compositions)
Randomly substitutes atomic species to generate alloyed structures.
add_defects(defect_type, concentration, elements=None)
Introduce vacancies, interstitials, or substitutional defects.
create_variations(base_structure, size_range, n_structures, **kwargs)
Generate multiple structural variations within a size range.
apply_strain(strain, direction='xyz')
Apply uniaxial or isotropic strain to the structure.
modify_surface(ligands, coverage=1.0)
Add ligand atoms to surface sites for surface modification.
create_core_shell(core_material, shell_material, shell_thickness, shell_lattice_constant=None)
Create a core–shell nanostructure by wrapping the core with another material.
translate(dx, dy, dz=0.0)
Translate all atoms by a specified vector.
rotate(angle_deg, about_center=True)
Rotate the structure about the z-axis or its center of mass.
copy()
Create a deep copy of the structure.
get_atoms()
Return a copy of the GAM_Atom list.
get_positions()
Return atomic positions as a list of (x, y, z) tuples.
get_elements()
Return a list of atomic symbols.
_to_GAM_Atoms(atoms)
Internal conversion from ASE atoms to GAM_Atom objects.
Notes
-----
- The generated nanostructures can serve as initial geometries for
relaxation, energy minimization, or electronic structure calculations.
- Surface atoms are automatically centered with optional vacuum padding.
- The Wulff construction minimizes total surface energy for given facets.
- Alloying, defect creation, and surface modification are stochastic; use a fixed random seed for reproducibility.
- The core–shell builder assumes isotropic growth; anisotropic extension can be implemented by modifying the shell generator.
Examples
--------
>>> from pygamlab import Nano_ZeroD_Builder
>>> # Generate a gold nanocluster with 5 atomic shells
>>> builder = Nano_ZeroD_Builder(material='Au', structure_type='nanocluster', size=5)
>>> atoms = builder.ASE_atoms
>>> builder.to_xyz('Au_cluster.xyz')
>>> # Build a 3 nm CdSe quantum dot
>>> qd = Nano_ZeroD_Builder(material='CdSe', structure_type='quantum_dot',
... crystal_structure='zincblende',
... lattice_constant=6.08, size=15.0)
>>> qd.apply_strain(0.02)
>>> qd.to_xyz('CdSe_QD_strained.xyz')
>>> # Create a core–shell Ag@Au nanoparticle
>>> cs = Nano_ZeroD_Builder(material='Ag', structure_type='nanoparticle',
... size=25.0, surfaces=[(1, 1, 1), (1, 0, 0)],
... surface_energies=[1.2, 1.5])
>>> cs.create_core_shell(core_material='Ag', shell_material='Au', shell_thickness=5.0)
>>> cs.to_xyz('AgAu_core_shell.xyz')
"""
def __init__(self, material: str, structure_type: str,
size: float,
crystal_structure: str = "fcc",
lattice_constant: float = None,
vacuum: float = 10.0,
surfaces=None,
surface_energies=None,
noshells: int = None,
radius_type: str = "sphere",
**kwargs):
"""
Initialize the builder.
:param material: Chemical symbol, e.g., "Au", "CdSe"
:param lattice_constant: Lattice constant in Ã… (optional)
:param crystal_structure: 'fcc', 'bcc', 'hcp', 'diamond', 'zincblende', etc.
:param structure_type: 'quantum_dot', 'nanocluster', or 'nanoparticle'
:param size: Radius (Ã…) for quantum_dot/nanoparticle, or number of shells for nanocluster
:param vacuum: Vacuum spacing in Ã…
:param surfaces: List of Miller indices for Wulff construction (nanoparticles)
:param surface_energies: Corresponding surface energies (J/m²)
:param noshells: Number of atomic shells for nanocluster (if not using size)
:param radius_type: Shape for quantum_dot/nanoparticle: 'sphere' (currently supported)
:param kwargs: Additional ASE builder arguments
:return: ASE Atoms object
"""
self.material = material
self.lattice_constant = lattice_constant
self.crystal_structure = crystal_structure
self.atoms: List[GAM_Atom] = []
self.bonds: List[GAM_Bond] = []
self.structure_type=structure_type
self.size=size
self.surfaces=surfaces
self.surface_energies=surface_energies
self.noshells=noshells
self.radius_type=radius_type
self.meta={'material':material ,
'lattice_constant':lattice_constant ,
'crystal_structure': crystal_structure,
'structure_type':structure_type ,
'size': size,
'surfaces':surfaces ,
'surface_energies': surface_energies,
'noshells':noshells ,
'radius_type': radius_type}
structure_type = structure_type.lower()
if structure_type == "nanocluster":
shells = noshells if noshells is not None else int(size)
atoms = Icosahedron(self.material, noshells=shells)
self.atoms=self._to_GAM_Atoms(atoms)
elif structure_type == "quantum_dot":
# Build bulk lattice first
atoms_bulk = bulk(self.material,
crystalstructure=self.crystal_structure,
a=self.lattice_constant)
positions = atoms_bulk.get_positions()
center = positions.mean(axis=0)
mask = np.linalg.norm(positions - center, axis=1) <= size
atoms = atoms_bulk[mask]
self.atoms=atoms
elif structure_type == "nanoparticle":
if surfaces is None or surface_energies is None:
raise ValueError("For nanoparticle Wulff construction, provide surfaces and surface_energies.")
atoms = wulff_construction(self.material,
surfaces, surface_energies,
size, # number of atoms approx
self.crystal_structure,
rounding="above")
#here changed
#self.atoms=atoms
else:
raise ValueError(f"Unknown 0D structure type: {structure_type}")
atoms.center(vacuum=vacuum)
#here changed
#self.atoms.center(vacuum=vacuum)
self.ASE_atoms = atoms
self.atoms=self._to_GAM_Atoms(atoms)
[docs]
def create_alloy(self,elements: List[str],
compositions: List[float]) -> GAM_Atom:
"""
Create an alloy by randomly substituting atoms.
:param base_atoms: Base structure
:param elements: List of elements to include
:param compositions: Corresponding atomic fractions
:return: Alloyed structure
"""
if len(elements) != len(compositions) or abs(sum(compositions) - 1.0) > 1e-6:
raise ValueError("Invalid composition specification")
atoms = self.ASE_atoms
n_atoms = len(self.atoms)
indices = list(range(n_atoms))
random.shuffle(indices)
current_idx = 0
for element, comp in zip(elements, compositions):
n_element = int(comp * n_atoms)
for idx in indices[current_idx:current_idx + n_element]:
atoms[idx].symbol = element
current_idx += n_element
self.ASE_atoms=atoms
self.atoms=self._to_GAM_Atoms(self.ASE_atoms)
#return atoms
#return self.atoms
[docs]
def add_defects(self, defect_type: str,
concentration: float, elements: List[str] = None) -> GAM_Atom:
"""
Add defects to the structure.
:param defect_type: 'vacancy', 'interstitial', or 'substitution'
:param concentration: Defect concentration (0-1)
:param elements: Elements for substitution/interstitial
:return: Structure with defects
"""
if concentration < 0 or concentration > 1:
raise ValueError("Concentration must be between 0 and 1")
if defect_type not in ['vacancy', 'interstitial', 'substitution']:
raise ValueError("Defect type must be 'vacancy', 'interstitial', or 'substitution'")
if defect_type in ['substitution', 'interstitial'] and not elements:
raise ValueError(f"Elements must be provided for {defect_type} defects")
defected_atoms = self.ASE_atoms.copy()
n_atoms = len(self.ASE_atoms)
n_defects = int(concentration * n_atoms)
# Ensure we don't try to remove more atoms than we have
if defect_type == 'vacancy' and n_defects >= n_atoms:
raise ValueError(f"Cannot remove {n_defects} atoms from structure with only {n_atoms} atoms")
if defect_type == 'vacancy':
# Randomly select atoms to remove
indices_to_remove = random.sample(range(n_atoms), n_defects)
# Create new atoms object without the selected indices
remaining_indices = [i for i in range(n_atoms) if i not in indices_to_remove]
defected_atoms = self.ASE_atoms[remaining_indices]
elif defect_type == 'substitution':
# Randomly select atoms to substitute
indices = random.sample(range(n_atoms), n_defects)
for idx in indices:
defected_atoms[idx].symbol = random.choice(elements)
elif defect_type == 'interstitial':
# Add interstitial atoms at random positions
positions = self.ASE_atoms.get_positions()
# Get the bounding box of existing atoms
min_pos = np.min(positions, axis=0)
max_pos = np.max(positions, axis=0)
for _ in range(n_defects):
# Generate random position within the bounding box
pos = min_pos + np.random.rand(3) * (max_pos - min_pos)
# Add some random offset to avoid overlapping with existing atoms
pos += (np.random.rand(3) - 0.5) * 2.0 # ±1 Å random offset
new_atom = Atom(random.choice(elements), pos)
defected_atoms.append(new_atom)
self.ASE_atoms = defected_atoms
self.atoms = self._to_GAM_Atoms(self.ASE_atoms)
#remove.....
[docs]
def create_variations(self, base_structure: str, size_range: Tuple[float, float],
n_structures: int, **kwargs) -> List[GAM_Atom]:
"""
Create multiple variations of a structure.
:param base_structure: Structure type
:param size_range: (min_size, max_size)
:param n_structures: Number of structures to generate
:return: List of structures
"""
structures = []
sizes = np.linspace(size_range[0], size_range[1], n_structures)
for size in sizes:
atoms = self.create(base_structure, size=size, **kwargs)
structures.append(atoms)
return structures
[docs]
def apply_strain(self, strain: float,
direction: str = 'xyz') -> GAM_Atom:
"""
Apply strain to the structure.
:param atoms: Input structure
:param strain: Strain factor (positive for tension, negative for compression)
:param direction: Direction(s) to apply strain
:return: Strained structure
"""
strained_atoms = self.ASE_atoms.copy()
cell = self.ASE_atoms.get_cell()
for i, axis in enumerate(['x', 'y', 'z']):
if axis in direction:
cell[i] *= (1 + strain)
strained_atoms.set_cell(cell, scale_atoms=True)
self.ASE_atoms=strained_atoms
self.atoms=self._to_GAM_Atoms(self.ASE_atoms)
#return strained_atoms
#return self.atoms
[docs]
def modify_surface(self, ligands: List[str],
coverage: float = 1.0) -> GAM_Atom:
"""
Modify surface with ligands.
:param atoms: Input structure
:param ligands: List of ligand elements
:param coverage: Surface coverage (0-1)
:return: Surface modified structure
"""
modified = self.ASE_atoms.copy()
positions = self.ASE_atoms.get_positions()
center = positions.mean(axis=0)
# Find surface atoms (simple distance-based criterion)
distances = np.linalg.norm(positions - center, axis=1)
max_dist = np.max(distances) * 0.9
surface_indices = np.where(distances >= max_dist)[0]
# Add ligands
n_ligands = int(len(surface_indices) * coverage)
selected_indices = random.sample(list(surface_indices), n_ligands)
for idx in selected_indices:
pos = positions[idx]
direction = pos - center
direction = direction / np.linalg.norm(direction)
ligand_pos = pos + direction * 2.0 # 2 Ã… bond length
modified.append(Atom(random.choice(ligands), ligand_pos))
self.ASE_atoms=modified
self.atoms=self._to_GAM_Atoms(self.ASE_atoms)
#return modified
#return self.atoms
[docs]
def create_core_shell(self, core_material: str, shell_material: str,
shell_thickness: float, shell_lattice_constant: float = None) -> GAM_Atom:
"""
Create a core-shell nanostructure.
:param core_material: Material for the core (e.g., "Au", "CdSe")
:param shell_material: Material for the shell (e.g., "Ag", "CdS")
:param shell_thickness: Thickness of the shell in Ã…
:param shell_lattice_constant: Lattice constant for shell material (optional)
:return: Core-shell structure
"""
# Get current structure as reference
current_positions = self.ASE_atoms.get_positions()
center = np.mean(current_positions, axis=0)
# Calculate distances from center
distances = np.linalg.norm(current_positions - center, axis=1)
max_core_distance = np.max(distances)
# Create new atoms object starting with core
core_shell_atoms = self.ASE_atoms.copy()
# Determine shell lattice constant
if shell_lattice_constant is None:
# Use default values for common materials
default_lattice_constants = {
"Ag": 4.09, "Au": 4.08, "Cu": 3.61, "Pt": 3.92,
"CdS": 5.83, "CdSe": 6.08, "ZnS": 5.41, "ZnSe": 5.67
}
shell_lattice_constant = default_lattice_constants.get(shell_material, 4.0)
# Create shell by adding atoms around the core
shell_radius = max_core_distance + shell_thickness
# Generate shell atoms using a simple cubic grid approach
# This is a simplified approach - for more accurate structures,
# you might want to use proper crystal structure generation
# Calculate grid spacing based on shell material
grid_spacing = shell_lattice_constant / 2.0
# Generate grid points for shell
x_range = np.arange(center[0] - shell_radius, center[0] + shell_radius + grid_spacing, grid_spacing)
y_range = np.arange(center[1] - shell_radius, center[1] + shell_radius + grid_spacing, grid_spacing)
z_range = np.arange(center[2] - shell_radius, center[2] + shell_radius + grid_spacing, grid_spacing)
shell_atoms_added = 0
for x in x_range:
for y in y_range:
for z in z_range:
pos = np.array([x, y, z])
distance_from_center = np.linalg.norm(pos - center)
# Check if position is in shell region (between core surface and shell surface)
if (distance_from_center > max_core_distance and
distance_from_center <= shell_radius):
# Check if position is not too close to existing atoms
min_distance_to_existing = np.min(np.linalg.norm(current_positions - pos, axis=1))
if min_distance_to_existing > 1.5: # Minimum 1.5 Ã… separation
new_atom = Atom(shell_material, pos)
core_shell_atoms.append(new_atom)
shell_atoms_added += 1
# Update the structure
self.ASE_atoms = core_shell_atoms
self.atoms = self._to_GAM_Atoms(self.ASE_atoms)
# Update metadata
self.meta['core_material'] = core_material
self.meta['shell_material'] = shell_material
self.meta['shell_thickness'] = shell_thickness
self.meta['shell_lattice_constant'] = shell_lattice_constant
#return self.atoms
[docs]
def translate(self, dx: float, dy: float, dz: float = 0.0):
"""Translate all atoms by (dx, dy, dz)."""
for atom in self.atoms:
atom.x += dx
atom.y += dy
atom.z += dz
# Update ASE atoms positions as well
for i, atom in enumerate(self.ASE_atoms):
atom.position += np.array([dx, dy, dz])
# Update origin in metadata
ox, oy = self.origin
self.origin = (ox + dx, oy + dy)
self.meta["origin"] = self.origin
#return self.atoms
[docs]
def rotate(self, angle_deg: float, about_center: bool = True):
"""Rotate structure about z-axis."""
angle_rad = np.radians(angle_deg)
cos_a, sin_a = np.cos(angle_rad), np.sin(angle_rad)
# Get center of mass
positions = np.array([(atom.x, atom.y, atom.z) for atom in self.atoms])
center = np.mean(positions, axis=0)
if about_center:
# Rotate about center of mass
for atom in self.atoms:
x_rel = atom.x - center[0]
y_rel = atom.y - center[1]
atom.x = center[0] + x_rel * cos_a - y_rel * sin_a
atom.y = center[1] + x_rel * sin_a + y_rel * cos_a
else:
# Rotate about (0, 0)
for atom in self.atoms:
x_old, y_old = atom.x, atom.y
atom.x = x_old * cos_a - y_old * sin_a
atom.y = x_old * sin_a + y_old * cos_a
# Update ASE atoms positions as well
for i, atom in enumerate(self.ASE_atoms):
atom.position[0] = self.atoms[i].x
atom.position[1] = self.atoms[i].y
atom.position[2] = self.atoms[i].z
# Update rotation in metadata
self.rotation = (self.rotation + angle_deg) % 360
self.meta["rotation"] = self.rotation
#return self.atoms
[docs]
def copy(self):
"""Create a deep copy of the structure."""
return copy.deepcopy(self.atoms)
[docs]
def get_atoms(self):
"""
Return a copy of the atoms in this molecule.
Returns
-------
List[str]
A list of atom symbols.
"""
return copy.deepcopy(self.atoms)
[docs]
def get_positions(self) -> List[Tuple[float, float, float]]:
"""Return positions of all atoms as a list of (x, y, z) tuples."""
return [atom.get_position() for atom in self.atoms]
[docs]
def get_elements(self) -> List[str]:
"""Return list of element symbols in the molecule."""
return [atom.element for atom in self.atoms]
[docs]
def _to_GAM_Atoms(self,atoms):
return [
GAM_Atom(
id=atom.index,
element=atom.symbol,
x=atom.position[0],
y=atom.position[1],
z=atom.position[2]
)
for atom in atoms
]