diff --git a/setup.py b/setup.py index 77ad5d29..0e5782c2 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,7 @@ # ASE 3.22 dependency will be deprecated in 1.1.0+ release install_requires=["ase>=3.22.0", "numpy>=1.23", "packaging>=20.0", "psutil>=5.0.0"], entry_points={ + # TODO: deprecate "ase.io": [ "sparc = sparc.io", ], diff --git a/sparc/api.py b/sparc/api.py index 31593d43..910601c5 100644 --- a/sparc/api.py +++ b/sparc/api.py @@ -44,6 +44,10 @@ 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"] + # TT: 2024-10-31 add the sources to trace the origin + # locate_api can modify self.source if it is deferred from LaTeX + # at runtime + self.source = {"path": json_api.as_posix(), "type": "json"} def get_parameter_dict(self, parameter): """ diff --git a/sparc/quicktest.py b/sparc/quicktest.py index 1ab7e90f..9863d5f8 100644 --- a/sparc/quicktest.py +++ b/sparc/quicktest.py @@ -1,118 +1,344 @@ """A simple test module for sparc python api Usage: python -m sparc.quicktest -A few tests will be performed -1) Is the sparc format recognizable by ASE -2) Is the command properly set -3) Is the psp directory accessible -4) Is the json api accessible -5) Can the command actually run a very simple calculation """ +from pathlib import Path + +from ase.data import chemical_symbols + from .utils import cprint -def import_test(): - cprint("Testing import...", color="COMMENT") - from ase.io.formats import ioformats +class BaseTest(object): + """Base class for all tests providing functionalities + + Each child class will implement its own `run_test` method to + update the `result`, `error_handling` and `info` fields. + + If you wish to include a simple error handling message for each + child class, add a line starting `Error handling` follows by the + helper message at the end of the docstring + """ + + def __init__(self): + self.result = None + self.error_msg = "" + self.error_handling = "" + self.info = {} + + @property + @classmethod + def dislay_name(cls): + return cls.__name__ + + def display_docstring(self): + """Convert the class's docstring to error handling""" + doc = self.__class__.__doc__ + error_handling_lines = [] + begin_record = False + indent = 0 # indentation for the "Error handling" line + if doc: + for line in doc.splitlines(): + if line.lstrip().startswith("Error handling"): + if begin_record is True: + msg = ( + "There are multiple Error handlings " + "in the docstring of " + f"{self.__class__.__name__}." + ) + raise ValueError(msg) + begin_record = True + indent = len(line) - len(line.lstrip()) + elif begin_record is True: + current_indent = len(line) - len(line.lstrip()) + line = line.strip() + if len(line) > 0: # Only add non-empty lines + # Compensate for the extra indentation + # if current_indent > indent + spaces = max(0, current_indent - indent) * " " + error_handling_lines.append(spaces + line) + else: + pass + else: + pass + error_handling_string = "\n".join(error_handling_lines) + return error_handling_string + + def make_test(self): + """Each class should implement ways to update `result` and `info`""" + raise NotImplementedError + + def run_test(self): + """Run test and update result etc. + If result is False, update the error handling message + """ + try: + self.make_test() + except Exception as e: + self.result = False + self.error_msg = str(e) + + if self.result is None: + raise ValueError( + "Test result is not updated for " f"{self.__class__.__name__} !" + ) + if self.result is False: + self.error_handling = self.display_docstring() + return - import sparc - if "sparc" not in ioformats.keys(): - return False - try: - from ase.io import sparc - except ImportError: - return False - return hasattr(sparc, "read_sparc") and hasattr(sparc, "write_sparc") +class ImportTest(BaseTest): + """Check if external io format `sparc` can be registered in ASE + Error handling: + - Make sure SPARC-X-API is installed via conda / pip / setuptools + - If you wish to work on SPARC-X-API source code, use `pip install -e` + instead of setting up $PYTHON_PATH + """ -def psp_test(): - cprint("Testing pseudo potential path...", color="COMMENT") - import tempfile + display_name = "Import" - from sparc.io import SparcBundle + def make_test(self): + cprint("Testing import...", color="COMMENT") + from ase.io.formats import ioformats + + self.result = "sparc" in ioformats.keys() + if self.result is False: + self.error_msg = ( + "Cannot find `sparc` as a valid " "external ioformat for ASE." + ) + return + + +class PspTest(BaseTest): + """Check at least one directory of Pseudopotential files exist + info[`psp_dir`] contains the first psp dir found on system + # TODO: check if all psp files can be located + #TODO: update to the ASE 3.23 config method + + Error handling: + - Default version of psp files can be downloaded by + `python -m sparc.download_data` + - Alternatively, specify the variable $SPARC_PSP_PATH + to the custom pseudopotential files + """ + + display_name = "Pseudopotential" + + def make_test(self): + cprint("Testing pseudo potential path...", color="COMMENT") + import tempfile + + from .io import SparcBundle + from .sparc_parsers.pseudopotential import find_pseudo_path + + with tempfile.TemporaryDirectory() as tmpdir: + sb = SparcBundle(directory=tmpdir) + psp_dir = sb.psp_dir - with tempfile.TemporaryDirectory() as tmpdir: - sb = SparcBundle(directory=tmpdir) - psp_dir = sb.psp_dir if psp_dir is not None: - cprint(f"Found psp files at {psp_dir}", color="OKBLUE") - return True + psp_dir = Path(psp_dir) + self.info["psp_dir"] = f"{psp_dir.resolve()}" + if not psp_dir.is_dir(): + self.result = False + self.error_msg = ( + "Pseudopotential files path " f"{psp_dir.resolve()} does not exist." + ) + else: + missing_elements = [] + # Default psp file are 1-57 + 72-83 + spms_elements = chemical_symbols[1:58] + chemical_symbols[72:84] + for element in spms_elements: + try: + find_pseudo_path(element, psp_dir) + except Exception: + missing_elements.append(element) + if len(missing_elements) == 0: + self.result = True + else: + self.result = False + self.error_msg = ( + "Pseudopotential files for " + f"{len(missing_elements)} elements are " + "missing or incompatible: \n" + f"{missing_elements}" + ) else: - cprint( - ( - "No psp files found! \n" - "Please make sure you have downloaded them via `python -m sparc.download_data`, " - "or set $SPARC_PSP_PATH" - ), - color="FAIL", + self.info["psp_dir"] = "None" + self.result = False + self.error_msg = ( + "Pseudopotential file path not defined and/or " + "default psp files are incomplete." ) - return False + return -def api_test(): - cprint("Testing JSON API...", color="COMMENT") - from sparc.api import SparcAPI +class ApiTest(BaseTest): + """Check if the API can be loaded, and store the Schema version. - try: - api = SparcAPI() - except Exception: - return False - version = api.sparc_version - if version is None: - cprint("Loaded API but no version date is provided.", color="WARNING") - else: - cprint(f"Loaded API version {version}", color="OKBLUE") - return True + # TODO: consider change to schema instead of api + # TODO: allow config to change json file path + Error handling: + - Check if default JSON schema exists in + `/sparc_json_api/parameters.json` + - Use $SPARC_DOC_PATH to specify the raw LaTeX files + """ + display_name = "JSON API" -def command_test(): - cprint("Testing SPARC command...", color="COMMENT") - import tempfile + def make_test(self): + from .utils import locate_api - from sparc.calculator import SPARC - - with tempfile.TemporaryDirectory() as tmpdir: - calc = SPARC(directory=tmpdir) try: - test_cmd = calc._make_command() - except Exception: - cprint( - ( - "No SPARC command found! \n" - "Please make sure you have sparc in your path, " - "or set up $ASE_SPARC_COMMAND variable!" - ), - color="FAIL", + api = locate_api() + version = api.sparc_version + self.result = True + self.info["api_version"] = version + self.info["api_source"] = api.source + except Exception as e: + self.result = False + self.info["api_version"] = "NaN" + self.info["api_source"] = "not found" + self.error_msg = ( + "Error when locating a JSON schema or " + f"LaTeX source files for SPARC. Error is {e}" ) - return False - cprint(f"The prefix for SPARC command is {test_cmd}", color="OKBLUE") - return True + return -def calc_test(): - cprint("Running simple calculation...", color="COMMENT") - import tempfile +class CommandTest(BaseTest): + """Check validity of command to run SPARC calculation. This test + also checks sparc version and socket compatibility - from ase.build import bulk + # TODO: check ase 3.23 config with separate binary + Error handling: + - The command prefix to run SPARC calculation should look like + ` ` + - Use $ASE_SPARC_COMMAND to set the command string + - Check HPC resources and compatibility (e.g. `srun` on a login node) + """ - from sparc.calculator import SPARC + display_name = "SPARC Command" - # 1x Al atoms with super bad calculation condition - al = bulk("Al", cubic=False) + def make_test(self): + import tempfile - with tempfile.TemporaryDirectory() as tmpdir: - calc = SPARC(h=0.3, kpts=(1, 1, 1), tol_scf=1e-3, directory=tmpdir) - try: - al.calc = calc - al.get_potential_energy() - except Exception: - cprint( - ("Simple SPARC calculation failed!"), - color="FAIL", + from sparc.calculator import SPARC + + self.info["command"] = "" + self.info["sparc_version"] = "" + + with tempfile.TemporaryDirectory() as tmpdir: + calc = SPARC(directory=tmpdir) + # Step 1: validity of sparc command + try: + test_cmd = calc._make_command() + self.result = True + self.info["command"] = test_cmd + except Exception as e: + self.result = False + self.info["command"] = "not found" + self.error_msg = f"Error setting SPARC command:\n{e}" + + # Step 2: check SPARC binary version + try: + sparc_version = calc.detect_sparc_version() + # Version may be None if failed to retrieve + if sparc_version: + self.result = self.result & True + self.info["sparc_version"] = sparc_version + else: + self.result = False + self.info["sparc_version"] = "NaN" + self.error_msg += "\n" if len(self.error_msg) > 0 else "" + self.error_msg += "Error detecting SPARC version" + except Exception as e: + self.result = False + self.info["sparc_version"] = "NaN" + self.error_msg += "\n" if len(self.error_msg) > 0 else "" + self.error_msg += f"\nError detecting SPARC version:\n{e}" + return + + +class FileIOCalcTest(BaseTest): + """Run a simple calculation in File IO mode. + + # TODO: check ase 3.23 config + Error handling: + - Check if settings for pseudopotential files are correct + - Check if SPARC binary exists and functional + - Check if specific HPC requirements are met: + (module files, libraries, parallel settings, resources) + """ + + display_name = "Calculation (File I/O)" + + def make_test(self): + import tempfile + + from ase.build import bulk + + from sparc.calculator import SPARC + + # 1x Al atoms with super bad calculation condition + al = bulk("Al", cubic=False) + + with tempfile.TemporaryDirectory() as tmpdir: + calc = SPARC(h=0.3, kpts=(1, 1, 1), tol_scf=1e-3, directory=tmpdir) + try: + al.calc = calc + al.get_potential_energy() + self.result = True + except Exception as e: + self.result = False + self.error_msg = "Simple calculation in file I/O mode failed: \n" f"{e}" + return + + +class SocketCalcTest(BaseTest): + """Run a simple calculation in Socket mode (UNIX socket). + + # TODO: check ase 3.23 config + Error handling: + - The same as error handling in file I/O calculation test + - Check if SPARC binary supports socket + """ + + display_name = "Calculation (UNIX socket)" + + def make_test(self): + import tempfile + + from ase.build import bulk + + from sparc.calculator import SPARC + + # Check SPARC binary socket compatibility + with tempfile.TemporaryDirectory() as tmpdir: + calc = SPARC(directory=tmpdir) + try: + sparc_compat = calc.detect_socket_compatibility() + self.info["sparc_socket_compatibility"] = sparc_compat + except Exception: + self.info["sparc_socket_compatibility"] = False + + # 1x Al atoms with super bad calculation condition + al = bulk("Al", cubic=False) + + with tempfile.TemporaryDirectory() as tmpdir: + calc = SPARC( + h=0.3, kpts=(1, 1, 1), tol_scf=1e-3, use_socket=True, directory=tmpdir ) - return False - return True + try: + al.calc = calc + al.get_potential_energy() + self.result = True + except Exception as e: + self.result = False + self.error_msg = ( + "Simple calculation in socket mode (UNIX socket) failed: \n" f"{e}" + ) + return def main(): @@ -120,33 +346,69 @@ def main(): ("Performing a quick test on your " "SPARC and python API setup"), color=None, ) - results = {} - results["Import"] = import_test() - results["Pseudopotential"] = psp_test() - results["JSON API"] = api_test() - results["SPARC command"] = command_test() - results["Calculation"] = False if results["SPARC command"] is False else calc_test() + test_classes = [ + ImportTest(), + PspTest(), + ApiTest(), + CommandTest(), + FileIOCalcTest(), + SocketCalcTest(), + ] + + system_info = {} + for test in test_classes: + test.run_test() + system_info.update(test.info) + + # Header section + print("-" * 80) cprint( - "\nSummary of test results", + "Summary", + bold=True, color="HEADER", ) + print("-" * 80) + cprint("Configuration", bold=True, color="HEADER") + for key, val in system_info.items(): + print(f"{key}: {val}") + + print("-" * 80) + # Body section + cprint("Tests", bold=True, color="HEADER") print_wiki = False - for key, val in results.items(): - cprint(f"{key}:", bold=True, end="") - if val is True: + for test in test_classes: + cprint(f"{test.display_name}:", bold=True, end="") + if test.result is True: cprint(" PASS", color="OKGREEN") else: cprint(" FAIL", color="FAIL") print_wiki = True + print("-" * 80) + # Error information section + has_print_error_header = False + for test in test_classes: + if (test.result is False) and (test.error_handling): + if has_print_error_header is False: + cprint( + ("Some tests failed! " "Please check the following information.\n"), + color="FAIL", + ) + has_print_error_header = True + cprint(f"{test.display_name}:", bold=True) + cprint(f"{test.error_msg}", color="FAIL") + print(test.error_handling) + print("\n") + if print_wiki: + print("-" * 80) cprint( - "\nSome of the tests failed! Please refer to the following resources: \n" + "Please check additional information from:\n" "1. SPARC's documentation: https://github.com/SPARC-X/SPARC/blob/master/doc/Manual.pdf \n" "2. Python API documentation: https://github.com/alchem0x2A/SPARC-X-API/blob/master/README.md\n", - color="FAIL", + color=None, ) diff --git a/sparc/utils.py b/sparc/utils.py index 270e210d..c475904f 100644 --- a/sparc/utils.py +++ b/sparc/utils.py @@ -175,6 +175,8 @@ def locate_api(json_file=None, doc_path=None): ) ) api = SparcAPI(tmpfile) + api.source["path"] = Path(doc_path).resolve().as_posix() + api.source["type"] = "latex" return api except Exception as e: warn(f"Cannot load JSON schema from env {doc_path}, the error is {e}.")