Source code for PyGamLab.structures.GAM_architectures.GAM_nano_particles

from ..Primatom import GAM_Atom , GAM_Bond 
#from gam_atom import GAM_Atom , GAM_Bond
#from gam_atom import REFERENCE_PERIODIC_TABLE_CONFIG
import copy
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import random
from typing import Dict, List, Tuple, Optional, Any, Union
import json
from dataclasses import dataclass, asdict
from ase import Atoms


#from ..Primatom.gam_atom import REFERENCE_PERIODIC_TABLE_CONFIG

REFERENCE_PERIODIC_TABLE_CONFIG = {
    'H':  {'number': 1,  'mass': 1.008,   'config': '1s1',                 'radius': 0.53, 'lattice_constant': None},
    'He': {'number': 2,  'mass': 4.003,   'config': '1s2',                 'radius': 0.31, 'lattice_constant': None},
    'Li': {'number': 3,  'mass': 6.941,   'config': '[He] 2s1',            'radius': 1.67, 'lattice_constant': 3.49},  # bcc
    'Be': {'number': 4,  'mass': 9.012,   'config': '[He] 2s2',            'radius': 1.12, 'lattice_constant': 2.29},  # hcp
    'B':  {'number': 5,  'mass': 10.811,  'config': '[He] 2s2 2p1',        'radius': 0.85, 'lattice_constant': 8.73},
    'C':  {'number': 6,  'mass': 12.011,  'config': '[He] 2s2 2p2',        'radius': 0.67, 'lattice_constant': 3.57},  # diamond
    'N':  {'number': 7,  'mass': 14.007,  'config': '[He] 2s2 2p3',        'radius': 0.56, 'lattice_constant': None},
    'O':  {'number': 8,  'mass': 15.999,  'config': '[He] 2s2 2p4',        'radius': 0.48, 'lattice_constant': None},
    'F':  {'number': 9,  'mass': 18.998,  'config': '[He] 2s2 2p5',        'radius': 0.42, 'lattice_constant': None},
    'Ne': {'number': 10, 'mass': 20.180,  'config': '[He] 2s2 2p6',        'radius': 0.38, 'lattice_constant': None},
    'Na': {'number': 11, 'mass': 22.990,  'config': '[Ne] 3s1',            'radius': 1.90, 'lattice_constant': 4.23},  # bcc
    'Mg': {'number': 12, 'mass': 24.305,  'config': '[Ne] 3s2',            'radius': 1.45, 'lattice_constant': 3.21},  # hcp
    'Al': {'number': 13, 'mass': 26.982,  'config': '[Ne] 3s2 3p1',        'radius': 1.18, 'lattice_constant': 4.05},  # fcc
    'Si': {'number': 14, 'mass': 28.085,  'config': '[Ne] 3s2 3p2',        'radius': 1.11, 'lattice_constant': 5.43},  # diamond
    'P':  {'number': 15, 'mass': 30.974,  'config': '[Ne] 3s2 3p3',        'radius': 1.06, 'lattice_constant': 7.17},
    'S':  {'number': 16, 'mass': 32.06,   'config': '[Ne] 3s2 3p4',        'radius': 1.02, 'lattice_constant': None},
    'Cl': {'number': 17, 'mass': 35.453,  'config': '[Ne] 3s2 3p5',        'radius': 0.99, 'lattice_constant': None},
    'Ar': {'number': 18, 'mass': 39.948,  'config': '[Ne] 3s2 3p6',        'radius': 0.95, 'lattice_constant': None},
    'K':  {'number': 19, 'mass': 39.098,  'config': '[Ar] 4s1',            'radius': 2.43, 'lattice_constant': 5.23},  # bcc
    'Ca': {'number': 20, 'mass': 40.078,  'config': '[Ar] 4s2',            'radius': 1.94, 'lattice_constant': 5.58},  # fcc
    'Fe': {'number': 26, 'mass': 55.845,  'config': '[Ar] 3d6 4s2',        'radius': 1.56, 'lattice_constant': 2.87},  # bcc
    'Cu': {'number': 29, 'mass': 63.546,  'config': '[Ar] 3d10 4s1',       'radius': 1.45, 'lattice_constant': 3.61},  # fcc
    'Zn': {'number': 30, 'mass': 65.38,   'config': '[Ar] 3d10 4s2',       'radius': 1.42, 'lattice_constant': 2.66},  # hcp
    'Ag': {'number': 47, 'mass': 107.868, 'config': '[Kr] 4d10 5s1',       'radius': 1.65, 'lattice_constant': 4.09},  # fcc
    'Cd': {'number': 48, 'mass': 112.411, 'config': '[Kr] 4d10 5s2',       'radius': 1.55, 'lattice_constant': 2.98},  # hcp
    'W':  {'number': 74, 'mass': 183.84,  'config': '[Xe] 4f14 5d4 6s2',   'radius': 1.35, 'lattice_constant': 3.16},  # bcc
    'Pt': {'number': 78, 'mass': 195.084, 'config': '[Xe] 4f14 5d9 6s1',   'radius': 1.39, 'lattice_constant': 3.92},  # fcc
    'Au': {'number': 79, 'mass': 196.967, 'config': '[Xe] 4f14 5d10 6s1',  'radius': 1.44, 'lattice_constant': 4.08},  # fcc
    'Ta': {'number': 73, 'mass': 180.948, 'config': '[Xe] 4f14 5d3 6s2',   'radius': 1.43, 'lattice_constant': 3.30},  # bcc
    'Nb': {'number': 41, 'mass': 92.906,  'config': '[Kr] 4d4 5s1',        'radius': 1.46, 'lattice_constant': 3.30},  # bcc
    'Mo': {'number': 42, 'mass': 95.94,   'config': '[Kr] 4d5 5s1',        'radius': 1.39, 'lattice_constant': 3.15},  # bcc
}


