From c53022147452e34f31d3991581d90621566424b6 Mon Sep 17 00:00:00 2001 From: "T.Tian" Date: Thu, 31 Oct 2024 15:00:02 +0800 Subject: [PATCH 1/7] prototyping test classes --- setup.py | 1 + sparc/quicktest.py | 234 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 184 insertions(+), 51 deletions(-) diff --git a/setup.py b/setup.py index 77ad5d2..0e5782c 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/quicktest.py b/sparc/quicktest.py index 1ab7e90..5f6428a 100644 --- a/sparc/quicktest.py +++ b/sparc/quicktest.py @@ -1,4 +1,5 @@ """A simple test module for sparc python api +TODO: fix the docstring Usage: python -m sparc.quicktest A few tests will be performed @@ -7,63 +8,168 @@ 3) Is the psp directory accessible 4) Is the json api accessible 5) Can the command actually run a very simple calculation + +TODO: use docstring as help information if things fail? +TODO: remove intermediate state print? """ 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 - import sparc + Each child class will implement its own `run_test` method to + update the `result`, `error_handling` and `info` fields. - 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") + 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_handling = "" + self.info = {} -def psp_test(): - cprint("Testing pseudo potential path...", color="COMMENT") - import tempfile + @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 - from sparc.io import SparcBundle + 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: + self.result = False + + 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 + + +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 + """ + + display_name = "Import" + + def make_test(self): + cprint("Testing import...", color="COMMENT") + from ase.io.formats import ioformats + + self.result = "sparc" in ioformats.keys() + 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: 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 + + 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 + self.result = True + self.info["psp_dir"] = psp_dir 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", - ) - return False + self.result = False + self.info["psp_dir"] = "Not found!" + return + +class ApiTest(BaseTest): + """Check if the API can be loaded, and store the Schema version. -def api_test(): - cprint("Testing JSON API...", color="COMMENT") - from sparc.api import SparcAPI + # 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 + """ - 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 + display_name = "JSON API" + + def make_test(self): + from .utils import locate_api + + # cprint("Testing JSON API...", color="COMMENT") + + try: + api = locate_api() + version = api.sparc_version + self.result = True + self.info["api_version"] = version + except Exception: + self.result = False + self.info["api_version"] = "Not found!" + return def command_test(): @@ -120,27 +226,53 @@ 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() + # 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(), + ] + + system_info = {} + for test in test_classes: + test.run_test() + system_info.update(test.info) cprint( "\nSummary of test results", color="HEADER", ) + print("-" * 60) + + for key, val in system_info.items(): + print(key, val) + + print("-" * 60) 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("-" * 60) + + for test in test_classes: + if (test.result is False) and (test.error_handling): + cprint(f"{test.display_name}:", bold=True) + print(test.error_handling) + + print("-" * 60) + if print_wiki: cprint( "\nSome of the tests failed! Please refer to the following resources: \n" From e3e409807497542247932c7080b4e60f6951f37f Mon Sep 17 00:00:00 2001 From: "T.Tian" Date: Thu, 31 Oct 2024 16:06:30 +0800 Subject: [PATCH 2/7] add error_msg for quick test cases --- sparc/quicktest.py | 49 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/sparc/quicktest.py b/sparc/quicktest.py index 5f6428a..7ecab03 100644 --- a/sparc/quicktest.py +++ b/sparc/quicktest.py @@ -12,6 +12,10 @@ TODO: use docstring as help information if things fail? TODO: remove intermediate state print? """ +from pathlib import Path + +from ase.data import chemical_symbols + from .utils import cprint @@ -28,6 +32,7 @@ class BaseTest(object): def __init__(self): self.result = None + self.error_msg = "" self.error_handling = "" self.info = {} @@ -79,8 +84,9 @@ def run_test(self): """ try: self.make_test() - except Exception: + except Exception as e: self.result = False + self.error_msg = str(e) if self.result is None: raise ValueError( @@ -107,12 +113,17 @@ def make_test(self): 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: @@ -129,17 +140,46 @@ def make_test(self): 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 if psp_dir is not None: - self.result = True - self.info["psp_dir"] = psp_dir + 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: + self.info["psp_dir"] = "None" self.result = False - self.info["psp_dir"] = "Not found!" + self.error_msg = ( + "Pseudopotential file path not defined and/or " + "default psp files are incomplete." + ) return @@ -269,6 +309,7 @@ def main(): for test in test_classes: if (test.result is False) and (test.error_handling): cprint(f"{test.display_name}:", bold=True) + cprint(f"{test.error_msg}", color="FAIL") print(test.error_handling) print("-" * 60) From 9bdf099aea7063b6676edda5213559e26d1984b9 Mon Sep 17 00:00:00 2001 From: "T.Tian" Date: Thu, 31 Oct 2024 16:20:25 +0800 Subject: [PATCH 3/7] allow json api to take a source information --- sparc/api.py | 4 ++++ sparc/quicktest.py | 12 ++++++++---- sparc/utils.py | 2 ++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/sparc/api.py b/sparc/api.py index 31593d4..910601c 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 7ecab03..5dedffe 100644 --- a/sparc/quicktest.py +++ b/sparc/quicktest.py @@ -199,16 +199,20 @@ class ApiTest(BaseTest): def make_test(self): from .utils import locate_api - # cprint("Testing JSON API...", color="COMMENT") - try: api = locate_api() version = api.sparc_version self.result = True self.info["api_version"] = version - except Exception: + self.info["api_source"] = api.source + except Exception as e: self.result = False - self.info["api_version"] = "Not found!" + 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 diff --git a/sparc/utils.py b/sparc/utils.py index 270e210..c475904 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}.") From b8443146fdd4fda5d5360b8a28f03b3cebc776b6 Mon Sep 17 00:00:00 2001 From: "T.Tian" Date: Thu, 31 Oct 2024 16:45:26 +0800 Subject: [PATCH 4/7] add socket test into quicktest --- sparc/quicktest.py | 134 ++++++++++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 38 deletions(-) diff --git a/sparc/quicktest.py b/sparc/quicktest.py index 5dedffe..4564a13 100644 --- a/sparc/quicktest.py +++ b/sparc/quicktest.py @@ -216,53 +216,108 @@ def make_test(self): return -def command_test(): - cprint("Testing SPARC command...", color="COMMENT") - import tempfile +class CommandTest(BaseTest): + """Check validity of command to run SPARC calculation. This test + only checks if the command prefix without actually running a + calculation. - from sparc.calculator import SPARC + # TODO: check ase 3.23 config + Error handling: + - The command prefix to run SPARC calculation should look like + ` ` + - Use $ASE_SPARC_COMMAND to set the command string + """ - 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", - ) - return False - cprint(f"The prefix for SPARC command is {test_cmd}", color="OKBLUE") - return True + display_name = "SPARC Command" + def make_test(self): + import tempfile -def calc_test(): - cprint("Running simple calculation...", color="COMMENT") - import tempfile + from sparc.calculator import SPARC - from ase.build import bulk + with tempfile.TemporaryDirectory() as tmpdir: + calc = SPARC(directory=tmpdir) + try: + test_cmd = calc._make_command() + self.result = True + self.info["command"] = test_cmd + except Exception as e: + self.result = False + self.info["command (example)"] = "not found" + self.error_msg = f"Error setting SPARC command: {e}" + return - from sparc.calculator import SPARC - # 1x Al atoms with super bad calculation condition - al = bulk("Al", cubic=False) +class FileIOCalcTest(BaseTest): + """Run a simple calculation in File IO mode. - 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", + # 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 + + # 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(): @@ -281,6 +336,9 @@ def main(): ImportTest(), PspTest(), ApiTest(), + CommandTest(), + FileIOCalcTest(), + SocketCalcTest(), ] system_info = {} From 33da492b81dbfa345d1acc16f09357f390ef036a Mon Sep 17 00:00:00 2001 From: "T.Tian" Date: Thu, 31 Oct 2024 17:44:25 +0800 Subject: [PATCH 5/7] add tests for sparc version --- sparc/quicktest.py | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/sparc/quicktest.py b/sparc/quicktest.py index 4564a13..477bc38 100644 --- a/sparc/quicktest.py +++ b/sparc/quicktest.py @@ -218,14 +218,14 @@ def make_test(self): class CommandTest(BaseTest): """Check validity of command to run SPARC calculation. This test - only checks if the command prefix without actually running a - calculation. + also checks sparc version and socket compatibility - # TODO: check ase 3.23 config + # 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) """ display_name = "SPARC Command" @@ -235,16 +235,36 @@ def make_test(self): 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 (example)"] = "not found" - self.error_msg = f"Error setting SPARC command: {e}" + 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 += "\nError detecting SPARC version" + except Exception as e: + self.result = False + self.info["sparc_version"] = "NaN" + self.error_msg += f"\nError detecting SPARC version:\n{e}" return @@ -301,6 +321,15 @@ def make_test(self): 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 as e: + self.info["sparc_socket_compatibility"] = False + # 1x Al atoms with super bad calculation condition al = bulk("Al", cubic=False) From 387964c0f3fbc9079d0071e69b7e839fbaf800ab Mon Sep 17 00:00:00 2001 From: "T.Tian" Date: Thu, 31 Oct 2024 18:03:33 +0800 Subject: [PATCH 6/7] format error message --- sparc/quicktest.py | 48 +++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/sparc/quicktest.py b/sparc/quicktest.py index 477bc38..f598ad7 100644 --- a/sparc/quicktest.py +++ b/sparc/quicktest.py @@ -260,10 +260,12 @@ def make_test(self): else: self.result = False self.info["sparc_version"] = "NaN" - self.error_msg += "\nError detecting SPARC version" + 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 @@ -327,7 +329,7 @@ def make_test(self): try: sparc_compat = calc.detect_socket_compatibility() self.info["sparc_socket_compatibility"] = sparc_compat - except Exception as e: + except Exception: self.info["sparc_socket_compatibility"] = False # 1x Al atoms with super bad calculation condition @@ -354,12 +356,6 @@ 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(), @@ -375,17 +371,22 @@ def main(): test.run_test() system_info.update(test.info) + # Header section + print("-" * 80) cprint( - "\nSummary of test results", + "Summary", + bold=True, color="HEADER", ) - - print("-" * 60) - + print("-" * 80) + cprint("Configuration", bold=True, color="HEADER") for key, val in system_info.items(): - print(key, val) + print(f"{key}: {val}") + + print("-" * 80) + # Body section + cprint("Tests", bold=True, color="HEADER") - print("-" * 60) print_wiki = False for test in test_classes: cprint(f"{test.display_name}:", bold=True, end="") @@ -395,22 +396,29 @@ def main(): cprint(" FAIL", color="FAIL") print_wiki = True - print("-" * 60) - + 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("-" * 60) + 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, ) From dcfc96050d0dda95c80fd76bd8c90db373185ace Mon Sep 17 00:00:00 2001 From: "T.Tian" Date: Thu, 31 Oct 2024 18:04:11 +0800 Subject: [PATCH 7/7] remove redundant docstring --- sparc/quicktest.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/sparc/quicktest.py b/sparc/quicktest.py index f598ad7..9863d5f 100644 --- a/sparc/quicktest.py +++ b/sparc/quicktest.py @@ -1,16 +1,6 @@ """A simple test module for sparc python api -TODO: fix the docstring 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 - -TODO: use docstring as help information if things fail? -TODO: remove intermediate state print? """ from pathlib import Path