diff --git a/.github/workflows/build_test_serial.yml b/.github/workflows/build_test_serial.yml index acd6a34be..e0ab84b7f 100644 --- a/.github/workflows/build_test_serial.yml +++ b/.github/workflows/build_test_serial.yml @@ -187,6 +187,8 @@ jobs: compiler-install: 'gcc-10 g++-10 gfortran-10' hdf5-install: 'libhdf5-dev' hdf5-restart-support: TRUE + python-version: '3.10' + numpy-version: '1.26' - os: 'ubuntu-22.04' compiler-type: 'GNU' compiler-version: '11' @@ -196,6 +198,8 @@ jobs: compiler-install: 'gcc-11 g++-11 gfortran-11' hdf5-install: 'libhdf5-dev' hdf5-restart-support: TRUE + python-version: '3.11' + numpy-version: '1.26' - os: 'ubuntu-24.04' compiler-type: 'GNU' compiler-version: '12' @@ -205,6 +209,8 @@ jobs: compiler-install: 'gcc-12 g++-12 gfortran-12' hdf5-install: '' hdf5-restart-support: FALSE + python-version: '3.12' + numpy-version: '2.0' - os: 'ubuntu-24.04' compiler-type: 'GNU' compiler-version: '13' @@ -214,6 +220,8 @@ jobs: compiler-install: 'gcc-13 g++-13 gfortran-13' hdf5-install: '' hdf5-restart-support: FALSE + python-version: '3.12' + numpy-version: '2.1' - os: 'ubuntu-24.04' compiler-type: 'GNU' compiler-version: '14' @@ -223,6 +231,8 @@ jobs: compiler-install: 'gcc-14 g++-14 gfortran-14' hdf5-install: '' hdf5-restart-support: FALSE + python-version: '3.13' + numpy-version: '2.2' - os: 'ubuntu-24.04' compiler-type: 'CLANG' compiler-version: '17' @@ -232,6 +242,8 @@ jobs: compiler-install: 'clang-17 gfortran' hdf5-install: '' hdf5-restart-support: FALSE + python-version: '3.11' + numpy-version: '1.26' - os: 'ubuntu-24.04' compiler-type: 'CLANG' compiler-version: '18' @@ -241,6 +253,8 @@ jobs: compiler-install: 'clang-18 gfortran' hdf5-install: '' hdf5-restart-support: FALSE + python-version: '3.12' + numpy-version: '2.0' - os: 'ubuntu-24.04' compiler-type: 'LLVM' compiler-version: '20' @@ -286,6 +300,8 @@ jobs: compiler-install: 'gcc-14 g++-14 gfortran-14' hdf5-install: '' hdf5-restart-support: FALSE + python-version: '3.13' + numpy-version: '2.2' - os: 'ubuntu-24.04-arm' compiler-type: 'CLANG' compiler-version: '18' @@ -295,6 +311,8 @@ jobs: compiler-install: 'clang-18 gfortran' hdf5-install: '' hdf5-restart-support: FALSE + python-version: '3.12' + numpy-version: '2.0' - os: 'macos-14' compiler-type: 'GNU' compiler-version: '15' @@ -304,6 +322,8 @@ jobs: compiler-install: 'gcc@15' hdf5-install: 'hdf5' hdf5-restart-support: TRUE + python-version: '3.12' + numpy-version: '2.0' - os: 'macos-14' compiler-type: 'CLANG' compiler-version: '20' @@ -314,6 +334,8 @@ jobs: compiler-install: 'llvm@20 gcc@15' hdf5-install: 'hdf5' hdf5-restart-support: TRUE + python-version: '3.12' + numpy-version: '2.1' - os: 'macos-15' compiler-type: 'GNU' compiler-version: '15' @@ -323,6 +345,8 @@ jobs: compiler-install: 'gcc@15' hdf5-install: 'hdf5' hdf5-restart-support: TRUE + python-version: '3.13' + numpy-version: '2.2' - os: 'macos-15' compiler-type: 'CLANG' compiler-version: '20' @@ -333,6 +357,8 @@ jobs: compiler-install: 'llvm@20 gcc@15' hdf5-install: 'hdf5' hdf5-restart-support: TRUE + python-version: '3.13' + numpy-version: '2.2' runs-on: ${{ matrix.os }} name: ${{ matrix.os }} - Serial - CMake - ${{ matrix.compiler-type }} - ${{ matrix.compiler-version }} steps: @@ -495,6 +521,33 @@ jobs: cd build cmake --build . --parallel 2 --verbose cmake --install . + - name: 'Set up Python for pyquick' + if: matrix.python-version + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: 'Install numpy for pyquick build' + if: matrix.python-version + run: | + python3 -m pip install "numpy==${{ matrix.numpy-version }}.*" meson ninja + - name: 'Build and install pyquick Python extension' + if: matrix.python-version + run: | + if [ ${{ matrix.compiler-type }} == 'INTELLLVM' ]; then + source /opt/intel/oneapi/setvars.sh + fi + cd build + cmake .. -DPYTHON=TRUE + cmake --build . --parallel 2 --target pyquick + cmake --install . + - name: 'Run pyquick test' + if: matrix.python-version + run: | + export PYTHONPATH=$PWD/install/lib${PYTHONPATH:+:$PYTHONPATH} + if [ "$RUNNER_OS" == "macOS" ]; then + export DYLD_LIBRARY_PATH=$PWD/install/lib${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH} + fi + python3 test/pyquick_ene_H2O_rhf_sto3g.py - name: 'Run Tests for Serial Version' run: | if [ ${{ matrix.compiler-type }} == 'INTELLLVM' ]; then diff --git a/CMakeLists.txt b/CMakeLists.txt index 9be472397..963ab40a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,7 @@ if(HDF5) endif() option(ENABLEF "Enables the support for f functions in the ERI code." FALSE) +option(PYTHON "Build the pyquick Python extension via f2py (requires numpy)." FALSE) # Compiler flags # These should really go into cmake/CompilerFlags.cmake but with the diff --git a/quick-cmake/quick.rc b/quick-cmake/quick.rc index fb15f4cf1..9d9703211 100644 --- a/quick-cmake/quick.rc +++ b/quick-cmake/quick.rc @@ -22,3 +22,5 @@ export QUICK_BASIS="$QUICK_INSTALL/basis" export PATH="$QUICK_INSTALL/bin":$PATH export LIBRARY_PATH="$QUICK_INSTALL/lib":$LIBRARY_PATH export LD_LIBRARY_PATH="$QUICK_INSTALL/lib":$LD_LIBRARY_PATH +export DYLD_LIBRARY_PATH="$QUICK_INSTALL/lib":$DYLD_LIBRARY_PATH +export PYTHONPATH="$QUICK_INSTALL/lib":$PYTHONPATH diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9e78e63de..e70f2afbf 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -392,3 +392,7 @@ if(CUDA AND MPI) install(TARGETS quick.${QUICK_GPU_TARGET_NAME}.MPI DESTINATION ${BINDIR} EXPORT QUICK) install(TARGETS test-api.${QUICK_GPU_TARGET_NAME}.MPI DESTINATION ${BINDIR} EXPORT QUICK) endif() + +if(PYTHON) + add_subdirectory(pyquick) +endif() diff --git a/src/pyquick/CMakeLists.txt b/src/pyquick/CMakeLists.txt new file mode 100644 index 000000000..a457e257d --- /dev/null +++ b/src/pyquick/CMakeLists.txt @@ -0,0 +1,105 @@ +# Build the pyquick Python extension via f2py. +# Enabled with -DPYTHON=TRUE at the cmake configure step. + +# --------------------------------------------------------------------------- +# 1. Locate the Python interpreter. +# +# Honour an explicit -DPython3_EXECUTABLE=... hint if provided. +# Otherwise resolve "python3" from PATH so that conda/virtualenv +# environments are found before any system Python. +# --------------------------------------------------------------------------- +if(NOT Python3_EXECUTABLE) + find_program(Python3_EXECUTABLE + NAMES python3 python + DOC "Python interpreter used to run f2py" + NO_CMAKE_SYSTEM_PATH # skip CMake's built-in system search first + ) + if(NOT Python3_EXECUTABLE) + find_program(Python3_EXECUTABLE NAMES python3 python + DOC "Python interpreter used to run f2py") + endif() +endif() + +if(NOT Python3_EXECUTABLE) + message(FATAL_ERROR + "Python interpreter not found. Set -DPython3_EXECUTABLE=/path/to/python3 " + "or activate the correct conda/virtualenv environment before running cmake.") +endif() + +message(STATUS "pyquick: using Python interpreter: ${Python3_EXECUTABLE}") + +# --------------------------------------------------------------------------- +# 2. Verify numpy and f2py are available via the located interpreter +# --------------------------------------------------------------------------- +execute_process( + COMMAND ${Python3_EXECUTABLE} -c "import numpy" + RESULT_VARIABLE _numpy_result + OUTPUT_QUIET ERROR_QUIET) +if(NOT _numpy_result EQUAL 0) + message(FATAL_ERROR + "numpy not importable by ${Python3_EXECUTABLE}. " + "Install numpy or reconfigure without -DPYTHON=TRUE.") +endif() + +execute_process( + COMMAND ${Python3_EXECUTABLE} -m numpy.f2py -h + RESULT_VARIABLE _f2py_result + OUTPUT_QUIET + ERROR_QUIET) +if(NOT _f2py_result EQUAL 0) + message(FATAL_ERROR + "f2py not found (numpy.f2py unavailable for ${Python3_EXECUTABLE}). " + "Install numpy to build the Python extension, or reconfigure without -DPYTHON=TRUE.") +endif() + +# --------------------------------------------------------------------------- +# 3. Compute the platform-specific extension suffix +# e.g. .cpython-313-x86_64-linux-gnu.so +# --------------------------------------------------------------------------- +execute_process( + COMMAND ${Python3_EXECUTABLE} -c + "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'), end='')" + OUTPUT_VARIABLE PYTHON_EXT_SUFFIX) + +set(_PYQUICK_SO "${CMAKE_CURRENT_BINARY_DIR}/_pyquick${PYTHON_EXT_SUFFIX}") + +# --------------------------------------------------------------------------- +# 4. Build the .so via f2py as a custom command +# +# Include path: build-tree .mod files for the serial libquick target. +# The directory is ${CMAKE_BINARY_DIR}/amber-modules/quick/serial, which +# is set by config_module_dirs(libquick quick/serial ...) in src/CMakeLists.txt +# and is fully populated after libquick has been compiled (DEPENDS libquick). +# +# Link path: resolved at build time via $, so +# -L always points to the actual location of libquick.so regardless of +# CMAKE_INSTALL_PREFIX. +# --------------------------------------------------------------------------- +add_custom_command( + OUTPUT "${_PYQUICK_SO}" + COMMAND ${Python3_EXECUTABLE} -m numpy.f2py + -c "${CMAKE_SOURCE_DIR}/src/pyquick/pyquick.f90" + "-I${CMAKE_BINARY_DIR}/amber-modules/quick/serial" + "-L$" + -lquick + -m _pyquick + DEPENDS libquick "${CMAKE_SOURCE_DIR}/src/pyquick/pyquick.f90" + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" + COMMENT "Building _pyquick Python extension via f2py" + VERBATIM) + +# --------------------------------------------------------------------------- +# 5. Named target included in the default ALL build +# --------------------------------------------------------------------------- +add_custom_target(pyquick ALL DEPENDS "${_PYQUICK_SO}") + +# --------------------------------------------------------------------------- +# 6. Install rules +# Both the compiled extension and the Python package glue go into +# /lib/pyquick/ so that after "source quick.rc" (which prepends +# /lib to PYTHONPATH) the package is importable as "import pyquick". +# --------------------------------------------------------------------------- +install(FILES "${_PYQUICK_SO}" + DESTINATION lib/pyquick) +install(FILES "${CMAKE_SOURCE_DIR}/src/pyquick/__init__.py" + DESTINATION lib/pyquick) diff --git a/src/pyquick/__init__.py b/src/pyquick/__init__.py new file mode 100644 index 000000000..d68bebd51 --- /dev/null +++ b/src/pyquick/__init__.py @@ -0,0 +1,310 @@ +try: + from ._pyquick import pyquick as _mod +except ImportError as e: + if 'libquick' in str(e): + raise ImportError( + "QUICK shared library not found. " + "Please source the QUICK environment setup script (quick.rc) " + "before importing this module." + ) from e + raise + + +def _checked(fn): + """Wrap a Fortran subroutine call; raise RuntimeError if had_error is set.""" + def wrapper(*args, **kwargs): + result = fn(*args, **kwargs) + if _mod.had_error: + msg = bytes(_mod.error_message).decode().strip() + _mod.had_error = False + _mod.error_message = b' ' * 512 + raise RuntimeError(msg) + return result + return wrapper + + +# --------------------------------------------------------------------------- +# PyQuick class +# --------------------------------------------------------------------------- + +class PyQuick: + """Object-oriented interface to a single QUICK calculation. + + Usage:: + + job = PyQuick() + job.set_calc('HF') + job.set_basis('STO-3G') + job.read_geom(''' + O 0.000 0.000 0.000 + H 0.757 0.586 0.000 + H -0.757 0.586 0.000 + ''') + job.run() + print(job.total_energy) + """ + + def __init__(self): + self._calc = None # str, e.g. 'HF' + self._basis = None # str, e.g. 'STO-3G' + self._methods = [] # list of (keyword_str, arg_str_or_None) + self._geom = None # raw geometry string as passed by user + self._ran = False + self._results = {} # snapshot of results captured at end of run() + + # -- setup methods ------------------------------------------------------- + + def set_calc(self, keyword): + """Set the calculation type: 'HF', 'UHF', 'DFT', or 'UDFT'.""" + _checked(_mod.set_calc)(keyword) + self._calc = keyword + + def set_basis(self, basis_name): + """Set the basis set, e.g. 'STO-3G', '6-31G*'.""" + _checked(_mod.set_basis)(basis_name) + self._basis = basis_name + + def set_method(self, keyword, arg=None): + """Add or update a keyword token in the job card. + + Examples:: + + job.set_method('DIPOLE') # bare keyword + job.set_method('CUTOFF', '1e-9') # keyword=value + """ + if arg is not None: + _checked(_mod.set_method)(keyword, arg) + else: + _checked(_mod.set_method)(keyword) + uname = keyword.strip().upper() + for i, (k, _) in enumerate(self._methods): + if k == uname: + self._methods[i] = (uname, arg) + return + self._methods.append((uname, arg)) + + def unset_method(self, keyword): + """Remove a keyword token previously added via set_method(). + + Raises ValueError if the keyword is not currently set. + """ + uname = keyword.strip().upper() + for i, (k, _) in enumerate(self._methods): + if k == uname: + del self._methods[i] + return + raise ValueError(f"method keyword {keyword!r} is not set") + + def set_output(self, stem): + """Set the output file stem (default 'pyquick_job'). + + QUICK writes diagnostic output to ``.out``. + """ + _checked(_mod.job_set_output)(stem) + + def read_geom(self, geom): + """Set the molecular geometry. + + *geom* is a multi-line string with one atom per line:: + + 'SYMBOL X Y Z' + + Coordinates are in Angstrom. + """ + _checked(_mod.read_geom)(geom) + self._geom = geom + + def print_input(self): + """Print the assembled QUICK input to stdout.""" + print(self.input_string) + + @property + def input_string(self): + """The assembled QUICK input as a string.""" + # replay this instance's state so Fortran's input_string reflects it + _mod.clear_methods() + if self._calc is not None: _checked(_mod.set_calc)(self._calc) + if self._basis is not None: _checked(_mod.set_basis)(self._basis) + for keyword, arg in self._methods: + if arg is not None: _checked(_mod.set_method)(keyword, arg) + else: _checked(_mod.set_method)(keyword) + if self._geom is not None: _checked(_mod.read_geom)(self._geom) + return bytes(_mod.input_string).decode().strip() + + # -- execution ----------------------------------------------------------- + + def run(self): + """Run the SCF energy calculation. + + Must be called after :meth:`set_calc`, :meth:`set_basis`, and + :meth:`read_geom`. Results are available as properties afterwards. + + If this instance has been run before, the previous QUICK state is + fully finalized before the new run begins, so basis sets and array + dimensions are always consistent. Output from successive runs is + appended to ``.out``; call :meth:`set_output` with a different + stem between runs if you need separate output files. + """ + self._results = {} + self._ran = False + if self._calc is None: + raise RuntimeError("call set_calc() before run()") + if self._basis is None: + raise RuntimeError("call set_basis() before run()") + if self._geom is None: + raise RuntimeError("call read_geom() before run()") + # replay this instance's state into the Fortran singleton + _mod.clear_methods() + _checked(_mod.set_calc)(self._calc) + _checked(_mod.set_basis)(self._basis) + for keyword, arg in self._methods: + if arg is not None: + _checked(_mod.set_method)(keyword, arg) + else: + _checked(_mod.set_method)(keyword) + _checked(_mod.read_geom)(self._geom) + _checked(_mod.job_run)() + self._ran = True + # snapshot all results into Python-owned storage so that a subsequent + # run() on a different instance cannot overwrite this instance's results + self._results['total_energy'] = float(_mod.job_total_energy) + self._results['e_core'] = float(_mod.job_e_core) + self._results['e_electronic'] = float(_mod.job_e_electronic) + self._results['e_1e'] = float(_mod.job_e_1e) + self._results['e_xc'] = float(_mod.job_e_xc) + self._results['e_disp'] = float(_mod.job_e_disp) + if _mod.job_has_mulliken: + r, n = _mod.job_get_mulliken() + self._results['mulliken'] = r[:n].copy() + if _mod.job_has_lowdin: + r, n = _mod.job_get_lowdin() + self._results['lowdin'] = r[:n].copy() + if _mod.job_has_mo_energies: + r, n = _mod.job_get_mo_energies() + self._results['mo_energies'] = r[:n].copy() + if _mod.job_has_density_matrix: + r, nr, nc = _mod.job_get_density_matrix() + self._results['density_matrix'] = r[:nr * nc].reshape(nr, nc).copy() + + def copy(self): + """Return a new PyQuick with the same setup state. + + Results from a previous :meth:`run` are not copied — the new instance + starts unrun. All setup attributes (_calc, _basis, _methods, _geom) + are independent copies, so changes to one object do not affect the other. + """ + new = PyQuick() + new._calc = self._calc + new._basis = self._basis + new._methods = list(self._methods) # list of immutable tuples — shallow copy is sufficient + new._geom = self._geom + return new + + def __copy__(self): + return self.copy() + + def __del__(self): + # Only finalize QUICK if this object successfully ran a calculation. + try: + if self._ran and _mod.job_active: + _mod.job_destroy() + except Exception: + pass + + # -- scalar results ------------------------------------------------------ + + def _require_run(self, prop_name): + if not self._ran: + raise AttributeError( + f"'{prop_name}' is not available until run() has been called" + ) + + @property + def total_energy(self): + """Total SCF energy in Hartree.""" + self._require_run('total_energy') + return self._results['total_energy'] + + @property + def e_core(self): + """Core (nuclear repulsion + one-electron) energy in Hartree.""" + self._require_run('e_core') + return self._results['e_core'] + + @property + def e_electronic(self): + """Total electronic energy in Hartree.""" + self._require_run('e_electronic') + return self._results['e_electronic'] + + @property + def e_1e(self): + """One-electron energy in Hartree.""" + self._require_run('e_1e') + return self._results['e_1e'] + + @property + def e_xc(self): + """Exchange-correlation energy in Hartree (0.0 for pure HF).""" + self._require_run('e_xc') + return self._results['e_xc'] + + @property + def e_disp(self): + """Dispersion correction energy in Hartree (0.0 if not requested).""" + self._require_run('e_disp') + return self._results['e_disp'] + + # -- array results ------------------------------------------------------- + + @property + def mulliken(self): + """Mulliken partial charges as a numpy array of shape (natom,). + + Requires DIPOLE in the keyword line:: + + job.set_method('DIPOLE') + """ + self._require_run('mulliken') + if 'mulliken' not in self._results: + raise AttributeError( + "'mulliken' charges were not computed; " + "include DIPOLE in the keyword line via set_method('DIPOLE')" + ) + return self._results['mulliken'] + + @property + def lowdin(self): + """Lowdin partial charges as a numpy array of shape (natom,). + + Requires DIPOLE in the keyword line:: + + job.set_method('DIPOLE') + """ + self._require_run('lowdin') + if 'lowdin' not in self._results: + raise AttributeError( + "'lowdin' charges were not computed; " + "include DIPOLE in the keyword line via set_method('DIPOLE')" + ) + return self._results['lowdin'] + + @property + def mo_energies(self): + """Molecular orbital energies (alpha) as a numpy array of shape (NBSuse,).""" + self._require_run('mo_energies') + if 'mo_energies' not in self._results: + raise AttributeError( + "'mo_energies' were not computed; run() must complete successfully" + ) + return self._results['mo_energies'] + + @property + def density_matrix(self): + """Alpha density matrix as a numpy array of shape (nbasis, nbasis).""" + self._require_run('density_matrix') + if 'density_matrix' not in self._results: + raise AttributeError( + "'density_matrix' was not computed; run() must complete successfully" + ) + return self._results['density_matrix'] diff --git a/src/pyquick/pyquick.f90 b/src/pyquick/pyquick.f90 new file mode 100644 index 000000000..81e46c12b --- /dev/null +++ b/src/pyquick/pyquick.f90 @@ -0,0 +1,670 @@ +module pyquick + + use iso_fortran_env, only : output_unit + use quick_molspec_module, only : quick_molspec, natom, xyz, alloc + use quick_calculated_module, only : quick_qm_struct + use quick_method_module, only : quick_method + use quick_files_module, only : iOutFile, outFileName, inFileName, isTemplate, & + set_quick_files, print_quick_io_file + use quick_api_module, only : quick_api + use quick_constants_module, only : SYMBOL, SYMBOL_MAX, A_TO_BOHRS + use quick_basis_module, only : nbasis, NBSuse + use quick_cutoff_module, only : schwarzoff + use quick_eri_cshell_module, only : getEriPrecomputables + use quick_sad_guess_module, only : getSadGuess + + implicit none + + private + public :: set_calc, set_basis, set_method, clear_methods, read_geom, & + had_error, error_message, print_input, input_string, & + job_run, job_destroy, job_set_output, & + job_active, & + job_total_energy, job_e_core, job_e_electronic, & + job_e_1e, job_e_xc, job_e_disp, & + job_has_mulliken, job_has_lowdin, job_has_mo_energies, job_has_density_matrix, & + job_get_mulliken, job_get_lowdin, job_get_mo_energies, job_get_density_matrix + + integer, parameter :: KEYWORD_LEN = 300 + integer, parameter :: INPUT_LEN = 10000 + + character(len=8) :: calc_keyword = '' + character(len=:), allocatable :: basis_token + integer, allocatable :: geom_atnum(:) ! atomic numbers, length geom_natom + double precision, allocatable :: geom_coords(:,:) ! Angstrom coordinates, shape (3, geom_natom) + + logical :: has_calc = .false. + logical :: has_basis = .false. + logical :: has_geom = .false. + + logical :: had_error = .false. + character(len=512) :: error_message = '' + + character(len=INPUT_LEN) :: input_string = '' + + ! atom count stored by read_geom so job_run knows natom before alloc + integer :: geom_natom = 0 + + ! output file stem (default 'pyquick_job') + character(len=80) :: output_stem = 'pyquick_job' + + ! job state + logical :: job_active = .false. + + ! scalar results (always available after a successful job_run) + double precision :: job_total_energy = 0.0d0 + double precision :: job_e_core = 0.0d0 + double precision :: job_e_electronic = 0.0d0 + double precision :: job_e_1e = 0.0d0 + double precision :: job_e_xc = 0.0d0 + double precision :: job_e_disp = 0.0d0 + + ! availability flags for array results + logical :: job_has_mulliken = .false. + logical :: job_has_lowdin = .false. + logical :: job_has_mo_energies = .false. + logical :: job_has_density_matrix = .false. + + type :: method_entry + character(len=:), allocatable :: keyword + character(len=:), allocatable :: arg + end type method_entry + + type(method_entry), allocatable :: method_list(:) + +contains + + ! ----------------------------------------------------------------------- + ! Backward-compatible input-assembly API + ! ----------------------------------------------------------------------- + + subroutine set_calc(keyword) + character(len=*), intent(in) :: keyword + character(len=:), allocatable :: normalized + + normalized = uppercase(trim(keyword)) + + select case (trim(normalized)) + case ('HF', 'UHF', 'DFT', 'UDFT') + calc_keyword = normalized + has_calc = .true. + call rebuild_input() + case default + call fail('set_calc: keyword must be HF, UHF, DFT or UDFT') + end select + end subroutine set_calc + + subroutine set_basis(basis_name) + character(len=*), intent(in) :: basis_name + + if (len_trim(basis_name) == 0) then + call fail('set_basis: basis name must be non-empty') + return + end if + + basis_token = 'BASIS=' // trim(adjustl(basis_name)) + has_basis = .true. + call rebuild_input() + end subroutine set_basis + + subroutine set_method(keyword, arg) + character(len=*), intent(in) :: keyword + character(len=*), intent(in), optional :: arg + character(len=:), allocatable :: uname + integer :: i + + if (len_trim(keyword) == 0) then + call fail('set_method: keyword name must be non-empty') + return + end if + + uname = uppercase(trim(adjustl(keyword))) + + if (allocated(method_list)) then + do i = 1, size(method_list) + if (trim(method_list(i)%keyword) == trim(uname)) then + if (present(arg) .and. len_trim(arg) > 0) then + method_list(i)%arg = trim(adjustl(arg)) + else + method_list(i)%arg = '' + end if + call rebuild_input() + return + end if + end do + end if + + call append_method(uname, arg) + call rebuild_input() + end subroutine set_method + + subroutine read_geom(input) + character(len=*), intent(in) :: input + character(len=:), allocatable :: line + integer :: start, len_input, nl, atom_count, ios, k, z + character(len=4) :: sym + double precision :: cx, cy, cz + + ! discard any previous geometry so a second call replaces the first + if (allocated(geom_atnum)) deallocate(geom_atnum) + if (allocated(geom_coords)) deallocate(geom_coords) + geom_natom = 0 + has_geom = .false. + + ! --- first pass: count non-empty lines to know how many atoms --- + atom_count = 0 + start = 1 + len_input = len(input) + + do while (start <= len_input) + nl = index(input(start:), new_line('a')) + if (nl == 0) then + if (len_trim(input(start:)) > 0) atom_count = atom_count + 1 + exit + else + if (len_trim(input(start:start+nl-2)) > 0) atom_count = atom_count + 1 + start = start + nl + end if + end do + + if (atom_count == 0) then + call fail('read_geom: geometry must contain at least one atom') + return + end if + + allocate(geom_atnum(atom_count)) + allocate(geom_coords(3, atom_count)) + + ! --- second pass: parse and validate each line --- + atom_count = 0 + start = 1 + + do while (start <= len_input) + nl = index(input(start:), new_line('a')) + if (nl == 0) then + line = trim(adjustl(input(start:))) + else + line = trim(adjustl(input(start:start+nl-2))) + start = start + nl + end if + + if (len_trim(line) == 0) then + if (nl == 0) exit + cycle + end if + + ! parse symbol and three coordinates; accepts decimal and scientific notation + sym = '' + read(line, *, iostat=ios) sym, cx, cy, cz + if (ios /= 0) then + call fail('read_geom: cannot parse line (expected: SYMBOL X Y Z): ' // & + trim(line)) + return + end if + + ! validate symbol against the QUICK element table + z = 0 + do k = 1, SYMBOL_MAX + if (trim(uppercase(sym)) == trim(uppercase(SYMBOL(k)))) then + z = k + exit + end if + end do + if (z == 0) then + call fail('read_geom: unknown element symbol "' // trim(sym) // & + '" in line: ' // trim(line)) + return + end if + + atom_count = atom_count + 1 + geom_atnum(atom_count) = z + geom_coords(1, atom_count) = cx + geom_coords(2, atom_count) = cy + geom_coords(3, atom_count) = cz + + if (nl == 0) exit + end do + + geom_natom = atom_count + has_geom = .true. + call rebuild_input() + + end subroutine read_geom + + subroutine print_input() + write(output_unit, '(A)') trim(input_string) + end subroutine print_input + + subroutine rebuild_input() + character(len=:), allocatable :: text + character(len=64) :: atom_line + integer :: i + + if (has_calc) then + text = trim(calc_keyword) + else + text = '' + end if + + if (allocated(method_list)) then + do i = 1, size(method_list) + if (len_trim(text) > 0) then + if (len_trim(method_list(i)%arg) > 0) then + text = trim(text) // ' ' // trim(method_list(i)%keyword) & + // '=' // trim(method_list(i)%arg) + else + text = trim(text) // ' ' // trim(method_list(i)%keyword) + end if + else + if (len_trim(method_list(i)%arg) > 0) then + text = trim(method_list(i)%keyword) // '=' // trim(method_list(i)%arg) + else + text = trim(method_list(i)%keyword) + end if + end if + end do + end if + + if (has_basis) then + if (len_trim(text) > 0) then + text = trim(text) // ' ' // trim(basis_token) + else + text = trim(basis_token) + end if + end if + + if (has_geom) then + do i = 1, geom_natom + write(atom_line, '(A2, 3(1X, F12.6))') & + trim(SYMBOL(geom_atnum(i))), & + geom_coords(1,i), geom_coords(2,i), geom_coords(3,i) + text = trim(text) // new_line('a') // trim(atom_line) + end do + end if + + input_string = '' + if (len_trim(text) > 0) then + input_string(1:min(len_trim(text), INPUT_LEN)) = & + text(1:min(len_trim(text), INPUT_LEN)) + end if + end subroutine rebuild_input + + ! ----------------------------------------------------------------------- + ! Job execution API + ! ----------------------------------------------------------------------- + + subroutine job_set_output(stem) + character(len=*), intent(in) :: stem + if (len_trim(stem) == 0) then + call fail('job_set_output: stem must be non-empty') + return + end if + output_stem = trim(stem) + end subroutine job_set_output + + subroutine job_run() + ! External free subroutines in libquick.so + external :: initialize1, read_Job_and_Atom, getMol, getEnergy, dipole + external :: finalize, outputCopyright, PrtDate, quick_open + + character(len=:), allocatable :: keyword_line + character(len=10) :: kwlen_str + character(len=1) :: open_mode + integer :: ierr, i, j, k + integer :: natm_type + integer :: atm_type_id(geom_natom) + logical :: new_type + character(len=256) :: note + + ierr = 0 + + ! --- determine open mode before finalize clears job_active --- + ! first run: replace the output file; re-runs: append to it + if (job_active) then + open_mode = 'A' + else + open_mode = 'R' + end if + + ! --- if a previous run is still active, finalize it before re-running --- + ! This deallocates all basis/MO/density arrays sized for the previous run + ! so they can be reallocated at the correct dimensions for the new run. + if (job_active) then + call finalize(iOutFile, ierr, 1) + if (ierr /= 0) then + call fail('job_run: finalize of previous run failed') + return + end if + job_active = .false. + end if + + ! --- validate prerequisites --- + if (.not. has_calc) then + call fail('job_run: call set_calc before run') + return + end if + if (.not. has_basis) then + call fail('job_run: call set_basis before run') + return + end if + if (.not. has_geom) then + call fail('job_run: call read_geom before run') + return + end if + + ! --- build keyword line --- + keyword_line = build_keyword_line() + + ! --- guard against silent Fortran truncation on assignment to quick_api%Keywd --- + if (len_trim(keyword_line) > KEYWORD_LEN) then + write(kwlen_str, '(I0)') KEYWORD_LEN + call fail('job_run: keyword line exceeds maximum length of ' // & + trim(kwlen_str) // ' characters') + return + end if + + ! --- configure quick_api for keyword injection --- + quick_api%apiMode = .true. + quick_api%hasKeywd = .true. + quick_api%Keywd = trim(keyword_line) + + ! --- configure file names; isTemplate suppresses coord read in getMol --- + inFileName = trim(output_stem) // '.in' + isTemplate = .true. + + ! --- QUICK call chain (follows main.f90) --- + call initialize1(ierr) + if (ierr /= 0) then + call fail('job_run: initialize1 failed') + return + end if + + call set_quick_files(.true., ierr) + if (ierr /= 0) then + call fail('job_run: set_quick_files failed') + return + end if + + call quick_open(iOutFile, outFileName, 'U', 'F', open_mode, .false., ierr) + if (ierr /= 0) then + call fail('job_run: quick_open failed') + return + end if + + call outputCopyright(iOutFile, ierr) + if (ierr /= 0) then + call fail('job_run: outputCopyright failed') + return + end if + + note = 'TASK STARTS ON:' + call PrtDate(iOutFile, note, ierr) + if (ierr /= 0) then + call fail('job_run: PrtDate failed') + return + end if + + call print_quick_io_file(iOutFile, ierr) + if (ierr /= 0) then + call fail('job_run: print_quick_io_file failed') + return + end if + + ! reads keyword from quick_api%Keywd; skips coordinate read (apiMode) + call read_Job_and_Atom(ierr) + if (ierr /= 0) then + call fail('job_run: read_Job_and_Atom failed') + return + end if + + ! set natom (module-level target) BEFORE alloc uses it + natom = geom_natom + + call alloc(quick_molspec, .false., ierr) + if (ierr /= 0) then + call fail('job_run: alloc(quick_molspec) failed') + return + end if + + ! --- inject geometry into QUICK module-level state --- + + ! build atom type list (deduplicate by atomic number) + natm_type = 0 + atm_type_id = 0 + do i = 1, geom_natom + new_type = .true. + do k = 1, natm_type + if (atm_type_id(k) == geom_atnum(i)) then + new_type = .false. + exit + end if + end do + if (new_type) then + natm_type = natm_type + 1 + atm_type_id(natm_type) = geom_atnum(i) + end if + end do + + quick_molspec%iAtomType = natm_type + do i = 1, natm_type + quick_molspec%atom_type_sym(i) = SYMBOL(atm_type_id(i)) + end do + + ! inject atomic numbers and coordinates (convert Angstrom -> Bohr) + do i = 1, geom_natom + quick_molspec%iattype(i) = geom_atnum(i) + do j = 1, 3 + xyz(j, i) = geom_coords(j, i) * A_TO_BOHRS + end do + end do + quick_molspec%xyz => xyz + + ! --- initial guess --- + if (quick_method%SAD) then + call getSadGuess(ierr) + if (ierr /= 0) then + call fail('job_run: getSadGuess failed') + return + end if + end if + + ! --- build molecular orbital / basis information --- + call getMol(ierr) + if (ierr /= 0) then + call fail('job_run: getMol failed') + return + end if + + ! --- ERI precomputables and cutoff screening --- + call getEriPrecomputables() + call schwarzoff() + + ! --- SCF energy --- + call getEnergy(.false., ierr) + if (ierr /= 0) then + call fail('job_run: getEnergy failed') + return + end if + + ! --- post-SCF: Mulliken/Lowdin charges and dipole moment --- + if (quick_method%dipole) call dipole + + ! --- harvest scalar results --- + job_total_energy = quick_qm_struct%ETot + job_e_core = quick_qm_struct%ECore + job_e_electronic = quick_qm_struct%EEl + job_e_1e = quick_qm_struct%E1e + job_e_xc = quick_qm_struct%Exc + job_e_disp = quick_qm_struct%Edisp + + ! --- set array availability flags --- + job_has_mulliken = quick_method%dipole + job_has_lowdin = quick_method%dipole + job_has_mo_energies = allocated(quick_qm_struct%E) + job_has_density_matrix = allocated(quick_qm_struct%dense) + + job_active = .true. + + end subroutine job_run + + subroutine job_destroy() + external :: finalize + integer :: ierr + ierr = 0 + if (job_active) then + call finalize(iOutFile, ierr, 1) + job_active = .false. + end if + end subroutine job_destroy + + ! ----------------------------------------------------------------------- + ! Array result getters + ! Each checks the availability flag and sets had_error if not computed. + ! ----------------------------------------------------------------------- + + subroutine job_get_mulliken(charges, n) + ! f2py cannot use external module variables as C array bounds, so we + ! use a literal upper bound and return the actual count in n. + !f2py intent(out) charges, n + integer, intent(out) :: n + double precision, intent(out) :: charges(10000) + charges = 0.0d0 + if (.not. job_has_mulliken) then + call fail("'mulliken' charges were not computed; " // & + "include DIPOLE in the keyword line via set_method('DIPOLE')") + n = 0 + return + end if + n = natom + charges(1:natom) = quick_qm_struct%Mulliken(1:natom) + end subroutine job_get_mulliken + + subroutine job_get_lowdin(charges, n) + !f2py intent(out) charges, n + integer, intent(out) :: n + double precision, intent(out) :: charges(10000) + charges = 0.0d0 + if (.not. job_has_lowdin) then + call fail("'lowdin' charges were not computed; " // & + "include DIPOLE in the keyword line via set_method('DIPOLE')") + n = 0 + return + end if + n = natom + charges(1:natom) = quick_qm_struct%Lowdin(1:natom) + end subroutine job_get_lowdin + + subroutine job_get_mo_energies(energies, n) + !f2py intent(out) energies, n + integer, intent(out) :: n + double precision, intent(out) :: energies(10000) + energies = 0.0d0 + if (.not. job_has_mo_energies) then + call fail("'mo_energies' were not computed; run() must complete successfully") + n = 0 + return + end if + n = NBSuse + energies(1:NBSuse) = quick_qm_struct%E(1:NBSuse) + end subroutine job_get_mo_energies + + subroutine job_get_density_matrix(dm, nr, nc) + ! Returns the alpha density matrix as a 1D (row-major) array of length + ! nr*nc = nbasis*nbasis. Reshape in Python: dm.reshape(nr, nc). + ! We cap at 3000*3000 = 9_000_000 elements; large basis sets are rare. + !f2py intent(out) dm, nr, nc + integer, intent(out) :: nr, nc + double precision, intent(out) :: dm(9000000) + integer :: i, j, idx + dm = 0.0d0 + if (.not. job_has_density_matrix) then + call fail("'density_matrix' was not computed; run() must complete successfully") + nr = 0 + nc = 0 + return + end if + nr = nbasis + nc = nbasis + do j = 1, nbasis + do i = 1, nbasis + idx = (i - 1) * nbasis + j + dm(idx) = quick_qm_struct%dense(i, j) + end do + end do + end subroutine job_get_density_matrix + + ! ----------------------------------------------------------------------- + ! Private helpers + ! ----------------------------------------------------------------------- + + function build_keyword_line() result(text) + character(len=:), allocatable :: text + integer :: i + + text = trim(calc_keyword) + + if (allocated(method_list)) then + do i = 1, size(method_list) + if (len_trim(method_list(i)%arg) > 0) then + text = trim(text) // ' ' // trim(method_list(i)%keyword) & + // '=' // trim(method_list(i)%arg) + else + text = trim(text) // ' ' // trim(method_list(i)%keyword) + end if + end do + end if + + if (has_basis) text = trim(text) // ' ' // trim(basis_token) + + end function build_keyword_line + + subroutine clear_methods() + if (allocated(method_list)) deallocate(method_list) + call rebuild_input() + end subroutine clear_methods + + subroutine append_method(uname, arg) + character(len=*), intent(in) :: uname + character(len=*), intent(in), optional :: arg + type(method_entry), allocatable :: tmp(:) + integer :: n + + if (.not. allocated(method_list)) then + allocate(method_list(1)) + n = 1 + else + n = size(method_list) + 1 + allocate(tmp(n)) + tmp(1:n-1) = method_list + call move_alloc(tmp, method_list) + end if + + method_list(n)%keyword = trim(uname) + if (present(arg) .and. len_trim(arg) > 0) then + method_list(n)%arg = trim(adjustl(arg)) + else + method_list(n)%arg = '' + end if + end subroutine append_method + + subroutine fail(message) + character(len=*), intent(in) :: message + had_error = .true. + error_message = trim(message) + end subroutine fail + + function uppercase(text) result(upper) + character(len=*), intent(in) :: text + character(len=len(text)) :: upper + integer :: i + + upper = text + do i = 1, len(text) + select case (upper(i:i)) + case ('a':'z') + upper(i:i) = achar(iachar(upper(i:i)) - 32) + end select + end do + end function uppercase + +end module pyquick diff --git a/test/pyquick_ene_H2O_rhf_sto3g.py b/test/pyquick_ene_H2O_rhf_sto3g.py new file mode 100644 index 000000000..57c32262c --- /dev/null +++ b/test/pyquick_ene_H2O_rhf_sto3g.py @@ -0,0 +1,45 @@ +""" +Smoke test for the pyquick Python interface. + +Runs an HF/STO-3G energy calculation on water using the same geometry and +cutoff settings as the reference test ene_H2O_rhf_sto3g, then checks that +the total energy matches the saved reference within the standard energy +tolerance used by the QUICK test harness (4.0e-5 Ha). +""" + +import math +import sys + +try: + import pyquick +except ImportError as e: + print(f"FAIL cannot import pyquick: {e}", file=sys.stderr) + sys.exit(1) + +REF_ENERGY = -74.947863811 # HF/STO-3G total energy for H2O (Ha) +TOL = 4.0e-5 # standard check_energy threshold from runtest + +job = pyquick.PyQuick() +job.set_calc('HF') +job.set_basis('STO-3G') +job.set_method('DIPOLE') +job.set_method('cutoff', '1.0e-9') +job.set_method('denserms', '1.0e-6') +job.read_geom(''' + O -0.33840 0.00380 0.23923 + H -0.33510 -0.00190 -0.83277 + H 0.67350 -0.00190 0.59353 +''') +job.run() + +assert math.isfinite(job.total_energy), \ + f"FAIL total_energy is not finite: {job.total_energy}" + +diff = abs(job.total_energy - REF_ENERGY) +assert diff < TOL, ( + f"FAIL total_energy {job.total_energy:.9f} Ha differs from reference " + f"{REF_ENERGY:.9f} Ha by {diff:.2e} (tol {TOL:.0e})" +) + +print(f"PASS total_energy = {job.total_energy:.9f} Ha " + f"(ref {REF_ENERGY:.9f}, diff {diff:.2e})") diff --git a/test/testlist_full.txt b/test/testlist_full.txt index de46d0fe2..b32bc5518 100644 --- a/test/testlist_full.txt +++ b/test/testlist_full.txt @@ -184,3 +184,4 @@ ene_multiple_keyword_lines #Acetate: DFT energy test: Tests if read chk_acetate_xyz #CHK test: writing/reading coordinates chk_acetate_xyz_density #CHK test: writing/reading densities and coordinates chk_acetate_density #CHK test: writing/reading densities +pyq_ene_H2O_rhf_sto3g #Water: pyquick Python interface energy test diff --git a/test/testlist_short.txt b/test/testlist_short.txt index 3461e465f..331ed9f0b 100644 --- a/test/testlist_short.txt +++ b/test/testlist_short.txt @@ -30,3 +30,4 @@ api_water_rhf_631g #Water: API test esp_charge_acetone_b3lyp_def2svp #ESP test: Tests both ESP charges and ESP on van der Waals surface. chk_acetate_xyz_density #CHK test: writing/reading densities and coordinates ene_benzene_b3lyp_aug-cc-pvdz #C6H6: DFT energy test: B3LYP/aug-cc-pvtz near-linear dependency of basis sets +pyq_ene_H2O_rhf_sto3g #Water: pyquick Python interface energy test diff --git a/tools/runtest b/tools/runtest index 5b898a503..a98a61f8c 100755 --- a/tools/runtest +++ b/tools/runtest @@ -59,6 +59,7 @@ test_opt='no' test_api='no' test_chk='no' test_esp='no' +test_pyq='no' uspec_test='no' test_length='short' @@ -114,6 +115,7 @@ print_help() --api Run only api tests --chk Run only checkpoint tests --esp Run only ESP tests + --python Run only Python interface (pyquick) tests --full Run a large set of tests (Default: no) --nolog Disable writing output into runtest.log/runtest-verbose.log (Default: no) @@ -309,11 +311,12 @@ set_job_gpus() # $4: run API tests ['yes'|'no'] # $5: run checkpoint tests ['yes'|'no'] # $6: run ESP tests ['yes'|'no'] +# $7: run Python interface (pyquick) tests ['yes'|'no'] # # outputs: # tests: array of tests # test_names: array of test names -# tot_{ene,grad,opt,api,esp}_tests: counts for test lists by type +# tot_{ene,grad,opt,api,esp,pyq}_tests: counts for test lists by type # total_tests: sum of counts for all test types get_total_tests() { @@ -386,6 +389,17 @@ get_total_tests() tot_esp_tests=0 fi + if [ "$7" = 'yes' ]; then + pyq_tests=(`grep "^pyq_" "$test_list" | awk '{print $1}'`) + tot_pyq_tests="${#pyq_tests[@]}" + tests+=(${pyq_tests[@]}) + while IFS= read -r line; do + test_names+=("$line") + done < <(grep "^pyq_" "$test_list" | awk 'BEGIN { FS=" #"; OFS="\n"; } {print $2}') + else + tot_pyq_tests=0 + fi + total_tests="${#tests[@]}" } @@ -970,12 +984,13 @@ print_summary() failed_api=0 failed_chk=0 failed_esp=0 + failed_pyq=0 # set testlist set_qexe_testlist "$buildtype" # get the number of tests to run - get_total_tests "$test_ene" "$test_grad" "$test_opt" "$test_api" "$test_chk" "$test_esp" + get_total_tests "$test_ene" "$test_grad" "$test_opt" "$test_api" "$test_chk" "$test_esp" "$test_pyq" # grab stuff from temporary log files sed -n '/'$buildtype' version started/,/All '$buildtype' tests are done/p' $QUICK_HOME/.quick.${buildtype}.runtest.log > .quick.runtest.${buildtype}.summary.tmp1 @@ -1025,12 +1040,16 @@ print_summary() elif [ `sed -n '/execution failed/p' .quick.runtest.${buildtype}.summary.tmp2 | wc -l` -gt 0 ]; then failed_esp=$(($failed_esp+1)) fi + elif [ `sed -n '/Python interface test/p' .quick.runtest.${buildtype}.summary.tmp2 | wc -l` -gt 0 ]; then + if [ `sed -n '/possible FAILURE/p' .quick.runtest.${buildtype}.summary.tmp2 | wc -l` -gt 0 ]; then + failed_pyq=$(($failed_pyq+1)) + fi fi rm -f .quick.runtest.${buildtype}.summary.tmp2 done - failed_total=$(($failed_ene+$failed_grad+$failed_opt+$failed_api+$failed_chk+$failed_esp)) + failed_total=$(($failed_ene+$failed_grad+$failed_opt+$failed_api+$failed_chk+$failed_esp+$failed_pyq)) # update cumulative failed tests too ncum_failed_tests=$(($ncum_failed_tests+$failed_total)) @@ -1042,6 +1061,7 @@ print_summary() echo " API tests: $failed_api/$tot_api_tests (failed/total)" >> .quick.runtest.${buildtype}.summary.tmp0 echo " RW tests: $failed_chk/$tot_chk_tests (failed/total)" >> .quick.runtest.${buildtype}.summary.tmp0 echo " ESP tests: $failed_esp/$tot_esp_tests (failed/total)" >> .quick.runtest.${buildtype}.summary.tmp0 + echo " Python tests: $failed_pyq/$tot_pyq_tests (failed/total)" >> .quick.runtest.${buildtype}.summary.tmp0 echo " Total tests: $failed_total/$total_tests (failed/total)" >> .quick.runtest.${buildtype}.summary.tmp0 echo "" >> .quick.runtest.${buildtype}.summary.tmp0 @@ -1091,6 +1111,75 @@ check_mpi_gpu_mismatch() } +# Function to check if the pyquick Python interface is importable. +# Returns 1 (skip) if the interface is not available, 0 (run) if it is. +# +# arguments: (none) +check_python_error() +{ + python3 -c "import pyquick" > /dev/null 2>&1 + if [ "$?" -ne 0 ]; then ret=1; else ret=0; fi + + return "$ret" +} + + +# wrapper function for Python interface (pyquick) tests. +# +# arguments: +# $1: test stem (e.g. pyq_ene_H2O_rhf_sto3g) +# $2: test name/description +# $3: test number for this test +# $4: total number of tests +# $5: directory containing test files and scripts +# $6: test summary log file +# $7: test verbose log file (captures stdout/stderr from running the Python script) +# $8: job slot (GNU parallel) (1-based indexing) +run_pyquick_test() +{ + check_python_error + check_python_error_ret="$?" + + lock_file "$6" + echo "Running test $3 of $4" | tee -a "$6" + echo "$2" | tee -a "$6" + + if [ "$check_python_error_ret" -eq 1 ]; then + echo "SKIPPED (Python interface not built)." | tee -a "$6" + echo "==============================================================" | tee -a "$6" + echo "" | tee -a "$6" + unlock_file "$6" + return + fi + unlock_file "$6" + + # Run the Python test script + rm -f "$1.py.tmp" + python3 "$5/$1.py" > "$1.py.tmp" 2>&1 + pyexe_ret="$?" + + lock_file "$7" + echo "Test $3 of $4" >> "$7" + echo "$2" >> "$7" + echo "Python test output (stdout/stderr):" >> "$7" + cat "$1.py.tmp" >> "$7" + rm -f "$1.py.tmp" + echo >> "$7" + echo >> "$7" + unlock_file "$7" + + lock_file "$6" + if [ "$pyexe_ret" -eq 0 ]; then + echo "PASSED." | tee -a "$6" + else + echo "possible FAILURE" | tee -a "$6" + fi + echo "==============================================================" | tee -a "$6" + echo "" | tee -a "$6" + unlock_file "$6" +} + + check_for_req_deps "awk" "cat" "date" "echo" "grep" "head" "rm" "sed" "seq" "tail" "tee" "touch" "wc" # !---------------------------------------------------------------------! @@ -1110,6 +1199,7 @@ while [ $# -gt 0 ]; do --api) test_api='yes';uspec_test='yes';; --chk) test_chk='yes';uspec_test='yes';; --esp) test_esp='yes';uspec_test='yes';; + --python) test_pyq='yes';uspec_test='yes';; --full) test_length='full';; --nolog) log='no';; -h| -H| -help| --help) print_help;; @@ -1338,9 +1428,9 @@ if [ -n "$PARALLEL_TEST_COUNT" ]; then # utilizes a export and eval approach for string representation of shell functions) export buildtype qbindir qexe apipath apiexe ismpirun has_parallel cuda cudampi hip hipmpi export -f set_job_gpus set_test run_test run_test_api run_energy_test run_grad_test run_opt_test run_api_test \ - run_chk_test run_esp_test check_chk check_energy check_gradient check_opt \ + run_chk_test run_esp_test run_pyquick_test check_chk check_energy check_gradient check_opt \ check_esp_charge check_esp_grid check_vdw_surface check_dipole \ - clean_up check_f_error check_mpi_gpu_mismatch lock_file unlock_file + clean_up check_f_error check_mpi_gpu_mismatch check_python_error lock_file unlock_file fi # !---------------------------------------------------------------------! @@ -1365,6 +1455,7 @@ if [ "$uspec_test" = 'no' ]; then test_api='yes' test_chk='yes' test_esp='yes' + test_pyq='yes' fi # !---------------------------------------------------------------------! @@ -1387,7 +1478,7 @@ for buildtype in $buildtypes; do set_qexe_testlist "$buildtype" # get the number of tests to run - get_total_tests "$test_ene" "$test_grad" "$test_opt" "$test_api" "$test_chk" "$test_esp" + get_total_tests "$test_ene" "$test_grad" "$test_opt" "$test_api" "$test_chk" "$test_esp" "$test_pyq" cd "$testdir/runs/$buildtype" @@ -1513,6 +1604,25 @@ for buildtype in $buildtypes; do fi fi + # Run Python interface (pyquick) tests + if [ "$test_pyq" = 'yes' ]; then + tc_start=$(($tc_end+1)) + tc_end=$(($tc_end+$tot_pyq_tests)) + if [ $has_parallel = 'no' ]; then + for tc in `seq $tc_start $tc_end`; do + run_pyquick_test "${tests[$(($tc-1))]}" "${test_names[$(($tc-1))]}" "$tc" "$total_tests" \ + "$testdir" "$QUICK_HOME/.quick.${buildtype}.runtest.log" \ + "$QUICK_HOME/.quick.${buildtype}.runtest-verbose.log" + done + elif [ $has_parallel = 'yes' ]; then + parallel --keep-order --group --jobs "$PARALLEL_TEST_COUNT" --xapply --will-cite run_pyquick_test {1} {2} {3} \ + "$total_tests" "$testdir" "$QUICK_HOME/.quick.${buildtype}.runtest.log" \ + "$QUICK_HOME/.quick.${buildtype}.runtest-verbose.log" {%} \ + ::: "${tests[@]:$(($tc_start-1)):$tot_pyq_tests}" ::: "${test_names[@]:$(($tc_start-1)):$tot_pyq_tests}" \ + ::: `seq $tc_start $tc_end` + fi + fi + echo "All $buildtype tests are done. The output files are located in $testdir/runs/$buildtype." | tee -a $QUICK_HOME/.quick.${buildtype}.runtest.log echo "" | tee -a $QUICK_HOME/.quick.${buildtype}.runtest.log