@dataclass
class AtomicData:
    """Store atomic data for different elements."""
    
    # Atomic radii in Angstroms (covalent radii)
    ATOMIC_RADII={element : properties['radius'] for element,properties in REFERENCE_PERIODIC_TABLE_CONFIG.items()}

    
    # Lattice parameters in Angstroms
    LATTICE_CONSTANTS = {element : properties['lattice_constant'] for element,properties in REFERENCE_PERIODIC_TABLE_CONFIG.items()}



[docs] class Nanoparticle_Generator: """ General-purpose nanoparticle structure generator for atomistic simulations. The `Nanoparticle_Generator` class provides a flexible and modular tool for constructing atomic-scale models of nanoparticles with arbitrary geometries, materials, and orientations. It supports the creation of finite clusters, spherical or polyhedral nanoparticles, and core–shell architectures with user-defined crystallography and dimensions. This class can be used to prepare atomic coordinates for density functional theory (DFT), molecular dynamics (MD), or machine learning (ML) workflows. It is particularly suited for generating input structures for nanomaterials research involving metals, semiconductors, and hybrid systems. Supported features include: - Generation of finite nanoparticles with specified radius, shape, and lattice - Core–shell or multi-layered structures - Selection of crystalline structure (FCC, BCC, HCP, etc.) - Randomized orientation and rotation of particles - Atom-based filtering for specific elements or sublattices - Export to standard formats such as XYZ or ASE-compatible structures The implementation can be extended to support: - Nanorods, nanowires, and anisotropic morphologies - Surface relaxation or reconstruction - Doping, vacancies, and alloy generation - Integration with LAMMPS, VASP, or ASE simulation interfaces Parameters ---------- element : str Chemical symbol of the atomic species (e.g., 'Au', 'Ag', 'Si'). lattice_constant : float Lattice constant of the crystal in Ångströms. structure_type : {'FCC', 'BCC', 'HCP', 'diamond', ...} Crystal structure used for atomic packing. radius : float Radius of the nanoparticle in Ångströms. center : tuple of float, optional Cartesian coordinates of the nanoparticle center. Default is (0.0, 0.0, 0.0). core_element : str, optional If specified, the core region of the nanoparticle will use this element. core_radius : float, optional Radius of the core region (Å) when building core–shell particles. random_orientation : bool, optional If True, applies a random rotation to the generated particle. Default is False. vacuum : float, optional Optional vacuum spacing in Ångströms for periodic export. Default is 10.0 Å. save_path : str, optional Path for saving generated structures in `.xyz` or `.cif` format. seed : int, optional Random seed for reproducibility. Attributes ---------- atoms : list of GAM_Atom List of atoms with positions, element type, and optional metadata. bonds : list of GAM_Bond List of identified bonds (if neighbor detection is used). lattice_vectors : numpy.ndarray Lattice vectors defining the crystal unit cell. meta : dict Metadata dictionary containing generation parameters and history. center : tuple of float Center of the nanoparticle in Cartesian coordinates. radius : float Radius of the nanoparticle in Ångströms. Methods ------- build() -> None Construct the nanoparticle structure from the given parameters. generate_core_shell(core_element: str, core_radius: float) -> None Create a core–shell nanoparticle by replacing atoms within the core radius. apply_rotation(angle: float, axis: str = 'z') -> None Rotate the nanoparticle by a given angle around a specified axis. translate(dx: float, dy: float, dz: float) -> None Translate all atoms by the specified vector. nearest_neighbors(r_cut: float | None = None) -> None Identify bonds based on a distance cutoff. filter_atoms(element: str) -> list[GAM_Atom] Return atoms belonging to a specific element. get_positions() -> list[tuple[float, float, float]] Return atomic positions as (x, y, z) tuples. get_elements() -> list[str] Return a list of all atomic species in the nanoparticle. to_xyz(path: str) -> None Export the nanoparticle to an XYZ file. to_ase() -> ase.Atoms Convert the nanoparticle to an ASE-compatible `Atoms` object. Notes ----- - The atomic cutoff radius for neighbor detection should be set according to the typical bond length of the chosen element (e.g., 1.2× nearest-neighbor distance). - The nanoparticle surface morphology depends on both lattice type and radius. - Randomized orientation can be used to eliminate orientation bias in ML datasets. - Large nanoparticles (>10,000 atoms) may require optimized neighbor-search algorithms. - The class is designed to be modular, allowing integration with simulation workflows such as VASP, Quantum ESPRESSO, LAMMPS, or CP2K. Examples -------- >>> # Generate a 5 nm gold nanoparticle >>> from pygamlab import Nanoparticle_Generator >>> npg = Nanoparticle_Generator(element='Au', lattice_constant=4.08, ... structure_type='FCC', radius=25.0) >>> npg.build() >>> npg.to_xyz('Au_nanoparticle.xyz') >>> # Generate a core–shell Ag@Au nanoparticle >>> cs = Nanoparticle_Generator(element='Au', lattice_constant=4.08, ... structure_type='FCC', radius=30.0) >>> cs.generate_core_shell(core_element='Ag', core_radius=15.0) >>> cs.to_xyz('AgAu_core_shell.xyz') """ def __init__(self, element: str, size_nm: float, shape: str = 'sphere', doping: Optional[Dict[str, float]] = None, coating: Optional[Tuple[str, float]] = None, crystal_structure: str = 'FCC', **kwargs): """ Initialize a nanoparticle with specified parameters. Args: element: Primary element for the nanoparticle (e.g., 'Au') size_nm: Particle size in nanometers shape: Shape type ('sphere', 'rod', 'cube', 'octahedron') doping: Optional dictionary of doping elements with percentages coating: Optional tuple of (material, thickness_nm) crystal_structure: Crystal structure ('FCC', 'BCC', 'hexagonal', 'diamond') **kwargs: Additional parameters (surface_roughness, lattice_constant, etc.) """ # Validate size_nm to ensure it's a positive value if size_nm <= 0: raise ValueError(f"Particle size must be a positive value. Provided size: {size_nm} nm.") # Validate doping percentages if doping: for element, percentage in doping.items(): if percentage > 100: raise ValueError(f"Doping percentage for {element} exceeds 100%. Provided: {percentage}%.") # Store basic parameters self.atoms: List[GAM_Atom] = [] self.bonds: List[GAM_Bond] = [] self.element = element self.elements = [element] # Keep for backward compatibility self.size_nm = size_nm self.shape = shape.lower() self.doping = doping or {} self.coating = coating self.crystal_structure = crystal_structure.upper() # Store additional parameters self.surface_roughness = kwargs.get('surface_roughness', 0.0) self.lattice_constant = kwargs.get('lattice_constant', None) self.custom_parameters = kwargs # Initialize atomic data self.atomic_data = AtomicData() # Store all parameters in a dictionary self.meta = { 'element': self.element, 'elements': self.elements, 'size_nm': self.size_nm, 'shape': self.shape, 'doping': self.doping, 'coating': self.coating, 'crystal_structure': self.crystal_structure, 'surface_roughness': self.surface_roughness, 'lattice_constant': self.lattice_constant, **kwargs } # Generate the nanoparticle structure self.positions = None self.atom_types = None self._build() def _get_lattice_constant(self) -> float: """ Get the lattice constant for the primary element. Returns: float: Lattice constant in Angstroms """ if self.lattice_constant is not None: return self.lattice_constant if self.element in self.atomic_data.LATTICE_CONSTANTS: return self.atomic_data.LATTICE_CONSTANTS[self.element] else: # Default lattice constant return 4.0 def _generate_unit_cell_positions(self) -> Tuple[np.ndarray, List[str]]: """ Generate unit cell positions based on crystal structure. Returns: Tuple of (positions array, atom types list) """ a = self._get_lattice_constant() if self.crystal_structure == 'FCC': # Face-centered cubic positions = np.array([ [0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5] ]) * a atom_types = [self.element] * 4 elif self.crystal_structure == 'BCC': # Body-centered cubic positions = np.array([ [0, 0, 0], [0.5, 0.5, 0.5] ]) * a atom_types = [self.element] * 2 elif self.crystal_structure == 'HEXAGONAL': # Hexagonal close-packed c_a_ratio = self.custom_parameters.get('c_a_ratio', 1.633) c = a * c_a_ratio positions = np.array([ [0, 0, 0], [2/3, 1/3, 0.5] ]) * np.array([a, a, c]) atom_types = [self.element] * 2 elif self.crystal_structure == 'DIAMOND': # Diamond cubic positions = np.array([ [0, 0, 0], [0.25, 0.25, 0.25], [0.5, 0.5, 0], [0.75, 0.75, 0.25], [0.5, 0, 0.5], [0.75, 0.25, 0.75], [0, 0.5, 0.5], [0.25, 0.75, 0.75] ]) * a atom_types = [self.element] * 8 else: raise ValueError(f"Unsupported crystal structure: {self.crystal_structure}") return positions, atom_types def _generate_lattice_positions(self) -> Tuple[np.ndarray, List[str]]: """ Generate lattice positions by replicating unit cells. Returns: Tuple of (positions array, atom types list) """ unit_positions, unit_atom_types = self._generate_unit_cell_positions() a = self._get_lattice_constant() # Determine number of unit cells needed size_angstrom = self.size_nm * 10 # Convert nm to Angstrom n_cells = int(np.ceil(size_angstrom / a)) + 1 all_positions = [] all_atom_types = [] # Replicate unit cell for i in range(-n_cells, n_cells + 1): for j in range(-n_cells, n_cells + 1): for k in range(-n_cells, n_cells + 1): offset = np.array([i, j, k]) * a shifted_positions = unit_positions + offset all_positions.append(shifted_positions) all_atom_types.extend(unit_atom_types) positions = np.vstack(all_positions) return positions, all_atom_types def _apply_shape_constraint(self, positions: np.ndarray) -> np.ndarray: """ Apply shape constraints to filter atomic positions. Args: positions: Array of atomic positions Returns: Boolean mask for positions within the shape """ size_angstrom = self.size_nm * 10 / 2 # Half-size in Angstrom if self.shape == 'sphere': distances = np.linalg.norm(positions, axis=1) mask = distances <= size_angstrom elif self.shape == 'cube': mask = np.all(np.abs(positions) <= size_angstrom, axis=1) elif self.shape == 'rod': # Rod along z-axis aspect_ratio = self.custom_parameters.get('aspect_ratio', 3.0) rod_length = size_angstrom * aspect_ratio rod_radius = size_angstrom / aspect_ratio xy_distances = np.linalg.norm(positions[:, :2], axis=1) z_mask = np.abs(positions[:, 2]) <= rod_length xy_mask = xy_distances <= rod_radius mask = z_mask & xy_mask elif self.shape == 'octahedron': # Regular octahedron mask = (np.abs(positions[:, 0]) + np.abs(positions[:, 1]) + np.abs(positions[:, 2])) <= size_angstrom else: raise ValueError(f"Unsupported shape: {self.shape}") return mask def _apply_doping(self, atom_types: List[str]) -> List[str]: """ Apply doping by randomly replacing atoms. Args: atom_types: Original atom types Returns: Modified atom types with doping """ if not self.doping: return atom_types doped_types = atom_types.copy() n_atoms = len(atom_types) for dopant, percentage in self.doping.items(): n_dopant = int(n_atoms * percentage / 100) indices = random.sample(range(n_atoms), n_dopant) for idx in indices: doped_types[idx] = dopant return doped_types def _add_coating(self, positions: np.ndarray, atom_types: List[str]) -> Tuple[np.ndarray, List[str]]: """ Add coating layer to the nanoparticle. Args: positions: Core positions atom_types: Core atom types Returns: Tuple of (all positions, all atom types) including coating """ if not self.coating: return positions, atom_types coating_material, coating_thickness_nm = self.coating coating_thickness_angstrom = coating_thickness_nm * 10 # Find surface atoms (simplified approach) center = np.mean(positions, axis=0) distances_from_center = np.linalg.norm(positions - center, axis=1) surface_threshold = np.percentile(distances_from_center, 90) surface_mask = distances_from_center >= surface_threshold surface_positions = positions[surface_mask] # Generate coating positions coating_positions = [] coating_types = [] for pos in surface_positions: direction = (pos - center) / np.linalg.norm(pos - center) # Add multiple layers n_layers = max(1, int(coating_thickness_angstrom / 2.0)) for layer in range(1, n_layers + 1): coat_pos = pos + direction * layer * 2.0 coating_positions.append(coat_pos) coating_types.append(coating_material) if coating_positions: all_positions = np.vstack([positions, np.array(coating_positions)]) all_types = atom_types + coating_types else: all_positions = positions all_types = atom_types return all_positions, all_types def _apply_surface_roughness(self, positions: np.ndarray) -> np.ndarray: """ Apply surface roughness by adding random displacement. Args: positions: Original positions Returns: Positions with surface roughness applied """ if self.surface_roughness <= 0: return positions # Find surface atoms center = np.mean(positions, axis=0) distances_from_center = np.linalg.norm(positions - center, axis=1) surface_threshold = np.percentile(distances_from_center, 80) surface_mask = distances_from_center >= surface_threshold # Apply random displacement to surface atoms roughness_positions = positions.copy() surface_displacement = np.random.normal(0, self.surface_roughness, (np.sum(surface_mask), 3)) roughness_positions[surface_mask] += surface_displacement return roughness_positions def _build(self): """Generate the complete nanoparticle structure.""" # Generate initial lattice positions, atom_types = self._generate_lattice_positions() # Apply shape constraint shape_mask = self._apply_shape_constraint(positions) positions = positions[shape_mask] atom_types = [atom_types[i] for i, mask in enumerate(shape_mask) if mask] # Apply doping atom_types = self._apply_doping(atom_types) # Add coating positions, atom_types = self._add_coating(positions, atom_types) # Apply surface roughness positions = self._apply_surface_roughness(positions) # Center the nanoparticle center = np.mean(positions, axis=0) positions -= center self.positions = positions self.atom_types = atom_types for i in range(len(self.atom_types)): x, y, z = self.positions[i] # unpack coordinates each_atom = GAM_Atom(id=i, element=self.atom_types[i], x=x, y=y, z=z) self.atoms.append(each_atom)
[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 add_element(self, element: str, percentage: float, distribution: str = 'random') -> None: """ Add an element to create an alloy nanoparticle. Args: element: Element symbol to add (e.g., 'Ag', 'Cu') percentage: Percentage of the new element (0-100) distribution: Distribution method ('random', 'surface', 'core', 'layered') """ if not 0 <= percentage <= 100: raise ValueError(f"Percentage must be between 0 and 100. Provided: {percentage}%") if element not in self.atomic_data.ATOMIC_RADII: raise ValueError(f"Element {element} not supported. Available elements: {list(self.atomic_data.ATOMIC_RADII.keys())}") # Add to elements list if element not in self.elements: self.elements.append(element) # Calculate number of atoms to replace n_atoms = len(self.atom_types) n_to_replace = int(n_atoms * percentage / 100) if distribution == 'random': # Random distribution throughout the nanoparticle indices = random.sample(range(n_atoms), n_to_replace) for idx in indices: self.atom_types[idx] = element elif distribution == 'surface': # Replace atoms on the surface center = np.mean(self.positions, axis=0) distances_from_center = np.linalg.norm(self.positions - center, axis=1) surface_threshold = np.percentile(distances_from_center, 80) surface_indices = np.where(distances_from_center >= surface_threshold)[0] if len(surface_indices) >= n_to_replace: selected_indices = random.sample(list(surface_indices), n_to_replace) else: selected_indices = surface_indices # Fill remaining with random selection remaining = n_to_replace - len(surface_indices) other_indices = [i for i in range(n_atoms) if i not in surface_indices] if other_indices: selected_indices.extend(random.sample(other_indices, min(remaining, len(other_indices)))) for idx in selected_indices: self.atom_types[idx] = element elif distribution == 'core': # Replace atoms in the core center = np.mean(self.positions, axis=0) distances_from_center = np.linalg.norm(self.positions - center, axis=1) core_threshold = np.percentile(distances_from_center, 20) core_indices = np.where(distances_from_center <= core_threshold)[0] if len(core_indices) >= n_to_replace: selected_indices = random.sample(list(core_indices), n_to_replace) else: selected_indices = core_indices # Fill remaining with random selection remaining = n_to_replace - len(core_indices) other_indices = [i for i in range(n_atoms) if i not in core_indices] if other_indices: selected_indices.extend(random.sample(other_indices, min(remaining, len(other_indices)))) for idx in selected_indices: self.atom_types[idx] = element elif distribution == 'layered': # Create layered structure (core-shell or alternating layers) center = np.mean(self.positions, axis=0) distances_from_center = np.linalg.norm(self.positions - center, axis=1) # Sort atoms by distance from center sorted_indices = np.argsort(distances_from_center) # Replace atoms in specific layers layer_size = n_atoms // 4 # Divide into 4 layers for i in range(min(n_to_replace, n_atoms)): layer_idx = (i // layer_size) % 4 if layer_idx % 2 == 1: # Replace in odd-numbered layers self.atom_types[sorted_indices[i]] = element else: raise ValueError(f"Unsupported distribution method: {distribution}. Use 'random', 'surface', 'core', or 'layered'") # Update atoms list for i, atom_type in enumerate(self.atom_types): if i < len(self.atoms): self.atoms[i].element = atom_type else: # Create new atom if needed x, y, z = self.positions[i] new_atom = GAM_Atom(id=i, element=atom_type, x=x, y=y, z=z) self.atoms.append(new_atom) # Update metadata self.meta['elements'] = self.elements print(f"Added {element} ({percentage}%) with {distribution} distribution")
[docs] def get_composition(self) -> Dict[str, float]: """ Get the current composition of the nanoparticle as percentages. Returns: Dictionary mapping element symbols to their percentages """ if not self.atom_types: return {} unique_elements, counts = np.unique(self.atom_types, return_counts=True) total_atoms = len(self.atom_types) composition = {} for element, count in zip(unique_elements, counts): composition[element] = (count / total_atoms) * 100 return composition
[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_xyz(self, path: str) -> None: """Build (if needed) and save structure directly to XYZ file.""" # Prepare XYZ string directly lines = [str(len(self.atoms))] ''' comment_parts = [] for key, value in self.meta.items(): comment_parts.append(f"{key}={value}") lines.append("Silicene structure: " + ", ".join(comment_parts)) ''' lines.append('Generated by PyGamlab') for atom in self.atoms: lines.append(f"{atom.element:2s} {atom.x:12.6f} {atom.y:12.6f} {atom.z:12.6f}") # Save directly with open(path, 'w') as f: f.write("\n".join(lines))
[docs] def to_ase(self): """ Export structure to ASE Atoms object (if ASE is available). Returns: ASE Atoms object or None if ASE not available """ symbols = [atom.element for atom in self.atoms] positions = [atom.position for atom in self.atoms] atoms_obj = Atoms(symbols=symbols, positions=positions) # Add metadata atoms_obj.info.update(self.meta) return atoms_obj
def _to_xyz(self, file_path: str) -> None: """ Save nanoparticle atomic positions to an XYZ file. Args: file_path: Path to save the XYZ file """ if self.positions is None: raise ValueError("Nanoparticle structure not generated") with open(file_path, 'w') as f: # Write number of atoms f.write(f"{len(self.positions)}\n") # Write comment line with nanoparticle info comment = (f"Nanoparticle: {', '.join(self.elements)}, " f"Size: {self.size_nm} nm, Shape: {self.shape}, " f"Crystal: {self.crystal_structure}") f.write(f"{comment}\n") # Write atomic coordinates for i, (pos, atom_type) in enumerate(zip(self.positions, self.atom_types)): f.write(f"{atom_type:2s} {pos[0]:12.6f} {pos[1]:12.6f} {pos[2]:12.6f}\n") print(f"Nanoparticle structure saved to {file_path}") def _to_ase(self): """ Return an ASE Atoms object (requires ASE installation). Returns: ASE Atoms object containing the nanoparticle structure """ if self.positions is None: raise ValueError("Nanoparticle structure not generated") try: from ase import Atoms atoms = Atoms(symbols=self.atom_types, positions=self.positions) return atoms except ImportError: print("ASE not available. Install with: pip install ase") # Return a simple dictionary instead return { 'symbols': self.atom_types, 'positions': self.positions, 'cell': None, 'pbc': [False, False, False] }
[docs] def get_info(self) -> Dict[str, Any]: """ Get comprehensive information about the nanoparticle. Returns: Dictionary containing nanoparticle information """ if self.positions is None: return self.meta # Calculate additional properties unique_elements, counts = np.unique(self.atom_types, return_counts=True) composition = dict(zip(unique_elements, counts)) center_of_mass = np.mean(self.positions, axis=0) max_distance = np.max(np.linalg.norm(self.positions - center_of_mass, axis=1)) actual_size_nm = max_distance * 2 / 10 # Convert to nm info = self.meta.copy() info.update({ 'n_atoms': len(self.positions), 'composition': composition, 'actual_size_nm': actual_size_nm, 'center_of_mass': center_of_mass.tolist(), 'bounding_box': { 'x_range': [float(self.positions[:, 0].min()), float(self.positions[:, 0].max())], 'y_range': [float(self.positions[:, 1].min()), float(self.positions[:, 1].max())], 'z_range': [float(self.positions[:, 2].min()), float(self.positions[:, 2].max())] } }) return info
[docs] def save_parameters(self, file_path: str) -> None: """ Save nanoparticle parameters to a JSON file. Args: file_path: Path to save the parameters file """ info = self.get_info() with open(file_path, 'w') as f: json.dump(info, f, indent=2, default=str) print(f"Parameters saved to {file_path}")