Repository: SPARC-X/SPARC-X-API (language: python)

Issue:
Enhancement for quicktest
The current quicktest needs to be expanded for better clarity. We could merge general setup information with the quick test.

- [x] fix legacy import issue from ase<3.23 #62 
- [x] add a header for setting parameters (useful for checking the current system information)
        - command
        - psp path
        - sparc doc / schema path (if set)
        - sparc binary version
- [x] Split calc test to file io and socket parts

        


Discussion / hints:

Fix patch:
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/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}.")


Test patch:
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-x-api-root>/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
+      `<mpi instructions> <sparc binary>`
+    - 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,
         )