Source code for zacrostools.lattice_model

from pathlib import Path
from typing import Union, Optional, List, Tuple, Dict, Any
import numpy as np
from zacrostools.header import write_header
from zacrostools.custom_exceptions import LatticeModelError


[docs] class LatticeModel: """ Represents a KMC lattice model for Zacros. Parameters ---------- lattice_type : str Type of lattice structure. Must be one of the following: - 'default_choice' - 'periodic_cell' - 'explicit' default_lattice_type : str, optional Required if `lattice_type` is 'default_choice'. One of 'triangular_periodic', 'rectangular_periodic', 'hexagonal_periodic'. lattice_constant : float, optional Required if `lattice_type` is 'default_choice'. The lattice constant. copies : list of int, optional Required for 'default_choice' and 'periodic_cell'. Number of copies in horizontal and vertical directions, e.g., `[10, 10]`. cell_vectors : tuple of float, optional Required if `lattice_type` is 'periodic_cell'. Two unit vectors defining the unit cell, e.g., `((2.5, 0.0), (0.0, 2.5))`. sites : dict, optional Required if `lattice_type` is 'periodic_cell'. Dictionary mapping site types to their coordinates. Values can be either a single tuple or a list of tuples. Example: ``` { 'A': [(0.0, 0.0)], 'B': [(0.5, 0.5)] } ``` coordinate_type : str, optional Required if `lattice_type` is 'periodic_cell'. Coordinate system used for site positions: 'direct' or 'cartesian'. Defaults to 'direct'. neighboring_structure : Union[str, dict], optional Required if `lattice_type` is 'periodic_cell'. - If a dictionary, it maps site pair strings to a list of relationship keywords. Example: `{'1-2': ['self'], '1-1': ['north', 'east']}` - If 'from_distances', the user must provide a `max_distances` dictionary to generate the neighboring structure automatically. max_distances : dict, optional Required if `neighboring_structure='from_distances'`. Dictionary mapping site type pairs to maximum allowed distances. Example: `{'A-A': 3.0, 'A-B': 3.0, 'B-B': 3.0}` Examples -------- **Default Choice Lattice** ```python lattice_model = LatticeModel( lattice_type='default_choice', default_lattice_type='triangular_periodic', lattice_constant=2.5, copies=[20, 20] ) ``` **Periodic Cell Lattice** ```python lattice_model = LatticeModel( lattice_type='periodic_cell', cell_vectors=((2.5, 0.0), (0.0, 2.5)), sites={ 'A': [(0.0, 0.0)], 'B': [(0.5, 0.5)] }, coordinate_type='direct', copies=[10, 10], neighboring_structure='from_distances', max_distances={ 'A-A': 3.0, 'A-B': 3.0, 'B-B': 3.0 } ) ``` Alternatively, when specifying the `neighboring_structure` directly: ```python neighboring_structure = { '1-1': ['north', 'east'], '1-2': ['self'], '2-1': ['north', 'east', 'northeast'], '2-2': ['north', 'east'] } lattice_model = LatticeModel( lattice_type='periodic_cell', cell_vectors=((3.27, 0.0), (0.0, 3.27)), sites={ 'tC': [(0.25, 0.25)], 'tM': [(0.75, 0.75)] }, coordinate_type='direct', copies=[10, 10], neighboring_structure=neighboring_structure ) ``` """ ALLOWED_LATTICE_TYPES = {'default_choice', 'periodic_cell', 'explicit'} ALLOWED_DEFAULT_LATTICE_TYPES = {'triangular_periodic', 'rectangular_periodic', 'hexagonal_periodic'} ALLOWED_COORDINATE_TYPES = {'direct', 'cartesian'} ALLOWED_NEIGHBORING_KEYWORDS = {'self', 'north', 'northeast', 'east', 'southeast'} DIRECTIONS = { 'east': (1, 0), 'north': (0, 1), 'northeast': (1, 1), 'southeast': (1, -1) } def __init__(self, lattice_type: str, default_lattice_type: Optional[str] = None, lattice_constant: Optional[float] = None, copies: Optional[List[int]] = None, cell_vectors: Optional[Tuple[Tuple[float, float], Tuple[float, float]]] = None, sites: Optional[Dict[str, Union[Tuple[float, float], List[Tuple[float, float]]]]] = None, coordinate_type: str = 'direct', neighboring_structure: Optional[Union[str, Dict[str, Union[str, List[str]]]]] = None, max_distances: Optional[Dict[str, float]] = None): # Validate lattice_type if lattice_type not in self.ALLOWED_LATTICE_TYPES: raise LatticeModelError( f"Invalid lattice_type '{lattice_type}'. " f"Allowed types are: {', '.join(self.ALLOWED_LATTICE_TYPES)}." ) self.lattice_type = lattice_type if lattice_type == 'default_choice': # Validate required parameters for 'default_choice' if default_lattice_type is None: raise LatticeModelError("For 'default_choice' lattice_type, 'default_lattice_type' must be provided.") if default_lattice_type not in self.ALLOWED_DEFAULT_LATTICE_TYPES: raise LatticeModelError( f"Invalid default_lattice_type '{default_lattice_type}'. " f"Allowed types are: {', '.join(self.ALLOWED_DEFAULT_LATTICE_TYPES)}." ) if lattice_constant is None: raise LatticeModelError("For 'default_choice' lattice_type, 'lattice_constant' must be provided.") if not isinstance(lattice_constant, (int, float)): raise LatticeModelError("'lattice_constant' must be a float.") if copies is None: raise LatticeModelError("For 'default_choice' lattice_type, 'copies' must be provided.") if (not isinstance(copies, list) or len(copies) != 2 or not all(isinstance(i, int) for i in copies)): raise LatticeModelError("'copies' must be a list of two integers.") self.default_lattice_type = default_lattice_type self.lattice_constant = float(lattice_constant) self.copies = copies # Attributes specific to 'default_choice' are set; others are None self.cell_vectors = None self.sites = None self.coordinate_type = None self.neighboring_structure = None self.max_distances = None elif lattice_type == 'periodic_cell': # Validate required parameters for 'periodic_cell' if cell_vectors is None: raise LatticeModelError("For 'periodic_cell' lattice_type, 'cell_vectors' must be provided.") if (not isinstance(cell_vectors, tuple) or len(cell_vectors) != 2 or not all(isinstance(vec, tuple) and len(vec) == 2 for vec in cell_vectors)): raise LatticeModelError( "'cell_vectors' must be a tuple of two tuples, each containing two floats." ) if sites is None: raise LatticeModelError("For 'periodic_cell' lattice_type, 'sites' must be provided.") if not isinstance(sites, dict): raise LatticeModelError( "'sites' must be a dictionary with site type names as keys and coordinate tuples or lists of " "coordinate tuples as values." ) # Normalize 'sites' to ensure all values are lists of tuples normalized_sites = {} for site_type, coords in sites.items(): if isinstance(coords, tuple): # Single tuple provided; convert to list normalized_sites[site_type] = [coords] elif isinstance(coords, list): # List of tuples; validate each if not all(isinstance(coord, tuple) and len(coord) == 2 and all(isinstance(c, (int, float)) for c in coord) for coord in coords): raise LatticeModelError( f"All coordinates for site type '{site_type}' must be tuples of two floats." ) normalized_sites[site_type] = coords else: raise LatticeModelError( f"Coordinates for site type '{site_type}' must be either a tuple or a list of tuples." ) self.sites = normalized_sites if coordinate_type not in self.ALLOWED_COORDINATE_TYPES: raise LatticeModelError( f"Invalid coordinate_type '{coordinate_type}'. " f"Allowed types are: {', '.join(self.ALLOWED_COORDINATE_TYPES)}." ) if copies is None: raise LatticeModelError("For 'periodic_cell' lattice_type, 'copies' must be provided.") if (not isinstance(copies, list) or len(copies) != 2 or not all(isinstance(i, int) for i in copies)): raise LatticeModelError("'copies' must be a list of two integers.") # Assign attributes that are required before handling neighboring_structure self.coordinate_type = coordinate_type self.copies = copies self.cell_vectors = cell_vectors # Validate neighboring_structure if neighboring_structure is None: raise LatticeModelError("For 'periodic_cell' lattice_type, 'neighboring_structure' must be provided.") if isinstance(neighboring_structure, dict): # Validate each key-value pair in neighboring_structure for pair, keywords in neighboring_structure.items(): if not isinstance(pair, str) or not self._is_valid_pair_key(pair): raise LatticeModelError( f"Invalid key '{pair}' in 'neighboring_structure'. Keys must be strings in the format " f"'int-int', e.g., '1-2'." ) if isinstance(keywords, str): keywords = [keywords] if not isinstance(keywords, list): raise LatticeModelError( f"Values in 'neighboring_structure' must be strings or lists of strings. Invalid value " f"for key '{pair}'." ) for keyword in keywords: if keyword not in self.ALLOWED_NEIGHBORING_KEYWORDS: raise LatticeModelError( f"Invalid keyword '{keyword}' for 'neighboring_structure'. " f"Allowed keywords are: {', '.join(self.ALLOWED_NEIGHBORING_KEYWORDS)}." ) self.neighboring_structure = neighboring_structure self.max_distances = None # Not used when providing a dict directly elif isinstance(neighboring_structure, str) and neighboring_structure == 'from_distances': if max_distances is None: raise LatticeModelError( "When 'neighboring_structure' is set to 'from_distances', 'max_distances' must be provided." ) if not isinstance(max_distances, dict): raise LatticeModelError("'max_distances' must be a dictionary mapping site type pairs to distances.") # Validate max_distances for pair, distance in max_distances.items(): if not isinstance(pair, str) or not self._is_valid_site_type_pair(pair): raise LatticeModelError( f"Invalid key '{pair}' in 'max_distances'. Keys must be strings in the format " f"'SiteType1-SiteType2', e.g., 'tC-tM'." ) if not isinstance(distance, (int, float)) or distance <= 0: raise LatticeModelError( f"Invalid distance '{distance}' for pair '{pair}' in 'max_distances'. Must be a positive " f"number." ) # Generate neighboring_structure based on max_distances self.neighboring_structure = self._generate_neighboring_structure_from_distances(max_distances) self.max_distances = max_distances # Store max_distances for future recalculations else: raise LatticeModelError( "Invalid 'neighboring_structure' parameter. Must be either a dictionary or the string 'from_distances'." ) # Attributes specific to 'periodic_cell' are set; others are None self.default_lattice_type = None self.lattice_constant = None # Perform additional validations self._validate_periodic_cell() @classmethod def from_file(cls, input_file: Union[str, Path]): """ Create a LatticeModel instance by reading a `lattice_input.dat` file. Parameters ---------- input_file : Union[str, Path] Path to the lattice input file. Returns ------- LatticeModel An instance of LatticeModel initialized with parameters extracted from the file. """ parsed_data = parse_lattice_input_file(input_file) lattice_type = parsed_data['lattice_type'] if lattice_type == 'default_choice': return cls( lattice_type='default_choice', default_lattice_type=parsed_data['default_lattice_type'], lattice_constant=parsed_data['lattice_constant'], copies=parsed_data['copies'] ) elif lattice_type == 'periodic_cell': return cls( lattice_type='periodic_cell', cell_vectors=parsed_data['cell_vectors'], sites=parsed_data['sites'], coordinate_type=parsed_data.get('coordinate_type', 'direct'), copies=parsed_data['copies'], neighboring_structure=parsed_data['neighboring_structure'] ) elif lattice_type == 'explicit': raise LatticeModelError("The 'explicit' lattice_type is not yet supported.") else: raise LatticeModelError(f"Unsupported lattice_type '{lattice_type}'.") def _is_valid_pair_key(self, pair: str) -> bool: """ Validate that the pair key is in the format 'int-int'. Parameters ---------- pair : str The pair string to validate. Returns ------- bool True if valid, False otherwise. """ parts = pair.split('-') if len(parts) != 2: return False return parts[0].isdigit() and parts[1].isdigit() def _is_valid_site_type_pair(self, pair: str) -> bool: """ Validate that the site type pair key is in the format 'SiteType1-SiteType2'. Parameters ---------- pair : str The site type pair string to validate. Returns ------- bool True if valid, False otherwise. """ parts = pair.split('-') if len(parts) != 2: return False return all(isinstance(part, str) and part for part in parts) def _validate_periodic_cell(self): """ Validate the properties specific to the 'periodic_cell' lattice type. Raises ------ LatticeModelError If any of the site coordinates are invalid based on the coordinate type. """ if self.coordinate_type == 'direct': # Ensure all coordinates are between 0 and 1 for site_type, coords_list in self.sites.items(): for coord in coords_list: if not (0.0 <= coord[0] <= 1.0 and 0.0 <= coord[1] <= 1.0): raise LatticeModelError( f"Direct coordinates for site '{site_type}' must be between 0 and 1. " f"Invalid coordinate: {coord}" ) elif self.coordinate_type == 'cartesian': # No specific range validation for cartesian coordinates pass else: raise LatticeModelError(f"Unsupported coordinate_type '{self.coordinate_type}'.") def _generate_neighboring_structure_from_distances(self, max_distances: Dict[str, float]) -> Dict[str, List[str]]: """ Generate the neighboring_structure based on distance criteria. Parameters ---------- max_distances : dict Mapping of site type pairs to maximum allowed distances in angstroms. Example: `{'A-A': 4.0, 'A-B': 3.0, 'B-B': 2.0}` Returns ------- dict Generated neighboring_structure mapping site pairs to a list of relationship keywords. Raises ------ LatticeModelError If site types in max_distances do not exist in the defined sites. """ # Create a list of all sites with their types and coordinates site_list: List[Dict[str, Any]] = [] for site_type in self.sites: for coord in self.sites[site_type]: site_list.append({'type': site_type, 'coord': coord}) num_sites = len(site_list) if num_sites == 0: raise LatticeModelError("No sites defined in 'sites'.") # Assign indices to sites starting from 1 for idx, site in enumerate(site_list, start=1): site['index'] = idx # Convert cell_vectors to numpy arrays for calculations alpha = np.array(self.cell_vectors[0]) # Vector α beta = np.array(self.cell_vectors[1]) # Vector β # Prepare dictionary to hold neighboring relations neighboring_structure: Dict[str, List[str]] = {} # Precompute Cartesian coordinates for all sites for site in site_list: site['cartesian'] = site['coord'][0] * alpha + site['coord'][1] * beta # Function to compute distance between two points def distance(p1: np.ndarray, p2: np.ndarray) -> float: return np.linalg.norm(p1 - p2) # Iterate over all unique pairs within the unit cell for 'self' relations for i in range(num_sites): for j in range(i + 1, num_sites): site_i = site_list[i] site_j = site_list[j] pair_key = f"{site_i['index']}-{site_j['index']}" pair_types = f"{site_i['type']}-{site_j['type']}" # Check if this pair type exists in max_distances max_distance = self._get_max_distance(pair_types, max_distances) if max_distance is None: continue # No distance criteria defined for this pair # Calculate distance within the unit cell dist = distance(site_i['cartesian'], site_j['cartesian']) if dist <= max_distance: if pair_key not in neighboring_structure: neighboring_structure[pair_key] = [] neighboring_structure[pair_key].append('self') # Directions and their corresponding shift vectors in direct coordinates directions = self.DIRECTIONS for direction, shift in directions.items(): shift_vector = np.array(shift) # Calculate the Cartesian shift based on cell_vectors cartesian_shift = shift_vector[0] * alpha + shift_vector[1] * beta # Iterate over all pairs between original sites and shifted sites for site in site_list: for target_site in site_list: pair_key = f"{site['index']}-{target_site['index']}" pair_types = f"{site['type']}-{target_site['type']}" # Check if this pair type exists in max_distances max_distance = self._get_max_distance(pair_types, max_distances) if max_distance is None: continue # No distance criteria defined for this pair # Calculate shifted position of target_site shifted_position = target_site['cartesian'] + cartesian_shift dist = distance(site['cartesian'], shifted_position) if dist <= max_distance: if pair_key not in neighboring_structure: neighboring_structure[pair_key] = [] if direction not in neighboring_structure[pair_key]: neighboring_structure[pair_key].append(direction) return neighboring_structure def _get_max_distance(self, pair_types: str, max_distances: Dict[str, float]) -> Optional[float]: """ Retrieve the maximum distance for a given pair of site types, considering symmetry. Parameters ---------- pair_types : str Pair of site types in the format 'type1-type2'. max_distances : dict Mapping of site type pairs to maximum distances. Returns ------- Optional[float] The maximum distance if defined, else None. """ # Check the pair as is if pair_types in max_distances: return max_distances[pair_types] # Check the symmetric pair reversed_pair = '-'.join(pair_types.split('-')[::-1]) return max_distances.get(reversed_pair, None) def write_lattice_input(self, output_dir: Union[str, Path], sig_figs: int = 8): """ Writes the `lattice_input.dat` file based on the lattice type. Parameters ---------- output_dir : Union[str, Path] Directory path where the file will be written. sig_figs : int, optional Number of significant figures for numerical values. Default is 8. Raises ------ LatticeModelError If file writing fails or if `lattice_type` is unsupported. """ # Convert output_dir to Path object if it's a string output_dir = Path(output_dir) if isinstance(output_dir, str) else output_dir # Ensure the output directory exists output_dir.mkdir(parents=True, exist_ok=True) output_file = output_dir / "lattice_input.dat" write_header(output_file) try: with output_file.open('a') as infile: if self.lattice_type == 'default_choice': infile.write("lattice default_choice\n\n") infile.write(f" {self.default_lattice_type} {self.lattice_constant:.{sig_figs}f} " f"{self.copies[0]} {self.copies[1]}\n") infile.write("\nend_lattice\n") elif self.lattice_type == 'periodic_cell': infile.write("lattice periodic_cell\n\n") # Write cell_vectors infile.write(" cell_vectors\n") for vec in self.cell_vectors: infile.write(f" {vec[0]:.{sig_figs}f} {vec[1]:.{sig_figs}f}\n") # Write repeat_cell infile.write(f" repeat_cell {self.copies[0]} {self.copies[1]}\n") # Calculate n_cell_sites, n_site_types, site_type_names, site_types, site_coordinates site_type_names = list(self.sites.keys()) n_site_types = len(site_type_names) site_types = [] site_coordinates = [] # Assign indices to sites site_indices = {} current_index = 1 for site_type in site_type_names: for coord in self.sites[site_type]: site_types.append(site_type) site_coordinates.append(coord) site_indices[(coord[0], coord[1])] = current_index current_index += 1 n_cell_sites = len(site_coordinates) # Write n_cell_sites infile.write(f" n_cell_sites {n_cell_sites}\n") # Write n_site_types infile.write(f" n_site_types {n_site_types}\n") # Write site_type_names infile.write(f" site_type_names {' '.join(site_type_names)}\n") # Write site_types infile.write(f" site_types {' '.join(site_types)}\n") # Write site_coordinates infile.write(" site_coordinates\n") for coord in site_coordinates: infile.write(f" {coord[0]:.{sig_figs}f} {coord[1]:.{sig_figs}f}\n") # Write neighboring_structure infile.write(" neighboring_structure\n") for pair, keywords in self.neighboring_structure.items(): if isinstance(keywords, str): keywords = [keywords] for keyword in keywords: infile.write(f" {pair} {keyword}\n") infile.write(" end_neighboring_structure\n") # End lattice infile.write("\nend_lattice\n") else: # Placeholder for other lattice types raise LatticeModelError(f"Lattice type '{self.lattice_type}' is not yet supported.") except IOError as e: raise LatticeModelError(f"Failed to write to '{output_file}': {e}") def repeat_lattice_model(self, a: int, b: int): """ Create a new unit cell by repeating the original unit cell `a` times along the first cell vector and `b` times along the second cell vector. Parameters ---------- a : int Number of repetitions along the first cell vector. b : int Number of repetitions along the second cell vector. Raises ------ LatticeModelError If `lattice_type` is not 'periodic_cell', `neighboring_structure` is not 'from_distances', or if `a` or `b` are not positive integers. """ if self.lattice_type != 'periodic_cell': raise LatticeModelError("repeat_lattice_model method is only available for 'periodic_cell' lattice_type.") if self.max_distances is None: raise LatticeModelError("repeat_lattice_model method requires 'neighboring_structure' to be 'from_distances' with 'max_distances' provided.") if not (isinstance(a, int) and a > 0 and isinstance(b, int) and b > 0): raise LatticeModelError("'a' and 'b' must be positive integers.") # Scale cell vectors original_alpha, original_beta = self.cell_vectors new_alpha = (original_alpha[0] * a, original_alpha[1] * a) new_beta = (original_beta[0] * b, original_beta[1] * b) self.cell_vectors = (new_alpha, new_beta) # Generate new sites by repeating the unit cell new_sites = {} for site_type, coords in self.sites.items(): new_sites[site_type] = [] for (x, y) in coords: for i in range(a): for j in range(b): new_x = (x + i) / a new_y = (y + j) / b # Ensure the coordinates wrap around within [0,1) new_x = new_x % 1.0 new_y = new_y % 1.0 new_sites[site_type].append((new_x, new_y)) self.sites = new_sites # Recalculate neighboring_structure if self.max_distances is not None: self.neighboring_structure = self._generate_neighboring_structure_from_distances(self.max_distances) elif isinstance(self.neighboring_structure, dict): # If neighboring_structure was manually provided as a dict, decide if it needs to be updated # For simplicity, we'll keep it unchanged. Alternatively, you can regenerate it. pass else: raise LatticeModelError( "Invalid 'neighboring_structure' parameter. Must be either a dictionary or the string 'from_distances'." ) def remove_site(self, direct_coords: Tuple[float, float], tolerance: float = 1e-8): """ Remove a site from the lattice model based on its direct coordinates and update the neighboring structure. Parameters ---------- direct_coords : Tuple[float, float] A tuple containing the direct x and y coordinates of the site to remove. tolerance : float, optional Tolerance for coordinate matching. Default is 1e-8. Raises ------ LatticeModelError If `lattice_type` is not 'periodic_cell', if `neighboring_structure` is not 'from_distances', or if the site with the given coordinates is not found. """ if self.lattice_type != 'periodic_cell': raise LatticeModelError("remove_site method is only available for 'periodic_cell' lattice_type.") if self.max_distances is None: raise LatticeModelError("remove_site method requires 'neighboring_structure' to be 'from_distances' with 'max_distances' provided.") # Unpack direct_coords x, y = direct_coords # Find the site to remove found = False for site_type, coords_list in list(self.sites.items()): for idx, coord in enumerate(coords_list): if np.isclose(coord[0], x, atol=tolerance) and np.isclose(coord[1], y, atol=tolerance): # Remove the site del self.sites[site_type][idx] # Remove site_type key if no more sites if not self.sites[site_type]: del self.sites[site_type] found = True break if found: break if not found: raise LatticeModelError(f"No site found with coordinates ({x}, {y}).") # Recalculate neighboring_structure self.neighboring_structure = self._generate_neighboring_structure_from_distances(self.max_distances) def change_site_type(self, direct_coords: Tuple[float, float], new_site_type: str, tolerance: float = 1e-8): """ Change the site type of a specific site based on its direct coordinates and update the neighboring structure. Parameters ---------- direct_coords : Tuple[float, float] A tuple containing the direct x and y coordinates of the site to change. new_site_type : str The new site type name to assign to the specified site. tolerance : float, optional Tolerance for coordinate matching. Default is 1e-8. Raises ------ LatticeModelError If `lattice_type` is not 'periodic_cell', if `neighboring_structure` is not 'from_distances', if the site with the given coordinates is not found, or if the `new_site_type` is invalid. """ if self.lattice_type != 'periodic_cell': raise LatticeModelError("change_site_type method is only available for 'periodic_cell' lattice_type.") if self.max_distances is None: raise LatticeModelError("change_site_type method requires 'neighboring_structure' to be 'from_distances' with 'max_distances' provided.") if not isinstance(new_site_type, str) or not new_site_type: raise LatticeModelError("new_site_type must be a non-empty string.") # Unpack direct_coords x, y = direct_coords # Find the site to change found = False for site_type, coords_list in list(self.sites.items()): for idx, coord in enumerate(coords_list): if np.isclose(coord[0], x, atol=tolerance) and np.isclose(coord[1], y, atol=tolerance): # Change the site type del self.sites[site_type][idx] if not self.sites[site_type]: del self.sites[site_type] # Add to new_site_type if new_site_type in self.sites: self.sites[new_site_type].append(coord) else: self.sites[new_site_type] = [coord] found = True break if found: break if not found: raise LatticeModelError(f"No site found with coordinates ({x}, {y}).") # Recalculate neighboring_structure self.neighboring_structure = self._generate_neighboring_structure_from_distances(self.max_distances)
def parse_lattice_input_file(input_file: Union[str, Path]) -> Dict[str, Any]: """ Parses a lattice_input.dat file and extracts the lattice parameters. Parameters ---------- input_file : Union[str, Path] Path to the lattice input file. Returns ------- Dict[str, Any] Dictionary containing the extracted parameters, including: - lattice_type - default_lattice_type - lattice_constant - copies - cell_vectors - sites - coordinate_type - neighboring_structure Raises ------ LatticeModelError If the file cannot be read, the lattice block is missing, or if required parameters are invalid or missing. """ # Ensure the input file exists input_file = Path(input_file) if not input_file.is_file(): raise LatticeModelError(f"Input file '{input_file}' does not exist.") # Read all lines from the input file with input_file.open('r') as f: lines = f.readlines() # Initialize variables to identify the lattice block lattice_block = [] # List to store lines within the lattice block inside_lattice_block = False lattice_type = None # Iterate through each line to find the lattice block for line in lines: stripped_line = line.strip() if stripped_line.startswith('lattice'): # Start of the lattice block inside_lattice_block = True parts = stripped_line.split() # The first line should be 'lattice lattice_type' if len(parts) < 2: raise LatticeModelError("Invalid lattice block: missing lattice type.") lattice_type = parts[1] lattice_block.append(line) elif stripped_line.startswith('end_lattice'): # End of the lattice block lattice_block.append(line) break # Stop reading further lines elif inside_lattice_block: # Lines within the lattice block lattice_block.append(line) # Check if the lattice block was found if not lattice_block: raise LatticeModelError("No lattice block found in the input file.") # Prepare a dictionary to store parsed data parsed_data = {'lattice_type': lattice_type} # Parse parameters based on the lattice_type if lattice_type == 'default_choice': # Handle 'default_choice' lattice type params_line = None # Find the line containing the parameters for line in lattice_block[1:]: stripped_line = line.strip() if stripped_line == '' or stripped_line.startswith('#'): continue # Skip empty lines and comments params_line = stripped_line break # Parameters found if params_line is None: raise LatticeModelError("No parameters found for 'default_choice' lattice.") # Extract parameters parts = params_line.split() if len(parts) != 4: raise LatticeModelError("Invalid parameters for 'default_choice' lattice. Expected 4 parameters.") default_lattice_type, lattice_constant_str, copies0_str, copies1_str = parts parsed_data['default_lattice_type'] = default_lattice_type parsed_data['lattice_constant'] = float(lattice_constant_str) parsed_data['copies'] = [int(copies0_str), int(copies1_str)] elif lattice_type == 'periodic_cell': # Handle 'periodic_cell' lattice type # Initialize variables to store lattice parameters cell_vectors = [] copies = None coordinate_type = 'direct' # Default coordinate type n_cell_sites = None site_type_names = [] site_types = [] site_coordinates = [] neighboring_structure = {} in_cell_vectors = False in_site_coordinates = False in_neighboring_structure = False # Iterate through the lattice block to extract parameters for line in lattice_block[1:]: stripped_line = line.strip() if stripped_line == '' or stripped_line.startswith('#'): continue # Skip empty lines and comments if stripped_line.startswith('cell_vectors'): # Start reading cell vectors in_cell_vectors = True continue elif stripped_line.startswith('repeat_cell'): # Extract repeat_cell values (copies) parts = stripped_line.split() copies = [int(parts[1]), int(parts[2])] elif stripped_line.startswith('coordinate_type'): # Extract coordinate type parts = stripped_line.split() coordinate_type = parts[1] elif stripped_line.startswith('n_cell_sites'): # Extract number of cell sites n_cell_sites = int(stripped_line.split()[1]) elif stripped_line.startswith('n_site_types'): # Number of site types (not used in this function) pass # No action needed elif stripped_line.startswith('site_type_names'): # Extract site type names parts = stripped_line.split() site_type_names = parts[1:] elif stripped_line.startswith('site_types'): # Extract site types for each site parts = stripped_line.split() site_types = parts[1:] elif stripped_line.startswith('site_coordinates'): # Start reading site coordinates in_site_coordinates = True continue elif stripped_line.startswith('neighboring_structure'): # Start reading neighboring structure in_neighboring_structure = True continue elif stripped_line.startswith('end_neighboring_structure'): # End of neighboring structure section in_neighboring_structure = False continue elif stripped_line.startswith('end_lattice'): # End of lattice block break elif in_cell_vectors: # Read cell vector components vec_parts = stripped_line.split() if len(vec_parts) != 2: raise LatticeModelError("Invalid cell vector format.") cell_vectors.append((float(vec_parts[0]), float(vec_parts[1]))) if len(cell_vectors) == 2: in_cell_vectors = False # All cell vectors read elif in_site_coordinates: # Read site coordinates coord_parts = stripped_line.split() if len(coord_parts) != 2: raise LatticeModelError("Invalid site coordinate format.") site_coordinates.append((float(coord_parts[0]), float(coord_parts[1]))) if len(site_coordinates) == n_cell_sites: in_site_coordinates = False # All site coordinates read elif in_neighboring_structure: # Read neighboring structure entries parts = stripped_line.split() if len(parts) != 2: raise LatticeModelError("Invalid neighboring structure entry.") pair = parts[0] keyword = parts[1] if pair in neighboring_structure: if keyword not in neighboring_structure[pair]: neighboring_structure[pair].append(keyword) else: neighboring_structure[pair] = [keyword] else: # Unhandled line or comments; no action needed continue # Validate that necessary site information has been read if not site_type_names or not site_types or not site_coordinates: raise LatticeModelError("Missing site information in the lattice file.") # Check that the number of site types matches the number of site coordinates if len(site_types) != len(site_coordinates): raise LatticeModelError("Mismatch between number of site types and site coordinates.") # Construct the 'sites' dictionary mapping site types to coordinates sites = {} for stype, coord in zip(site_types, site_coordinates): if stype not in sites: sites[stype] = [] sites[stype].append(coord) # Store the parsed parameters in the dictionary parsed_data['cell_vectors'] = tuple(cell_vectors) parsed_data['copies'] = copies parsed_data['coordinate_type'] = coordinate_type parsed_data['sites'] = sites parsed_data['neighboring_structure'] = neighboring_structure elif lattice_type == 'explicit': # Explicit lattice type is not supported raise LatticeModelError("The 'explicit' lattice_type is not yet supported.") else: # Unsupported lattice type encountered raise LatticeModelError(f"Unsupported lattice_type '{lattice_type}'.") # Return the dictionary containing all parsed parameters return parsed_data