diff --git a/.github/workflows/installation_test.yml b/.github/workflows/installation_test.yml index 9f8276f8..2687cccb 100644 --- a/.github/workflows/installation_test.yml +++ b/.github/workflows/installation_test.yml @@ -46,7 +46,7 @@ jobs: # python -m pytest -svv tests/ --cov=sparc --cov-report=json --cov-report=html export SPARC_TESTS_DIR="./SPARC-master/tests" export ASE_SPARC_COMMAND="mpirun -n 1 sparc" - export SPARC_DOC_PATH="./SPARC-master/doc" + export SPARC_DOC_PATH="./SPARC-master/doc/.LaTeX" coverage run -a -m pytest -svv tests/ coverage json --omit="tests/*.py" coverage html --omit="tests/*.py" diff --git a/.gitignore b/.gitignore index 2a602edb..59d8d699 100644 --- a/.gitignore +++ b/.gitignore @@ -773,3 +773,10 @@ al-eos-sparc.traj examples/ex1-ase/ /SPARC-master/ /master.zip +/*.ion +/*.static +/*.out* +/*.static* +/*.inpt* +/*.log +/*.psp8 diff --git a/README.md b/README.md index 4bff193d..a5c948e8 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,21 @@ schema used by the API at sparc.sparc_json_api.default_json_api ``` The schema file is generated from SPARC's LaTeX documentation. In -upcoming releases of `SPARC-X-API`, we're aiming to provide users -the flexibility to use their own custom schema files. This would be +upcoming releases of `SPARC-X-API`, we're aiming to provide users the +flexibility to use their own custom schema files. This would be particularly useful for those who might be testing a development -branch of SPARC. +branch of SPARC. By default, the JSON schema is packaged under +`sparc/sparc_json_api` directory. If you have another version of SPARC +source code, you can set the environment variable `$SPARC_DOC_PATH` to +the directory containing the LaTeX codes for the documentation, such +as `/doc/.LaTeX`. If you obtain `sparc-x` from +the conda method as mentioned above, By default, the JSON schema is +packaged under `sparc/sparc_json_api` directory. If you have another +version of SPARC source code, you can set the environment variable +`$SPARC_DOC_PATH` is automatically set to +`/share/doc/sparc/.LaTeX`. Setting up the environment +variable `$SPARC_DOC_PATH` helps loading the correct JSON schame that +is compatible with your SPARC binary code. ### C) SPARC Command Configuration diff --git a/sparc/api.py b/sparc/api.py index 2e077ba1..31593d43 100644 --- a/sparc/api.py +++ b/sparc/api.py @@ -6,15 +6,33 @@ import numpy as np curdir = Path(__file__).parent -# TODO: must clean the api directory default_api_dir = curdir / "sparc_json_api" default_json_api = default_api_dir / "parameters.json" class SparcAPI: + """ + An interface to the parameter settings in SPARC-X calculator. User can use the + SparcAPI instance to validate and translate parameters that matches a certain + version of the SPARC-X code. + + Attributes: + sparc_version (str): Version of SPARC. + categories (dict): Categories of parameters. + parameters (dict): Detailed parameters information. + other_parameters (dict): Additional parameters. + data_types (dict): Supported data types. + + Methods: + get_parameter_dict(parameter): Retrieves dictionary for a specific parameter. + help_info(parameter): Provides detailed information about a parameter. + validate_input(parameter, input): Validates user input against the expected parameter type. + convert_string_to_value(parameter, string): Converts string input to the appropriate data type. + convert_value_to_string(parameter, value): Converts a value to a string representation. + """ + def __init__(self, json_api=None): - """Initialize the API from a json file""" - # TODO: like ase io, adapt to both file and fio + """ """ if json_api is None: json_api = Path(default_json_api) else: @@ -26,9 +44,20 @@ def __init__(self, json_api=None): self.parameters = json_data["parameters"] self.other_parameters = json_data["other_parameters"] self.data_types = json_data["data_types"] - # TODO: Make a parameters by categories def get_parameter_dict(self, parameter): + """ + Retrieves the dictionary for a specified parameter. + + Args: + parameter (str): The name of the parameter. + + Returns: + dict: Dictionary containing details of the parameter. + + Raises: + KeyError: If the parameter is not known to the SPARC version. + """ parameter = parameter.upper() if parameter not in self.parameters.keys(): raise KeyError( @@ -37,6 +66,14 @@ def get_parameter_dict(self, parameter): return self.parameters[parameter] def help_info(self, parameter): + """Provides a detailed information string for a given parameter. + + Args: + parameter (str): The name of the parameter to get information for. + + Returns: + str: A formatted string with detailed information about the parameter. + """ pdict = self.get_parameter_dict(parameter) message = "\n".join( [ @@ -57,13 +94,18 @@ def help_info(self, parameter): return message def validate_input(self, parameter, input): - """Give a string for a parameter, - determine if the input follows the type + """ + Validates if the given input is appropriate for the specified parameter's type. + + Args: + parameter (str): The name of the parameter. + input: The input to validate, can be of various types (string, int, float, numpy types). - input can be either a string or a 'direct' data type, - like python float or numpy float + Returns: + bool: True if input is valid, False otherwise. - TODO: there are many exceptions in array types, should enumerate + Raises: + ValueError: If the data type of the parameter is not supported. """ is_input_string = isinstance(input, str) pdict = self.get_parameter_dict(parameter) @@ -97,7 +139,6 @@ def validate_input(self, parameter, input): except Exception: return False elif "array" in dtype: - # import pdb; pdb.set_trace() if is_input_string: if ("." in input) and ("integer" in dtype): warn( @@ -109,7 +150,6 @@ def validate_input(self, parameter, input): ) ) try: - # import pdb; pdb.set_trace() arr = np.genfromtxt(input.splitlines(), dtype=float, ndmin=1) # In valid input with nan if np.isnan(arr).any(): @@ -131,14 +171,24 @@ def validate_input(self, parameter, input): except Exception: arr = np.array(0.0) return len(arr.shape) > 0 - # elif dtype == "other": - # # Any "other"-type inputs should be provided only using string - # return is_input_string else: raise ValueError(f"Data type {dtype} is not supported!") def convert_string_to_value(self, parameter, string): - """Convert a string input into valie parameter type""" + """ + Converts a string input to the appropriate value type of the parameter. + + Args: + parameter (str): The name of the parameter. + string (str): The string input to convert. + + Returns: + The converted value, type depends on parameter's expected type. + + Raises: + TypeError: If the input is not a string. + ValueError: If the string is not a valid input for the parameter. + """ # Special case, the string may be a multiline string-array! if isinstance(string, list): @@ -189,7 +239,19 @@ def convert_string_to_value(self, parameter, string): return value def convert_value_to_string(self, parameter, value): - """Convert a valid value for the paramter to string for writing""" + """ + Converts a value to its string representation based on the parameter type. + + Args: + parameter (str): The name of the parameter. + value: The value to convert. + + Returns: + str: The string representation of the value. + + Raises: + ValueError: If the value is not valid for the parameter. + """ is_input_string = isinstance(value, str) if not self.validate_input(parameter, value): @@ -224,6 +286,16 @@ def convert_value_to_string(self, parameter, value): def _array_to_string(arr, format): + """ + Converts an array to a string representation based on the specified format. + + Args: + arr (array): The array to convert. + format (str): The format type ('integer array', 'double array', etc.). + + Returns: + str: String representation of the array. + """ arr = np.array(arr) if arr.ndim == 1: arr = arr.reshape(1, -1) diff --git a/sparc/calculator.py b/sparc/calculator.py index a6315ce2..7bc7f35f 100644 --- a/sparc/calculator.py +++ b/sparc/calculator.py @@ -1,10 +1,12 @@ import datetime import os import subprocess +import tempfile from pathlib import Path from warnings import warn, warn_explicit import numpy as np +from ase.atoms import Atoms from ase.calculators.calculator import Calculator, FileIOCalculator, all_changes from ase.units import Bohr, GPa, Hartree, eV @@ -28,15 +30,12 @@ class SPARC(FileIOCalculator): - # TODO: magmom should be a possible input + """Calculator interface to the SPARC codes via the FileIOCalculator""" + implemented_properties = ["energy", "forces", "fermi", "stress"] name = "sparc" ase_objtype = "sparc_calculator" # For JSON storage special_inputs = sparc_python_inputs - - # A "minimal" set of parameters that user can call plug-and-use - # like atoms.calc = SPARC() - # TODO: should we provide a minimal example for each system? default_params = { "xc": "pbe", "kpts": (1, 1, 1), @@ -55,11 +54,26 @@ def __init__( log="sparc.log", sparc_json_file=None, sparc_doc_path=None, + check_version=False, **kwargs, ): - # Initialize the calculator but without restart. - # Do not pass the label to the parent FileIOCalculator class to avoid issue - # Handle old restart file separatedly since we rely on the sparc_bundle to work + """ + Initialize the SPARC calculator similar to FileIOCalculator. The validator uses the JSON API guessed + from sparc_json_file or sparc_doc_path. + + Arguments: + restart (str or None): Path to the directory for restarting a calculation. If None, starts a new calculation. + directory (str or Path): Directory for SPARC calculation files. + label (str, optional): Custom label for identifying calculation files. + atoms (Atoms, optional): ASE Atoms object representing the system to be calculated. + command (str, optional): Command to execute SPARC. If None, it will be determined automatically. + psp_dir (str or Path, optional): Directory containing pseudopotentials. + log (str, optional): Name of the log file. + sparc_json_file (str, optional): Path to a JSON file with SPARC parameters. + sparc_doc_path (str, optional): Path to the SPARC doc LaTeX code for parsing parameters. + check_version (bool): Check if SPARC and document versions match + **kwargs: Additional keyword arguments to set up the calculator. + """ FileIOCalculator.__init__( self, restart=None, @@ -87,15 +101,14 @@ def __init__( # Try restarting from an old calculation and set results self._restart(restart=restart) - # Run a short test to return version of SPARC's binary - # TODO: sparc_version should allow both read from results / short stdout - self.sparc_version = self._detect_sparc_version() - # Sanitize the kwargs by converting lower -- > upper # and perform type check - # TODO: self.parameter should be the only entry self.valid_params, self.special_params = self._sanitize_kwargs(kwargs) self.log = self.directory / log if log is not None else None + if check_version: + self.sparc_version = self.detect_sparc_version() + else: + self.sparc_version = None @property def directory(self): @@ -166,8 +179,6 @@ def check_state(self, atoms, tol=1e-9): if "positions" in system_changes: atoms_copy.wrap() new_system_changes = FileIOCalculator.check_state(self, atoms_copy, tol=tol) - # TODO: make sure such check only happens for PBC - # the position is wrapped, accept as the same structure if "positions" not in new_system_changes: system_changes.remove("positions") return system_changes @@ -178,8 +189,6 @@ def _make_command(self, extras=""): Extras will add additional arguments to the self.command, e.g. -name, -socket etc - - """ if isinstance(extras, (list, tuple)): extras = " ".join(extras) @@ -209,9 +218,7 @@ def _make_command(self, extras=""): def calculate(self, atoms=None, properties=["energy"], system_changes=all_changes): """Perform a calculation step""" # Check if the user accidentally provides atoms unit cell without vacuum - if atoms and np.any(atoms.cell.cellpar()[:3] == 0): - # TODO: choose a better error name msg = "Cannot setup SPARC calculation because at least one of the lattice dimension is zero!" if any([bc_ is False for bc_ in atoms.pbc]): msg += " Please add a vacuum in the non-periodic direction of your input structure." @@ -242,24 +249,6 @@ def get_stress(self, atoms=None): ) return super().get_stress(atoms) - # atoms = self.atoms.copy() - - # def update_atoms(self, atoms): - # """Update atoms after calculation if the positions are changed - - # Idea taken from Vasp.update_atoms. - # """ - # if (self.int_params['ibrion'] is not None - # and self.int_params['nsw'] is not None): - # if self.int_params['ibrion'] > -1 and self.int_params['nsw'] > 0: - # # Update atomic positions and unit cell with the ones read - # # from CONTCAR. - # atoms_sorted = read(self._indir('CONTCAR')) - # atoms.positions = atoms_sorted[self.resort].positions - # atoms.cell = atoms_sorted.cell - - # self.atoms = atoms # Creates a copy - def _check_input_exclusion(self, input_parameters, atoms=None): """Check if mutually exclusive parameters are provided @@ -327,12 +316,6 @@ def write_input(self, atoms, properties=[], system_changes=[]): if "stress" in properties: input_parameters["CALC_STRESS"] = True - # TODO: detect if minimal values are set - - # TODO: system_changes ? - - # TODO: check parameter exclusion - self._check_input_exclusion(input_parameters, atoms=atoms) self._check_minimal_input(input_parameters) @@ -355,12 +338,10 @@ def write_input(self, atoms, properties=[], system_changes=[]): def execute(self): """Make the calculation. Note we probably need to use a better handling of background process!""" - # TODO: add -socket? extras = f"-name {self.label}" command = self._make_command(extras=extras) self.print_sysinfo(command) - # TODO: distinguish between normal process try: if self.log is not None: with open(self.log, "a") as fd: @@ -395,14 +376,11 @@ def raw_results(self, value): def read_results(self): """Parse from the SparcBundle""" - # TODO: try use cache? # self.sparc_bundle.read_raw_results() last = self.sparc_bundle.convert_to_ase(indices=-1, include_all_files=False) self.atoms = last.copy() self.results.update(last.calc.results) - # self._extract_out_results() - def _restart(self, restart=None): """Reload the input parameters and atoms from previous calculation. @@ -433,20 +411,43 @@ def get_fermi_level(self): """Extra get-method for Fermi level, if calculated""" return self.results.get("fermi", None) - def _detect_sparc_version(self): + def detect_sparc_version(self): """Run a short sparc test to determine which sparc is used""" - command = self._make_command() - - return None + try: + cmd = self._make_command() + except EnvironmentError: + return None + print("Running a short calculation to determine SPARC version....") + # check_version must be set to False to avoid recursive calling + new_calc = SPARC( + command=self.command, psp_dir=self.sparc_bundle.psp_dir, check_version=False + ) + with tempfile.TemporaryDirectory() as tmpdir: + new_calc.set(xc="pbe", h=0.3, kpts=(1, 1, 1), maxit_scf=1, directory=tmpdir) + atoms = Atoms(["H"], positions=[[0.0, 0.0, 0.0]], cell=[2, 2, 2], pbc=False) + try: + new_calc.calculate(atoms) + version = new_calc.raw_results["out"]["sparc_version"] + except Exception as e: + print("Error handling simple calculation: ", e) + version = None + # Warning information about version mismatch between binary and JSON API + # only when both are not None + if (version is None) and (self.validator.sparc_version is not None): + if version != self.validator.sparc_version: + warn( + ( + f"SPARC binary version {version} does not match JSON API version {self.validator.sparc_version}. " + "You can set $SPARC_DOC_PATH to the SPARC documentation location." + ) + ) + return version def _sanitize_kwargs(self, kwargs): """Convert known parameters from""" - # print(kwargs) - # TODO: versioned validator validator = self.validator valid_params = {} special_params = self.default_params.copy() - # TODO: how about overwriting the default parameters? # SPARC API is case insensitive for key, value in kwargs.items(): if key in self.special_inputs: @@ -460,8 +461,6 @@ def _sanitize_kwargs(self, kwargs): if validator.validate_input(key, value): valid_params[key] = value else: - # TODO: helper information - # TODO: should we raise exception instead? warn(f"Input parameter {key} does not have a valid value!") return valid_params, special_params @@ -506,7 +505,6 @@ def _convert_special_params(self, atoms=None): elif xc.lower() == "scan": converted_sparc_params["EXCHANGE_CORRELATION"] = "SCAN" else: - # TODO: alternative exception raise ValueError(f"xc keyword value {xc} is invalid!") # h --> gpts @@ -528,7 +526,6 @@ def _convert_special_params(self, atoms=None): "conversion of h to mesh grid is ignored." ) else: - # TODO: is there any limitation for parallelization? gpts = h2gpts(h, atoms.cell) params["gpts"] = gpts @@ -538,7 +535,6 @@ def _convert_special_params(self, atoms=None): if validator.validate_input("FD_GRID", gpts): converted_sparc_params["FD_GRID"] = gpts else: - # TODO: customize error raise ValueError(f"Input parameter gpts has invalid value {gpts}") # kpts @@ -548,7 +544,6 @@ def _convert_special_params(self, atoms=None): if validator.validate_input("KPOINT_GRID", kpts): converted_sparc_params["KPOINT_GRID"] = kpts else: - # TODO: customize error raise ValueError(f"Input parameter kpts has invalid value {kpts}") # nbands @@ -687,21 +682,6 @@ def estimate_memory(self, atoms=None, units="GB", **kwargs): converted_estimate = estimate * conversion_dict[units] return converted_estimate - # TODO: update method for static / geopt / aimd - def get_scf_steps(self, include_uncompleted_last_step=False): - raise NotImplemented - - @deprecated("Use SPARC.get_number_of_ionic_steps instead") - def get_geometric_steps(self, include_uncompleted_last_step=False): - raise NotImplemented - - def get_runtime(self): - raise NotImplemented - - def get_fermi_level(self): - raise NotImplemented - - # TODO: update method for static / geopt / aimd def get_scf_steps(self, include_uncompleted_last_step=False): raise NotImplemented diff --git a/sparc/docparser.py b/sparc/docparser.py index ca41ec37..129d4e30 100644 --- a/sparc/docparser.py +++ b/sparc/docparser.py @@ -28,8 +28,31 @@ sparc_repo_url = "https://github.com/SPARC-X/SPARC.git" -class SPARCDocParser(object): - """Use regex to parse LaTeX doc to python API""" +class SparcDocParser(object): + """Parses LaTeX documentation of SPARC-X and converts it into a Python API. + + This class extracts parameter information from LaTeX source files, + organizing it into a structured format that can be easily used in + Python. It supports parsing of version details, parameter types, + units, and other relevant information. + + Attributes: + version (str): Parsed SPARC version, based on the documentation. + parameter_categories (list): Categories of parameters extracted. + parameters (dict): Extracted parameters with detailed information. + other_parameters (dict): Additional parameters not categorized. + + Methods: + find_main_file(main_file_pattern): Finds the main LaTeX file based on a pattern. + get_include_files(): Retrieves a list of included LaTeX files. + parse_version(parse): Parses and sets the SPARC version. + parse_parameters(): Extracts parameters from LaTeX files. + postprocess(): Applies hard-coded post-processing to some parameters. + to_dict(): Converts parsed information into a dictionary. + json_from_directory(directory, include_subdirs, **kwargs): Class method to create JSON from a directory. + json_from_repo(url, version, include_subdirs, **kwargs): Class method to create JSON from a repository. + + """ def __init__( self, @@ -50,12 +73,12 @@ def __init__( For parameters additional to the standard SPARC options, such as the SQ / cyclix options, we merge the dict from the sub-dirs - Arguments: - `doc_root`: root directory to the LaTeX files, may look like `SPARC/doc/.LaTeX` - `main_file`: main LaTeX file for the manual - `intro_file`: LaTeX file for the introduction - `params_from_intro`: only contain the parameters that can be parsed in `intro_file` - `parse_date`: get the SPARC version by date + Args: + doc_root: root directory to the LaTeX files, may look like `SPARC/doc/.LaTeX` + main_file: main LaTeX file for the manual + intro_file: LaTeX file for the introduction + params_from_intro: only contain the parameters that can be parsed in `intro_file` + parse_date: get the SPARC version by date """ self.root = Path(directory) self.main_file = self.find_main_file(main_file) @@ -69,7 +92,18 @@ def __init__( self.postprocess() def find_main_file(self, main_file_pattern): - """Find the matching name for the main-file, e.g. Manual.tex or Manual_cyclix.tex""" + """ + Finds the main LaTeX file that matches the given pattern, e.g. Manual.tex or Manual_cyclix.te + + Args: + main_file_pattern (str): Pattern to match the main LaTeX file name. + + Returns: + Path: Path to the main LaTeX file. + + Raises: + FileNotFoundError: If no or multiple files match the pattern. + """ candidates = list(self.root.glob(main_file_pattern)) if len(candidates) != 1: raise FileNotFoundError( @@ -78,7 +112,12 @@ def find_main_file(self, main_file_pattern): return candidates[0] def get_include_files(self): - """Get a list of included LaTeX files from Manual.tex""" + """ + Retrieves a list of LaTeX files included in the main LaTeX document, e.g. Manual.tex. + + Returns: + list: A list of paths to the included LaTeX files. + """ pattern = r"\\begin\{document\}(.*?)\\end\{document\}" text = open(self.main_file, "r", encoding="utf8").read() # Only the first begin/end document will be matched @@ -100,7 +139,18 @@ def get_include_files(self): return include_files def parse_version(self, parse=True): - """Get the version (format "YYYY.MM.DD" of SPARC) from C-source file, if possible""" + """ + Parses and sets the SPARC version based on the C-source file, if possible. + The date for the SPARC code is parsed from initialization.c in the "YYYY.MM.DD" + format. + + Args: + parse (bool): Whether to parse the version from the documentation. + + Sets: + self.version (str): The parsed version in 'YYYY.MM.DD' format or None, + if either parse=False, or the C-source code is missing + """ if parse is False: self.version = None return @@ -128,13 +178,14 @@ def parse_version(self, parse=True): def __parse_parameter_from_frame(self, frame): """Parse the parameters from a single LaTeX frame - Arguments: - `frame`: a string containing the LaTeX frame (e.g. \begin{frame} ... \end{frame}) + Args: + frame (str): a string containing the LaTeX frame (e.g. \\begin{frame} ... \\end{frame}) - fields are: - name: TOL_POISSON - type: Double | Integer | String | Character | Double array - unit: specified in the doc + Returns: + dict: a key-value paired dict parsed from the frame. Some field names include: + name: TOL_POISSON + type: Double | Integer | String | Character | Double array + unit: specified in the doc """ pattern_label = r"\\texttt\{(.*?)\}.*?\\label\{(.*?)\}" pattern_block = r"\\begin\{block\}\{(.*?)\}([\s\S]*?)\\end\{block\}" @@ -174,7 +225,9 @@ def __parse_frames_from_text(self, text): """Extract all the frames that aren't commented in the text Arguments: - `text`: LaTeX text + text (str): Full LaTeX text + Returns: + list: Matched LaTeX Beamer frame fragments """ pattern_frame = r"\\begin\{frame\}(.*?)\\end\{frame\}" matches = re.findall(pattern_frame, text, re.DOTALL | re.MULTILINE) @@ -184,9 +237,9 @@ def __parse_intro_file(self): """Parse the introduction file Returns: - `parameter_dict`: dictionary using the parameter category as the main key - (following order in Introduction.tex) - `parameter_categories`: list of categories + parameter_dict (dict): dictionary using the parameter category as the main key + (following order in Introduction.tex) + parameter_categories (list): list of categories """ text_intro = open(self.intro_file, "r", encoding="utf8").read() pattern_params = ( @@ -224,7 +277,10 @@ def __parse_intro_file(self): return parameter_categories, parameter_dict def __parse_all_included_files(self): - """Pop up all known parameters from included files,""" + """Pop up all known parameters from included files + Returns: + dict: All known parameters from included files + """ all_params = {} for f in self.include_files: # Do not parse intro file since it's waste of time @@ -240,7 +296,13 @@ def __parse_all_included_files(self): return all_params def parse_parameters(self): - """The actual thing for parsing parameters""" + """The actual thing for parsing parameters + + Sets: + parameters (dict): All parsed parameters + parameter_categoris (list): List of categories + other_parameters (dict): Any parameters that are not included in the categories + """ parameter_categories, parameter_dict = self.__parse_intro_file() all_params = self.__parse_all_included_files() self.parameter_categories = parameter_categories @@ -260,21 +322,20 @@ def parse_parameters(self): for param_details in all_params.values(): symbol = param_details["symbol"] self.other_parameters[symbol] = param_details - return def postprocess(self): - """Use the hardcoded parameter correction dict to fix some issues""" + """Use the hardcoded dict prostprocess_items to fix some issues""" for param, fix in postprocess_items.items(): if param in self.parameters: self.parameters[param].update(**fix) return def to_dict(self): - """Output a json string from current document parser + """Output a json dict from current document parser - Arguments: - `indent`: whether to make the json string pretty + Returns: + dict: All API schemes in dict """ doc = {} doc["sparc_version"] = self.version @@ -284,12 +345,18 @@ def to_dict(self): k: v for k, v in sorted(self.other_parameters.items()) } doc["data_types"] = sorted(set([p["type"] for p in self.parameters.values()])) - # json_string = json.dumps(doc, indent=indent) return doc @classmethod def json_from_directory(cls, directory=".", include_subdirs=True, **kwargs): - """Recursively add parameters from all Manual files""" + """ + Recursively add parameters from all Manual files + Arguments: + directory (str or PosixPath): The directory to the LaTeX files, e.g. /doc/.LaTeX + include_subdirs (bool): If true, also parse the manual files in submodules, e.g. cyclix, highT + Returns: + str: Formatted json-string of the API + """ directory = Path(directory) root_dict = cls(directory=directory, **kwargs).to_dict() if include_subdirs: @@ -300,8 +367,6 @@ def json_from_directory(cls, directory=".", include_subdirs=True, **kwargs): except FileNotFoundError: print(subdir) continue - # We only merge the parameters that have not appeared in the main - # manual. TODO: maybe assign repeating parameters to a different section? for param, param_desc in sub_dict["parameters"].items(): if param not in root_dict["parameters"]: root_dict["parameters"][param] = param_desc @@ -312,7 +377,15 @@ def json_from_directory(cls, directory=".", include_subdirs=True, **kwargs): def json_from_repo( cls, url=sparc_repo_url, version="master", include_subdirs=True, **kwargs ): - """Download the source code from git and use json_from_directory to parse""" + """ + Download the source code from git and use json_from_directory to parse + Arguments: + url (str): URL for the repository of SPARC, default is "https://github.com/SPARC-X/SPARC.git" + version (str): Git version or commit hash of the SPARC repo + include_subdirs (bool): If true, also parse the manual files in submodules, e.g. cyclix, highT + Returns: + str: Formatted json-string of the API + """ import tempfile from subprocess import run @@ -335,7 +408,13 @@ def json_from_repo( def convert_tex_parameter(text): - """Conver a TeX string to non-escaped name (for parameter only)""" + """ + Conver a TeX string to non-escaped name (for parameter only) + Arguments: + text (str): Parameter name in LaTeX format + Returns: + str: Text with sanitized parameter + """ return text.strip().replace("\_", "_") @@ -343,6 +422,10 @@ def convert_tex_example(text): """Convert TeX codes of examples as much as possible The examples follow the format SYMBOL: values (may contain new lines) + Arguments: + text (str): Single or multiline LaTeX contents + Returns: + str: Sanitized literal text """ mapper = {"\\texttt{": "", "\_": "_", "}": "", "\\": "\n"} new_text = copy(text) @@ -369,6 +452,13 @@ def convert_tex_default(text, desired_type=None): 1. Remove all surrounding text modifiers (texttt) 2. Remove all symbol wrappers $ 3. Convert value to single or array + + Arguments: + text (str): Raw text string for value + desired_type (str or None): Data type to be converted to. If None, preserve the string format + + Returns: + converted: Value converted from raw text """ mapper = { "\\texttt{": "", @@ -410,6 +500,12 @@ def convert_tex_default(text, desired_type=None): def convert_comment(text): """Used to remove TeX-specific commands in description and remarks as much as possible + + Arguments: + text (str): Raw LaTeX code for the comment section in manual + + Returns: + str: Sanitized plain text """ mapper = { "\\texttt{": "", @@ -433,6 +529,16 @@ def convert_comment(text): def text2value(text, desired_type): + """Convert raw text to a desired type + + Arguments: + text (str): Text contents for the value + desired_type (str): Target data type from 'string', 'integer', + 'integer array', 'double', 'double array', + 'bool', 'bool array' + Returns: + converted: Value converted to the desired type + """ if desired_type is None: return text desired_type = desired_type.lower() @@ -503,7 +609,15 @@ def contain_only_bool(text): def sanitize_description(param_dict): - """Sanitize the description and remark field""" + """Sanitize the description and remark field + + Arguments: + param_dict (dict): Raw dict for one parameter entry + + Returns: + dict: Sanitized parameter dict with comment, remark and description + converted to human-readable formats + """ sanitized_dict = param_dict.copy() original_desc = sanitized_dict["description"] @@ -632,11 +746,11 @@ def sanitize_type(param_dict): root = sparc_repo_url else: root = args.root - json_string = SPARCDocParser.json_from_repo( + json_string = SparcDocParser.json_from_repo( url=root, version=args.version, include_subdirs=args.include_subdirs ) else: - json_string = SPARCDocParser.json_from_directory( + json_string = SparcDocParser.json_from_directory( directory=Path(args.root), include_subdirs=args.include_subdirs ) with open(output, "w", encoding="utf8") as fd: diff --git a/sparc/download_data.py b/sparc/download_data.py index 143d0503..7f027754 100644 --- a/sparc/download_data.py +++ b/sparc/download_data.py @@ -24,10 +24,15 @@ def download_psp(sparc_tag=sparc_tag, psp_dir=psp_dir): - """Download the external PSPs into the sparc/psp folder""" + """Download the external PSPs into the sparc/psp folder + + Arguments: + sparc_tag (str): Commit hash or git tag for the psp files + psp_dir (str or PosixPath): Directory to download the psp files + """ if is_psp_download_complete(): print("PSPs have been successfully downloaded!") - return True + return download_url = sparc_source_url.format(sparc_tag=sparc_tag) print(f"Download link: {download_url}") with tempfile.TemporaryDirectory() as tmpdir: @@ -47,11 +52,20 @@ def download_psp(sparc_tag=sparc_tag, psp_dir=psp_dir): shutil.copy(pspf, psp_dir) if not is_psp_download_complete(psp_dir): raise RuntimeError(f"Files downloaded to {psp_dir} have different checksums!") - return True + return def checksum_all(psp_dir=psp_dir, extension="*.psp8"): - """Checksum all the files under the psp_dir""" + """Checksum all the files under the psp_dir to make sure the psp8 files + are the same as intended + + Arguments: + psp_dir (str or PosixPath): Directory for the psp files + extension (str): Search pattern for the psp files, either '.psp', '.psp8' or '.pot' + + Returns: + str: Checksum for all the files concatenated + """ checker = hashlib.md5() psp_dir = Path(psp_dir) # Use sorted to make sure file order is correct diff --git a/sparc/io.py b/sparc/io.py index 8a8c6963..e8bd2171 100644 --- a/sparc/io.py +++ b/sparc/io.py @@ -14,7 +14,6 @@ import numpy as np from ase.atoms import Atoms from ase.calculators.singlepoint import SinglePointDFTCalculator -from ase.units import GPa, Hartree # various io formatters from .api import SparcAPI @@ -28,7 +27,7 @@ from .sparc_parsers.out import _read_out from .sparc_parsers.pseudopotential import copy_psp_file, parse_psp8_header from .sparc_parsers.static import _read_static -from .utils import deprecated, string2index, locate_api +from .utils import deprecated, locate_api, string2index # from .sparc_parsers.ion import read_ion, write_ion defaultAPI = SparcAPI() @@ -43,20 +42,43 @@ class SparcBundle: Currently the write method only supports 1 image, while read method support reading atoms results in following conditions - 1) No calculation (minimal): .ion + .inpt file --> 1 image 2) - Single point calculation: .ion + .inpt + .out + .static --> 1 - image with calc 3) Multiple SP calculations: chain all + 1) No calculation (minimal): .ion + .inpt file --> 1 image + 2) Single point calculation: .ion + .inpt + .out + .static --> 1 + image with calc + 3) Multiple SP calculations: chain all .out{digits} and .static{digitis} outputs 4) Relaxation: read from .geopt and .out (supporting chaining) 5) AIMD: read from .aimd and .out (support chaining) - Currently, the bundle object is intended to be used for one-time - read / write - - TODO: multiple occurance support - TODO: archive support - + Attributes: + directory (Path): Path to the directory containing SPARC files. + mode (str): File access mode ('r', 'w', or 'a'). + label (str): Name of the main SPARC file. + init_atoms (Atoms): Initial atomic configuration. + init_inputs (dict): Initial input parameters. + psp_data (dict): Pseudopotential data. + raw_results (dict): Raw results from SPARC calculations. + psp_dir (Path): Directory containing pseudopotentials. + sorting (list): Sort order for atoms. + last_image (int): Index of the last image in a series of calculations. + validator (SparcAPI): API validator for SPARC calculations. + + Methods: + __find_psp_dir(psp_dir=None): Finds the directory for SPARC pseudopotentials. + _find_files(): Finds all files matching the bundle label. + _make_label(label=None): Infers or sets the label for the SPARC bundle. + _indir(ext, label=None, occur=0, d_format="{:02d}"): Finds a file with a specific extension in the bundle. + _read_ion_and_inpt(): Reads .ion and .inpt files together. + _write_ion_and_inpt(): Writes .ion and .inpt files to the bundle. + _read_results_from_index(index, d_format="{:02d}"): Reads results from a specific calculation index. + _make_singlepoint(calc_results, images, raw_results): Converts results and images to SinglePointDFTCalculators. + _extract_static_results(raw_results, index=":"): Extracts results from static calculations. + _extract_geopt_results(raw_results, index=":"): Extracts results from geometric optimization calculations. + _extract_aimd_results(raw_results, index=":"): Extracts results from AIMD calculations. + convert_to_ase(index=-1, include_all_files=False, **kwargs): Converts raw results to ASE Atoms with calculators. + read_raw_results(include_all_files=False): Parses all files in the bundle and merges results. + read_psp_info(): Parses pseudopotential information from the inpt file. """ psp_env = ["SPARC_PSP_PATH", "SPARC_PP_PATH"] @@ -70,6 +92,22 @@ def __init__( psp_dir=None, validator=defaultAPI, ): + """ + Initializes a SparcBundle for accessing SPARC calculation data. + + Args: + directory (str or Path): The path to the directory containing the SPARC files. + mode (str, optional): The file access mode. Can be 'r' (read), 'w' (write), or 'a' (append). Defaults to 'r'. + atoms (Atoms, optional): The initial atomic configuration. Only relevant in write mode. + label (str, optional): A custom label for the bundle. If None, the label is inferred from the directory or files. + psp_dir (str or Path, optional): Path to the directory containing pseudopotentials. If None, the path is inferred. + validator (SparcAPI, optional): An instance of SparcAPI for validating and parsing SPARC parameters. Defaults to a default SparcAPI instance. + + Raises: + AssertionError: If an invalid mode is provided. + ValueError: If multiple .ion files are found and no label is specified. + Warning: If no .ion file is found in read-mode, or illegal characters are in the label. + """ self.directory = Path(directory) self.mode = mode.lower() assert self.mode in ( @@ -77,8 +115,7 @@ def __init__( "w", "a", ), f"Invalid mode {self.mode}! Must one of 'r', 'w' or 'a'" - self.label = self._make_label(label) # name of the main sparc file - # TODO: assigning atoms here is probably not useful! + self.label = self._make_label(label) self.init_atoms = atoms.copy() if atoms is not None else None self.init_inputs = {} self.psp_data = {} @@ -99,8 +136,10 @@ def _make_label(self, label=None): Special cases if label is None: 1. read mode --> get the ion file name 2. write mode --> infer from the directory + + Arguments: + label (str or None): Label to be used to write the .ion, .inpt files """ - # TODO: more sensible naming for name? prefix = self.directory.resolve().with_suffix("").name illegal_chars = '\\/:*?"<>|' @@ -112,7 +151,6 @@ def _make_label(self, label=None): # read match_ion = list(self.directory.glob("*.ion")) if len(match_ion) > 1: - # TODO: customize error msg raise ValueError( "Cannot read sparc bundle with multiple ion files without specifying the label!" ) @@ -139,6 +177,12 @@ def __find_psp_dir(self, psp_dir=None): 2. $SPARC_PSP_PATH 3. $SPARC_PP_PATH 4. psp bundled with sparc-api + + Arguments: + psp_dir (str or PosixPath or None): the specific directory to search the psp files. + Each element can only have 1 psp file under psp_dir + Returns: + PosixPath: Location of psp files """ if psp_dir is not None: return Path(psp_dir) @@ -170,10 +214,17 @@ def __find_psp_dir(self, psp_dir=None): return None def _indir(self, ext, label=None, occur=0, d_format="{:02d}"): - """Find the file with {label}.{ext} under current dir - + """Find the file with {label}.{ext} under current dir, if label is None, use the default - # TODO: how about recursive? + + Arguments: + ext (str): Extension of file, e.g. '.ion' or 'ion' + label (str or None): Label for the file. If None, use the parent directory name for searching + occur (int): Occurance index of the file, if occur > 0, search for files with suffix like 'SPARC.out_01' + d_format (str): Format for the index + + Returns: + PosixPath: Path to the target file under self.directory """ label = self.label if label is None else label if not ext.startswith("."): @@ -185,9 +236,10 @@ def _indir(self, ext, label=None, occur=0, d_format="{:02d}"): return target def _read_ion_and_inpt(self): - """Read the ion and inpt files together + """Read the ion and inpt files together to obtain basic atomstic data. - This method should be rarely used + Returns: + Atoms: atoms object from .ion and .inpt file """ f_ion, f_inpt = self._indir(".ion"), self._indir(".inpt") ion_data = _read_ion(f_ion, validator=self.validator) @@ -218,6 +270,17 @@ def _write_ion_and_inpt( there will only be .ion writing the positions and .inpt writing a minimal cell information + Args: + atoms (Atoms, optional): The Atoms object to write. If None, uses initialized atoms associated with SparcBundle. + label (str, optional): Custom label for the written files. + direct (bool, optional): If True, writes positions in direct coordinates. + sort (bool, optional): If True, sorts atoms before writing. + ignore_constraints (bool, optional): If True, ignores constraints on atoms. + wrap (bool, optional): If True, wraps atoms into the unit cell. + **kwargs: Additional keyword arguments for writing. + + Raises: + ValueError: If the bundle is not in write mode. """ if self.mode != "w": raise ValueError( @@ -225,7 +288,6 @@ def _write_ion_and_inpt( ) os.makedirs(self.directory, exist_ok=True) atoms = self.atoms.copy() if atoms is None else atoms.copy() - # TODO: make the parameter more explicit pseudopotentials = kwargs.pop("pseudopotentials", {}) if sort: @@ -244,9 +306,7 @@ def _write_ion_and_inpt( ) merged_inputs = input_parameters.copy() merged_inputs.update(kwargs) - # TODO: special input param handling data_dict["inpt"]["params"].update(merged_inputs) - # TODO: label # If copy_psp, change the PSEUDO_POT field and copy the files if copy_psp: @@ -265,13 +325,17 @@ def read_raw_results(self, include_all_files=False): """Parse all files using the given self.label. The results are merged dict from all file formats - Argument all_files: True --> include all files (out, out_01, - out_02, etc) when all files are included, output is a list of - dicts; otherwise a single dict + Arguments: + include_all_files (bool): Whether to include output files with different suffices + If true: include all files (e.g. SPARC.out, SPARC.out_01, + SPARC.out_02, etc). + Returns: + dict or List: Dict containing all raw results. Only some of them will appear in the calculator's results + Sets: + self.raw_results (dict or List): the same as the return value """ # Find the max output index - # TODO: move this into another function last_out = sorted(self.directory.glob(f"{self.label}.out*"), reverse=True) # No output file, only ion / inpt if len(last_out) == 0: @@ -284,8 +348,6 @@ def read_raw_results(self, include_all_files=False): self.last_image = int(suffix.split("_")[1]) self.num_calculations = self.last_image + 1 - # print(self.last_image, self.num_calculations) - if include_all_files: results = [ self._read_results_from_index(index) @@ -298,12 +360,9 @@ def read_raw_results(self, include_all_files=False): if include_all_files: init_raw_results = self.raw_results[0] - # self.sorting = self.raw_results[0]["ion"]["sorting"] else: init_raw_results = self.raw_results.copy() - # self.sorting = self.raw_results["ion"]["sorting"] - # TODO: init is actually last! self.init_atoms = dict_to_atoms(init_raw_results) self.init_inputs = { "ion": init_raw_results["ion"], @@ -314,7 +373,16 @@ def read_raw_results(self, include_all_files=False): def _read_results_from_index(self, index, d_format="{:02d}"): """Read the results from one calculation index, and return a - single raw result dict""" + single raw result dict + + Arguments: + index (int): Index of image to return the results + d_format (str): Format for the index suffix + + Returns: + dict: Results for single image + + """ results_dict = {} for ext in ("ion", "inpt"): @@ -329,7 +397,6 @@ def _read_results_from_index(self, index, d_format="{:02d}"): results_dict.update(data_dict) # Must have files: ion, inpt - # TODO: make another function to check sanity if ("ion" not in results_dict) or ("inpt" not in results_dict): raise RuntimeError( "Either ion or inpt files are missing from the bundle! " @@ -352,7 +419,12 @@ def convert_to_ase(self, index=-1, include_all_files=False, **kwargs): """Read the raw results from the bundle and create atoms with single point calculators - TODO: what to do about the indices? + Arguments: + index (int or str): Index or slice of the image(s) to convert. Uses the same format as ase.io.read + include_all_files (bool): If true, also read results with indexed suffices + + Returns: + Atoms or List[Atoms]: ASE-atoms or images with single point results """ # Convert to images! @@ -362,7 +434,6 @@ def convert_to_ase(self, index=-1, include_all_files=False, **kwargs): else: raw_results = list(rs) res_images = [] - # print("RAW RES: ", raw_results) for entry in raw_results: if "static" in entry: calc_results, images = self._extract_static_results(entry, index=":") @@ -390,6 +461,14 @@ def _make_singlepoint(self, calc_results, images, raw_results): The calculator also takes parameters from ion, inpt that exist in self.raw_results + Arguments: + calc_results (List): Calculation results for all images + images (List): Corresponding Atoms images + raw_results (List): Full raw results dict to obtain additional information + + Returns: + List(Atoms): ASE-atoms images with single point calculators attached + """ converted_images = [] for res, _atoms in zip(calc_results, images): @@ -417,6 +496,13 @@ def _extract_static_results(self, raw_results, index=":"): Note: make all energy / forces resorted! + Arguments: + raw_results (dict): Raw results parsed from self.read_raw_results + index (str or int): Index or slice of images + + Returns: + List[results], List[Atoms] + """ # TODO: implement the multi-file static static_results = raw_results.get("static", {}) @@ -455,6 +541,13 @@ def _extract_geopt_results(self, raw_results, index=":"): value atoms: ASE atoms object The priority is to parse position from static file first, then fallback from ion + inpt + Arguments: + raw_results (dict): Raw results parsed from self.read_raw_results + index (str or int): Index or slice of images + + Returns: + List[results], List[Atoms] + """ # print("RAW_RES: ", raw_results) geopt_results = raw_results.get("geopt", []) @@ -476,11 +569,9 @@ def _extract_geopt_results(self, raw_results, index=":"): partial_result = {} if "energy" in result: partial_result["energy"] = result["energy"] - # TODO: shall we distinguish? partial_result["free energy"] = result["energy"] if "forces" in result: - # TODO: what about non-sorted calculations partial_result["forces"] = result["forces"][self.resort] if "stress" in result: @@ -547,6 +638,13 @@ def _extract_aimd_results(self, raw_results, index=":"): We probably want more information for the AIMD calculations, but I'll keep them for now + Arguments: + raw_results (dict): Raw results parsed from self.read_raw_results + index (str or int): Index or slice of images + + Returns: + List[results], List[Atoms] + """ aimd_results = raw_results.get("aimd", []) calc_results = [] @@ -586,9 +684,6 @@ def _extract_aimd_results(self, raw_results, index=":"): result["positions"][self.resort], apply_constraint=False ) - # TODO: need to get an example for NPT MD to set Cell - # TODO: need to set stress information - if "velocities" in result: atoms.set_velocities(result["velocities"][self.resort]) @@ -596,32 +691,9 @@ def _extract_aimd_results(self, raw_results, index=":"): calc_results.append(partial_result) return calc_results, ase_images - # def get_ionic_steps(self, raw_results): - # """Get last ionic step dict from raw results""" - # out_results = raw_results.get("out", {}) - # ionic_steps = out_results.get("ionic_steps", []) - # return ionic_steps - - # def _extract_output_results(self, raw_results): - # """Extract extra information from results, need to be more polished - # (maybe move to calculator?) - # """ - # last_step = self.get_ionic_step(raw_results)[-1] - # if "fermi level" in last_step: - # value = last_step["fermi level"]["value"] - # unit = last_step["fermi level"]["unit"] - # if unit.lower() == "ev": - # self.results["fermi"] = value - # # Should rarely happen, but keep it here! - # elif unit.lower() == "hartree": - # self.results["fermi"] = value * Hartree - # else: - # raise ValueError("Wrong unit in Fermi!") - # return - @property def sort(self): - """wrap the self.sorting dict. If sorting information does not exist, + """Wrap the self.sorting dict. If sorting information does not exist, use the default slicing """ @@ -635,7 +707,7 @@ def sort(self): @property def resort(self): - """wrap the self.sorting dict. If sorting information does not exist, + """Wrap the self.sorting dict. If sorting information does not exist, use the default slicing """ @@ -675,6 +747,15 @@ def read_sparc(filename, index=-1, include_all_files=False, **kwargs): """Parse a SPARC bundle, return an Atoms object or list of Atoms (image) with embedded calculator result. + Arguments: + filename (str or PosixPath): Filename to the sparc bundle + index (int or str): Index or slice of the images, following the ase.io.read convention + include_all_files (bool): If true, parse all output files with indexed suffices + **kwargs: Additional parameters + + Returns: + Atoms or List[Atoms] + """ # We rely on minimal api version choose, i.e. default or set from env api = locate_api() @@ -688,6 +769,11 @@ def read_sparc(filename, index=-1, include_all_files=False, **kwargs): def write_sparc(filename, images, **kwargs): """Write sparc file. Images can only be Atoms object or list of length 1 + + Arguments: + filename (str or PosixPath): Filename to the output sparc directory + images (Atoms or List(Atoms)): Atoms object to be written. Only supports writting 1 Atoms + **kwargs: Additional parameters """ if isinstance(images, Atoms): atoms = images @@ -702,13 +788,21 @@ def write_sparc(filename, images, **kwargs): @deprecated( - "Reading individual .ion is not recommended. Please use read_sparc instead." + "Reading individual .ion file is not recommended. Please use read_sparc instead." ) def read_ion(filename, **kwargs): """Parse an .ion file inside the SPARC bundle using a wrapper around SparcBundle The reader works only when other files (.inpt) exist. The returned Atoms object of read_ion method only contains the initial positions + + Arguments: + filename (str or PosixPath): Filename to the .ion file + index (int or str): Index or slice of the images, following the ase.io.read convention + **kwargs: Additional parameters + + Returns: + Atoms or List[Atoms] """ api = locate_api() parent_dir = Path(filename).parent @@ -721,9 +815,14 @@ def read_ion(filename, **kwargs): "Writing individual .ion file is not recommended. Please use write_sparc instead." ) def write_ion(filename, atoms, **kwargs): - """Write .ion and .inpt files using the SparcBundle wrapper. + """Write .ion file using the SparcBundle wrapper. This method will also create the .inpt file This is only for backward compatibility + + Arguments: + filename (str or PosixPath): Filename to the .ion file + atoms (Atoms): atoms to be written + **kwargs: Additional parameters """ label = Path(filename).with_suffix("").name parent_dir = Path(filename).parent @@ -733,9 +832,20 @@ def write_ion(filename, atoms, **kwargs): return atoms +@deprecated( + "Reading individual .static file is not recommended. Please use read_sparc instead." +) def read_static(filename, index=-1, **kwargs): """Parse a .static file bundle using a wrapper around SparcBundle The reader works only when other files (.ion, .inpt) exist. + + Arguments: + filename (str or PosixPath): Filename to the .static file + index (int or str): Index or slice of the images, following the ase.io.read convention + **kwargs: Additional parameters + + Returns: + Atoms or List[Atoms] """ parent_dir = Path(filename).parent api = locate_api() @@ -744,9 +854,20 @@ def read_static(filename, index=-1, **kwargs): return atoms_or_images +@deprecated( + "Reading individual .geopt file is not recommended. Please use read_sparc instead." +) def read_geopt(filename, index=-1, **kwargs): """Parse a .geopt file bundle using a wrapper around SparcBundle The reader works only when other files (.ion, .inpt) exist. + + Arguments: + filename (str or PosixPath): Filename to the .geopt file + index (int or str): Index or slice of the images, following the ase.io.read convention + **kwargs: Additional parameters + + Returns: + Atoms or List[Atoms] """ parent_dir = Path(filename).parent api = locate_api() @@ -755,9 +876,20 @@ def read_geopt(filename, index=-1, **kwargs): return atoms_or_images +@deprecated( + "Reading individual .aimd file is not recommended. Please use read_sparc instead." +) def read_aimd(filename, index=-1, **kwargs): """Parse a .static file bundle using a wrapper around SparcBundle The reader works only when other files (.ion, .inpt) exist. + + Arguments: + filename (str or PosixPath): Filename to the .aimd file + index (int or str): Index or slice of the images, following the ase.io.read convention + **kwargs: Additional parameters + + Returns: + Atoms or List[Atoms] """ parent_dir = Path(filename).parent api = locate_api() diff --git a/sparc/utils.py b/sparc/utils.py index c088e29f..617c4afe 100644 --- a/sparc/utils.py +++ b/sparc/utils.py @@ -11,7 +11,7 @@ import numpy as np from .api import SparcAPI -from .docparser import SPARCDocParser +from .docparser import SparcDocParser def deprecated(message): @@ -138,20 +138,20 @@ def locate_api(json_file=None, doc_path=None): return api if doc_path is None: - doc_root = os.environ.get("SPARC_DOC_PATH", None) - if doc_root is not None: - doc_path = Path(doc_root) / ".LaTeX" - else: - doc_path = Path(doc_path) - - if (doc_path is not None) and doc_path.is_dir(): + doc_path = os.environ.get("SPARC_DOC_PATH", None) + + if (doc_path is not None) and Path(doc_path).is_dir(): try: with tempfile.TemporaryDirectory() as tmpdir: tmpdir = Path(tmpdir) tmpfile = tmpdir / "parameters.json" with open(tmpfile, "w") as fd: - fd.write(SPARCDocParser.json_from_directory(doc_path, include_subdirs=True)) - api = SparcAPI(tmpfile) + fd.write( + SparcDocParser.json_from_directory( + Path(doc_path), include_subdirs=True + ) + ) + api = SparcAPI(tmpfile) return api except Exception: pass diff --git a/tests/test_api_version.py b/tests/test_api_version.py index 8d862434..bf7e8189 100644 --- a/tests/test_api_version.py +++ b/tests/test_api_version.py @@ -1,13 +1,13 @@ import os -from packaging import version from pathlib import Path import pytest +from packaging import version curdir = Path(__file__).parent -def test_sparc_api(): +def test_sparc_api(monkeypatch): from sparc.api import SparcAPI from sparc.utils import locate_api @@ -18,4 +18,26 @@ def test_sparc_api(): # Version not detected since src not presented older_ver = locate_api(doc_path=curdir / "sparc-latex-doc-202302").sparc_version assert older_ver is None + # Specify SPARC_DOC_PATH + monkeypatch.setenv( + "SPARC_DOC_PATH", (curdir / "sparc-latex-doc-202302").resolve().as_posix() + ) + older_version = locate_api().sparc_version + assert older_version is None + + +def test_sparc_params(): + if "SPARC_DOC_PATH" not in os.environ: + pytest.skip(msg="No $SPARC_DOC_PATH set. Skip") + + from sparc.utils import locate_api + + # Use the default api with SPARC_DOC_PATH + api = locate_api() + if api.sparc_version is None: + pytest.skip(msg="SPARC version not known. skip") + if version.parse(api.sparc_version) > version.parse("2023.09.01"): + assert "NPT_SCALE_VECS" in api.parameters + assert "NPT_SCALE_CONSTRAINTS" in api.parameters + assert "TWIST_ANGLE" in api.parameters diff --git a/tests/test_calculator.py b/tests/test_calculator.py index c0378b75..45663364 100644 --- a/tests/test_calculator.py +++ b/tests/test_calculator.py @@ -207,3 +207,36 @@ def test_cache_results(): energy = nh3.get_potential_energy() static_files = list(Path(tmpdir).glob("*.static*")) assert len(static_files) == 1 + + +def test_sparc_version(): + from pathlib import Path + + from ase.build import molecule + + from sparc.calculator import SPARC + + h2 = molecule("H2", cell=[6, 6, 6], pbc=False) + dummy_calc = SPARC() + try: + cmd = dummy_calc._make_command() + except EnvironmentError: + print("Skip test since no sparc command found") + return + with tempfile.TemporaryDirectory() as tmpdir: + calc = SPARC( + h=0.3, kpts=(1, 1, 1), xc="pbe", print_forces=True, directory=tmpdir + ) + version = calc.detect_sparc_version() + assert version is not None + assert calc.directory == Path(tmpdir) + assert calc.sparc_version is None + calc1 = SPARC( + h=0.3, + kpts=(1, 1, 1), + xc="pbe", + print_forces=True, + directory=tmpdir, + check_version=True, + ) + assert calc1.sparc_version is not None diff --git a/tests/test_docparser.py b/tests/test_docparser.py index 8804e5e7..15c3bf8e 100644 --- a/tests/test_docparser.py +++ b/tests/test_docparser.py @@ -11,31 +11,31 @@ def test_docparser_init_wrong(fs): """Mimic situations where docparser inits at wrong file structure""" - from sparc.docparser import SPARCDocParser + from sparc.docparser import SparcDocParser # Case 1: not a doc structure with pytest.raises(FileNotFoundError): - sp = SPARCDocParser("/tmp") + sp = SparcDocParser("/tmp") # Case 2: missing manual fs.create_dir(".LaTeX") fs.create_file(".LaTeX/Manual.tex") with pytest.raises(FileNotFoundError): - sp = SPARCDocParser(".LaTeX") + sp = SparcDocParser(".LaTeX") # Case 3: missing manual fs.create_file(".LaTeX/Introduction.tex") with pytest.raises(Exception): - sp = SPARCDocParser(".LaTeX") + sp = SparcDocParser(".LaTeX") def test_docparser_init_working(): """Mimic a working doc parser""" - from sparc.docparser import SPARCDocParser + from sparc.docparser import SparcDocParser # Should work assert test_doc_dir.is_dir() - sp = SPARCDocParser(directory=test_doc_dir) + sp = SparcDocParser(directory=test_doc_dir) assert all([f.name != "SQ.tex" for f in sp.include_files]) # No source code, date version will be None @@ -49,7 +49,7 @@ def test_docparser_init_working(): def test_version_parser(fs, monkeypatch): """Only parse version""" - from sparc.docparser import SPARCDocParser + from sparc.docparser import SparcDocParser content_init_c = """void write_output_init(SPARC_OBJ *pSPARC) { int i, j, nproc, count; @@ -94,8 +94,8 @@ def test_version_parser(fs, monkeypatch): def mock_init(self): self.root = Path("doc/.LaTeX") - monkeypatch.setattr(SPARCDocParser, "__init__", mock_init) - sp = SPARCDocParser() + monkeypatch.setattr(SparcDocParser, "__init__", mock_init) + sp = SparcDocParser() with open("src/initialization.c", "w") as fd: fd.write(content_init_c) sp.parse_version(parse=False) @@ -106,9 +106,9 @@ def mock_init(self): def test_include_files(): """Test only include files""" - from sparc.docparser import SPARCDocParser + from sparc.docparser import SparcDocParser - sp = SPARCDocParser(test_doc_dir) + sp = SparcDocParser(test_doc_dir) with pytest.warns(UserWarning): sp.get_include_files() @@ -117,9 +117,9 @@ def test_json(): """json formatter""" import json - from sparc.docparser import SPARCDocParser + from sparc.docparser import SparcDocParser - sp = SPARCDocParser(test_doc_dir) + sp = SparcDocParser(test_doc_dir) # json_string = sp.to_json() loaded = sp.to_dict() assert all( @@ -137,9 +137,9 @@ def test_json(): def test_class_load(): - from sparc.docparser import SPARCDocParser + from sparc.docparser import SparcDocParser - sp = SPARCDocParser.json_from_directory(test_doc_dir) + sp = SparcDocParser.json_from_directory(test_doc_dir) def test_text2value(): diff --git a/tests/test_import.py b/tests/test_import.py index 474701d6..cd33d2ec 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -26,4 +26,4 @@ def test_docparser(monkeypatch): monkeypatch.setitem(sys.modules, "ase", None) with pytest.raises(ImportError): import ase - from sparc.docparser import SPARCDocParser + from sparc.docparser import SparcDocParser diff --git a/tests/test_read_all_examples.py b/tests/test_read_all_examples.py index 6f51f9c1..3ce67d86 100644 --- a/tests/test_read_all_examples.py +++ b/tests/test_read_all_examples.py @@ -36,7 +36,12 @@ def test_read_all_tests(): """Search all .inpt files within the tests dir.""" from sparc.io import read_sparc + from sparc.utils import locate_api + import os + print("SPARC_DOC_PATH is: ", os.environ.get("SPARC_DOC_PATH", None)) + api = locate_api() + print(api.sparc_version) skipped_names = [] tests_dir = os.environ.get("SPARC_TESTS_DIR", "") print(f"Current test dir is {tests_dir}") diff --git a/tests/test_sparc_bundle.py b/tests/test_sparc_bundle.py index 548ea60a..b5976b10 100644 --- a/tests/test_sparc_bundle.py +++ b/tests/test_sparc_bundle.py @@ -20,20 +20,20 @@ def test_bundle_psp(monkeypatch): from sparc.io import SparcBundle - os.environ.pop("SPARC_PP_PATH", None) - os.environ.pop("SPARC_PSP_PATH", None) + monkeypatch.delenv("SPARC_PP_PATH", raising=False) + monkeypatch.delenv("SPARC_PSP_PATH", raising=False) sb = SparcBundle(directory=test_output_dir / "Cu_FCC.sparc") with pytest.warns(UserWarning, match="re-download"): sb = SparcBundle(directory=test_output_dir / "Cu_FCC.sparc", mode="w") assert sb.psp_dir is None - os.environ["SPARC_PP_PATH"] = test_psp_dir.as_posix() + monkeypatch.setenv("SPARC_PP_PATH", test_psp_dir.as_posix()) sb = SparcBundle(directory=test_output_dir / "Cu_FCC.sparc") assert sb.psp_dir.resolve() == test_psp_dir.resolve() # SPARC_PSP_PATH has higher priority - os.environ["SPARC_PSP_PATH"] = test_psp_dir.parent.as_posix() + monkeypatch.setenv("SPARC_PSP_PATH", test_psp_dir.parent.as_posix()) sb = SparcBundle(directory=test_output_dir / "Cu_FCC.sparc") assert sb.psp_dir.resolve() == test_psp_dir.parent.resolve() @@ -54,8 +54,8 @@ def _fake_psp_check(directory): from sparc.common import psp_dir as default_psp_dir from sparc.io import SparcBundle - os.environ.pop("SPARC_PP_PATH", None) - os.environ.pop("SPARC_PSP_PATH", None) + monkeypatch.delenv("SPARC_PP_PATH", raising=False) + monkeypatch.delenv("SPARC_PSP_PATH", raising=False) sb = SparcBundle(directory=test_output_dir / "Cu_FCC.sparc") assert Path(sb.psp_dir).resolve() == Path(default_psp_dir).resolve() @@ -151,7 +151,7 @@ def test_write_ion_inpt(fs): # Copy psp should have the psps available -def test_write_ion_inpt_real(): +def test_write_ion_inpt_real(monkeypatch): """Same example as in test_parse_atoms but try writing inpt and atoms""" from ase.build import bulk from ase.units import Angstrom, Bohr @@ -159,8 +159,8 @@ def test_write_ion_inpt_real(): from sparc.io import SparcBundle # Even without SPARC_PP_PATH, the psp files should exist - os.environ.pop("SPARC_PP_PATH", None) - os.environ.pop("SPARC_PSP_PATH", None) + monkeypatch.delenv("SPARC_PP_PATH", raising=False) + monkeypatch.delenv("SPARC_PSP_PATH", raising=False) atoms = bulk("Cu") * [4, 4, 4] with tempfile.TemporaryDirectory() as tmpdir: