From 3904892cea3011f3fc0193306c64ce088aef436d Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" <64498081+galjos@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:47:36 +0200 Subject: [PATCH] Streamline screening workflows --- .github/workflows/docs.yml | 10 +- .github/workflows/publish.yml | 4 +- .github/workflows/python-app.yml | 30 +- README.md | 43 ++- ThermoScreening/calculator/dftbplus.py | 46 ++- ThermoScreening/cli/.gitignore | 161 --------- ThermoScreening/cli/__init__.py | 3 +- ThermoScreening/cli/dftb_setup.py | 123 ++++--- ThermoScreening/cli/slurm.py | 6 +- ThermoScreening/cli/thermo.py | 113 +++++-- ThermoScreening/dftb_data.py | 24 ++ ThermoScreening/thermo/__init__.py | 9 +- ThermoScreening/thermo/api.py | 18 +- ThermoScreening/thermo/screening.py | 414 ++++++++++++++++++++++-- conda-recipes/README.md | 54 ---- conda-recipes/thermoscreening/meta.yaml | 66 ---- docs/api.rst | 2 + docs/configuration.rst | 15 +- docs/installation.rst | 30 +- docs/usage.rst | 12 + examples/gibbs_dftb.log | 74 ----- examples/python.log | 225 ------------- examples/thermo_old.log | 133 -------- setup.cfg | 3 - tests/calculator/test_dftbplus.py | 23 +- tests/cli/test_dftb_setup.py | 59 +++- tests/cli/test_import.py | 16 + tests/cli/test_slurm.py | 27 +- tests/thermo/.coverage | Bin 53248 -> 0 bytes tests/thermo/test_main.py | 57 +++- tests/thermo/test_redox_workflow.py | 287 ++++++++++++++++ tests/thermo/test_screening.py | 90 +++++- 32 files changed, 1240 insertions(+), 937 deletions(-) delete mode 100644 ThermoScreening/cli/.gitignore create mode 100644 ThermoScreening/dftb_data.py delete mode 100644 conda-recipes/README.md delete mode 100644 conda-recipes/thermoscreening/meta.yaml delete mode 100644 examples/gibbs_dftb.log delete mode 100644 examples/python.log delete mode 100644 examples/thermo_old.log delete mode 100644 setup.cfg create mode 100644 tests/cli/test_import.py delete mode 100644 tests/thermo/.coverage diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 863874e..42cbe37 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,18 +14,18 @@ jobs: build-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 # setuptools_scm needs the tags/history - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Install the package and docs dependencies run: python -m pip install -e ".[docs]" - name: Build the documentation - run: python -m sphinx -b html docs docs/_build/html + run: python -m sphinx -W --keep-going -b html docs docs/_build/html - name: Upload the Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: docs/_build/html @@ -48,4 +48,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1aae6a4..c7f5563 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,10 +14,10 @@ jobs: permissions: id-token: write # OIDC token for Trusted Publishing steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 # setuptools_scm needs the tags - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Build sdist and wheel diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 025ef3a..2a6ebb8 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,6 +1,3 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - name: Python application on: @@ -18,12 +15,14 @@ permissions: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.12 with conda - uses: actions/setup-python@v3 + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Set up Python 3.12 + uses: actions/setup-python@v7 with: python-version: "3.12" - name: Add conda to system path @@ -34,22 +33,21 @@ jobs: run: | conda install -y -c conda-forge python=3.12 gsl dftbplus python -m pip install --upgrade pip - pip install . + python -m pip install '.[test,lint]' - name: Lint with Pylint - run: | - pip install '.[lint]' - python -m pylint ThermoScreening + run: python -m pylint ThermoScreening - name: Test with pytest + run: python -m pytest --cov=ThermoScreening --cov-report=xml + - name: Build package run: | - pip install '.[test]' - python -m pytest --cov=ThermoScreening --cov-report=xml - shell: bash + python -m pip install build + python -m build - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} - env_vars: OS,PYTHON + files: ./coverage.xml fail_ci_if_error: true flags: unittests verbose: true diff --git a/README.md b/README.md index 82a6af4..02aeb25 100644 --- a/README.md +++ b/README.md @@ -30,27 +30,30 @@ python -m sphinx -b html docs docs/_build/html # open docs/_build/html/index.h ## Installation -Install the package from a checkout: +Install the released package: ```bash -python -m pip install . +python -m pip install thermoscreening ``` -For development and tests: +For a complete environment with DFTB+, native xTB, and tblite: ```bash -python -m pip install -e ".[test,lint]" +conda create -n thermoscreening -c conda-forge \ + python=3.12 dftbplus xtb tblite-python pip +conda activate thermoscreening +python -m pip install thermoscreening ``` -For a Conda-based development environment with all calculation backends -(DFTB+, `modes`, xtb, tblite) included: +From a source checkout, the equivalent development environment is: ```bash conda env create -f environment.yml conda activate thermoscreening ``` -Then `thermo doctor` should report every backend as found. +Run `thermo doctor` to report the usable engines, or check one explicitly with +`thermo doctor --engine dftb+`. ## DFTB+ Setup @@ -65,25 +68,22 @@ Install DFTB+ with Conda if it is not already available: conda install -c conda-forge dftbplus ``` -Download the default `3ob-3-1` Slater-Koster files into a user-local directory: +Download the default `3ob-3-1` Slater-Koster files into the automatically +discovered user-local directory: ```bash thermo setup-dftb ``` -The command prints the `DFTB_PREFIX` export needed by DFTB+ and ThermoScreening: - -```bash -export DFTB_PREFIX="$HOME/.local/share/thermoscreening/slakos/3ob-3-1/" -``` - -Add that line to your shell configuration for persistent use. Verify the setup with: +Verify the setup with: ```bash thermo doctor ``` -ThermoScreening does not vendor Slater-Koster files. For custom installations, point the calculator to a parameter directory with `DFTB_PREFIX` or pass `slako_dir` explicitly: +ThermoScreening does not vendor Slater-Koster files. For custom installations, +override the discovered directory with `DFTB_PREFIX` or pass `slako_dir` +explicitly: ```python from ThermoScreening.thermo.api import dftbplus_thermo @@ -101,7 +101,16 @@ The bundled DFTB+ parameters were removed from the repository because they are l Run thermochemistry from an input file with the command-line entry point: ```bash -thermo path/to/thermo.in +thermo run path/to/thermo.in +``` + +The historic `thermo path/to/thermo.in` form remains supported. + +Run a complete reference-calibrated redox screen locally or as a Slurm array: + +```bash +thermo redox molecules.csv --engine xtb-cli --solvent acetonitrile -o redox +thermo slurm --tasks 32 --submit -- redox molecules.csv -o redox ``` Use the Python API when integrating ThermoScreening into another workflow: diff --git a/ThermoScreening/calculator/dftbplus.py b/ThermoScreening/calculator/dftbplus.py index 85fd9fd..0b7db23 100644 --- a/ThermoScreening/calculator/dftbplus.py +++ b/ThermoScreening/calculator/dftbplus.py @@ -8,6 +8,13 @@ from ase.calculators.dftb import Dftb from ase.io import read +from ThermoScreening.dftb_data import ( + DEFAULT_PARAMETER_SET, + REQUIRED_PARAMETER_FILE, + canonical_parameter_set, + default_parameter_dir, +) + from ..utils.physicalConstants import PhysicalConstants # --------------------------------------------------------------------------- # @@ -34,12 +41,19 @@ def _read_hessian_matrix(filename, size): return values.reshape(size, size) -def _slako_dir(slako_dir=None): +def resolve_slako_dir(slako_dir=None, parameter_set=DEFAULT_PARAMETER_SET): + """Resolve an explicit, configured, or downloaded parameter directory.""" selected_dir = slako_dir or os.getenv("DFTB_PREFIX") + if not selected_dir: + downloaded = default_parameter_dir(parameter_set) + if (downloaded / REQUIRED_PARAMETER_FILE).is_file(): + selected_dir = downloaded if not selected_dir: raise FileNotFoundError( "Slater-Koster files are not bundled with ThermoScreening. " - "Set DFTB_PREFIX or pass slako_dir to the DFTB+ calculator." + f"Run 'thermo setup-dftb --parameter-set " + f"{canonical_parameter_set(parameter_set).split('-', maxsplit=1)[0]}', " + "set DFTB_PREFIX, or pass slako_dir explicitly." ) selected_dir = os.path.abspath(os.path.expanduser(selected_dir)) @@ -51,6 +65,10 @@ def _slako_dir(slako_dir=None): return selected_dir + os.sep +def _slako_dir(slako_dir=None, parameter_set=DEFAULT_PARAMETER_SET): + return resolve_slako_dir(slako_dir, parameter_set) + + # Atomic spin constants (Hartree): the spin constant of the highest occupied # shell per element (Wss for H, Wpp for the p-block, valence Wss for the s-block # and Zn), used with ShellResolvedSpin = No to match the atom-resolved SCC. These @@ -245,7 +263,7 @@ def _dispersion_kwargs(dispersion=None): class Geoopt(Dftb): """ - Custom DFTB+ calculator to optimize the system with the 'GeometryOptimisation' driver (Rational). + Custom DFTB+ calculator using the ``GeometryOptimisation`` Rational driver. It is a subclass of ase.calculators.dftb.Dftb. It uses the 'LBFGS' driver. @@ -258,8 +276,8 @@ class Geoopt(Dftb): charge : int Charge of the system. Default is 0. slako_dir : str - Path to the Slater-Koster files. If None, it will look for - the DFTB_PREFIX environment variable. + Path to the Slater-Koster files. If None, use ``DFTB_PREFIX`` or the + user-local set downloaded by ``thermo setup-dftb``. max_force : float Maximum force component. Default is 1.0e-6. @@ -275,6 +293,7 @@ def __init__( label="geo_opt", charge=0, slako_dir=None, + parameter_set=DEFAULT_PARAMETER_SET, max_force=1.0e-6, **kwargs, ): @@ -290,8 +309,8 @@ def __init__( charge : int Charge of the system. Default is 0. slako_dir : str - Path to the Slater-Koster files. If None, it will look - for the DFTB_PREFIX environment variable. + Path to the Slater-Koster files. If None, use ``DFTB_PREFIX`` or the + automatically discovered downloaded set. max_force : float Maximum force component. Default is 1.0e-6. @@ -304,7 +323,7 @@ def __init__( super().__init__( atoms=atoms, label=label, - slako_dir=_slako_dir(slako_dir), + slako_dir=_slako_dir(slako_dir, parameter_set), Hamiltonian_Charge=charge, Driver_="GeometryOptimisation", Driver_Optimiser="Rational {}", @@ -361,8 +380,8 @@ class Hessian(Dftb): charge : int Charge of the system. Default is 0. slako_dir : str - Path to the Slater-Koster files. If None, it will look for - the DFTB_PREFIX environment variable. + Path to the Slater-Koster files. If None, use ``DFTB_PREFIX`` or the + user-local downloaded set. Other Parameters: ----------------- @@ -377,6 +396,7 @@ def __init__( charge=0, delta=1.0e-4, slako_dir=None, + parameter_set=DEFAULT_PARAMETER_SET, **kwargs, ): """ @@ -393,8 +413,8 @@ def __init__( delta : float Finite difference step. Default is 1.0e-4. slako_dir : str - Path to the Slater-Koster files. If None, it will look - for the DFTB_PREFIX environment variable. + Path to the Slater-Koster files. If None, use ``DFTB_PREFIX`` or the + automatically discovered downloaded set. Other Parameters: ----------------- @@ -405,7 +425,7 @@ def __init__( super().__init__( atoms=atoms, label=label, - slako_dir=_slako_dir(slako_dir), + slako_dir=_slako_dir(slako_dir, parameter_set), Hamiltonian_Charge=charge, Driver_="SecondDerivatives", Driver_Delta=delta, diff --git a/ThermoScreening/cli/.gitignore b/ThermoScreening/cli/.gitignore deleted file mode 100644 index 1a50163..0000000 --- a/ThermoScreening/cli/.gitignore +++ /dev/null @@ -1,161 +0,0 @@ - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file diff --git a/ThermoScreening/cli/__init__.py b/ThermoScreening/cli/__init__.py index 6794e2f..c58295e 100644 --- a/ThermoScreening/cli/__init__.py +++ b/ThermoScreening/cli/__init__.py @@ -1,6 +1,5 @@ """Command-line interface for ThermoScreening.""" from .thermo import main -from ..utils import print_header -print_header() +__all__ = ["main"] diff --git a/ThermoScreening/cli/dftb_setup.py b/ThermoScreening/cli/dftb_setup.py index 64bd9a1..4d8beac 100644 --- a/ThermoScreening/cli/dftb_setup.py +++ b/ThermoScreening/cli/dftb_setup.py @@ -10,13 +10,19 @@ import importlib.util import os import shutil +import subprocess import tarfile import tempfile from urllib.request import urlopen +from ThermoScreening.dftb_data import ( + DEFAULT_PARAMETER_SET, + REQUIRED_PARAMETER_FILE, + canonical_parameter_set, + default_parameter_dir as calculator_parameter_dir, + default_parameter_root, +) -DEFAULT_PARAMETER_SET = "3ob-3-1" -REQUIRED_PARAMETER_FILE = "C-C.skf" # Download URLs for the supported Slater-Koster sets (dftbparams GitHub releases). PARAMETER_SET_URLS = { @@ -28,16 +34,12 @@ ), } -# Short names accepted by the CLI (mapped to the canonical archive/directory name). -PARAMETER_SET_ALIASES = {"3ob": "3ob-3-1", "mio": "mio-1-1"} - - def _canonical_set_name(parameter_set: str) -> str: """ Map a short set name (e.g. ``"mio"``) to its canonical name (``"mio-1-1"``). """ - return PARAMETER_SET_ALIASES.get(parameter_set, parameter_set) + return canonical_parameter_set(parameter_set) def slako_url(parameter_set: str = DEFAULT_PARAMETER_SET) -> str: @@ -80,7 +82,7 @@ def default_install_root() -> Path: Return the default user-local Slater-Koster install directory. """ - return Path.home() / ".local" / "share" / "thermoscreening" / "slakos" + return default_parameter_root() def default_parameter_dir( @@ -91,8 +93,7 @@ def default_parameter_dir( Return the parameter directory for ``parameter_set`` under an install root. """ - root = Path(install_root).expanduser() if install_root is not None else default_install_root() - return root / _canonical_set_name(parameter_set) + return calculator_parameter_dir(parameter_set, install_root) def _download_file(url: str, destination: Path, timeout: int = 60) -> None: @@ -297,13 +298,15 @@ def _xtb_diagnostics(env: Mapping[str, str]) -> list[Diagnostic]: # native xtb binary (for --engine xtb-cli): honour XTB_COMMAND, then PATH xtb_command = env.get("XTB_COMMAND") or "xtb" - xtb_path = shutil.which(xtb_command) - xtb = Diagnostic( + xtb = _executable_diagnostic( "xtb", - xtb_path is not None, - xtb_path or "not found (set XTB_COMMAND or `conda install -c conda-forge xtb`; " - "needed for --engine xtb-cli)", + xtb_command, + expected="xtb", optional=True, + missing_detail=( + "not found (set XTB_COMMAND or `conda install -c conda-forge xtb`; " + "needed for --engine xtb-cli)" + ), ) # tblite python package (for the in-process --engine xtb) @@ -320,7 +323,44 @@ def _xtb_diagnostics(env: Mapping[str, str]) -> list[Diagnostic]: return [xtb, tblite] -def check_dftb_setup(env: dict[str, str] | None = None) -> list[Diagnostic]: +def _executable_diagnostic( + name, + command=None, + *, + expected=None, + optional=False, + missing_detail="not found on PATH", +): + """Check that an executable is present and can start.""" + executable = shutil.which(command or name) + if executable is None: + return Diagnostic(name, False, missing_detail, optional=optional) + try: + completed = subprocess.run( + [executable, "--help"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return Diagnostic(name, False, f"could not start: {exc}", optional=optional) + + output = f"{completed.stdout}\n{completed.stderr}" + loader_errors = ("Library not loaded", "error while loading shared libraries") + starts = not any(marker in output for marker in loader_errors) + if expected is not None: + starts = starts and expected.lower() in output.lower() + else: + starts = starts and completed.returncode == 0 + detail = executable if starts else "found but could not start correctly" + return Diagnostic(name, starts, detail, optional=optional) + + +def check_dftb_setup( + env: dict[str, str] | None = None, + parameter_set: str = "3ob", +) -> list[Diagnostic]: """ Check whether the calculation backends are available. @@ -331,42 +371,39 @@ def check_dftb_setup(env: dict[str, str] | None = None) -> list[Diagnostic]: current_env = os.environ if env is None else env prefix = current_env.get("DFTB_PREFIX") diagnostics = [ - Diagnostic( + _executable_diagnostic( "dftb+", - shutil.which("dftb+") is not None, - shutil.which("dftb+") or "not found on PATH", + expected="DFTB+", ), - Diagnostic( + _executable_diagnostic( "modes", - shutil.which("modes") is not None, - shutil.which("modes") or "not found on PATH", + expected="DFTB+", ), ] if not prefix: - diagnostics.extend( - [ - Diagnostic("DFTB_PREFIX", False, "not set"), - Diagnostic(REQUIRED_PARAMETER_FILE, False, "DFTB_PREFIX is not set"), - ] - ) + parameter_dir = default_parameter_dir(parameter_set=parameter_set) + source = f"downloaded {canonical_parameter_set(parameter_set)}" else: parameter_dir = Path(prefix).expanduser() - parameter_file = parameter_dir / REQUIRED_PARAMETER_FILE - diagnostics.extend( - [ - Diagnostic( - "DFTB_PREFIX", - parameter_dir.is_dir(), - str(parameter_dir.resolve()) if parameter_dir.is_dir() else "directory not found", - ), - Diagnostic( - REQUIRED_PARAMETER_FILE, - parameter_file.is_file(), - str(parameter_file.resolve()) if parameter_file.is_file() else "not found", - ), - ] - ) + source = "DFTB_PREFIX" + parameter_file = parameter_dir / REQUIRED_PARAMETER_FILE + diagnostics.extend( + [ + Diagnostic( + "parameters", + parameter_dir.is_dir(), + f"{source}: {parameter_dir.resolve()}" + if parameter_dir.is_dir() + else f"{source} directory not found", + ), + Diagnostic( + REQUIRED_PARAMETER_FILE, + parameter_file.is_file(), + str(parameter_file.resolve()) if parameter_file.is_file() else "not found", + ), + ] + ) diagnostics.extend(_xtb_diagnostics(current_env)) return diagnostics diff --git a/ThermoScreening/cli/slurm.py b/ThermoScreening/cli/slurm.py index 88c2d6b..e2a7d64 100644 --- a/ThermoScreening/cli/slurm.py +++ b/ThermoScreening/cli/slurm.py @@ -44,14 +44,14 @@ def write_slurm_array_script( working_directory=None, python_executable=None, ): - """Write a Slurm array that runs one deterministic screen shard per task.""" + """Write a Slurm array that runs one deterministic workflow shard per task.""" tasks = _positive_integer(tasks, "tasks") local_jobs = _positive_integer(local_jobs, "local_jobs") cpus_per_task = _positive_integer(cpus_per_task, "cpus_per_task") if local_jobs > cpus_per_task: raise TSValueError("local --jobs cannot exceed cpus_per_task.") - if not command_args or command_args[0] != "screen": - raise TSValueError("Slurm arrays currently support the screen command.") + if not command_args or command_args[0] not in {"screen", "redox"}: + raise TSValueError("Slurm arrays support the screen and redox commands.") if "--shard-index" in command_args or "--shard-count" in command_args: raise TSValueError("Do not pass shard options to the Slurm generator.") diff --git a/ThermoScreening/cli/thermo.py b/ThermoScreening/cli/thermo.py index c1aa4f6..1d1a68b 100644 --- a/ThermoScreening/cli/thermo.py +++ b/ThermoScreening/cli/thermo.py @@ -6,7 +6,6 @@ from ThermoScreening.cli.dftb_setup import ( check_dftb_setup, - dftb_prefix_export, format_diagnostics, install_gbsa_param, install_slakos, @@ -15,7 +14,7 @@ from ThermoScreening.cli.slurm import write_slurm_array_script, submit_slurm_array from ThermoScreening.thermo.api import execute from ThermoScreening.thermo.screening import ( - collect_screen_shards, + collect_shards, redox_screen, screen, screen_shard_directory, @@ -26,6 +25,7 @@ SUBCOMMANDS = { + "run", "setup-dftb", "doctor", "screen", @@ -36,19 +36,25 @@ } -def _run_parser(): - parser = ArgumentParser(description="ThermoScreening") +def _add_run_arguments(parser): + """Add arguments for the legacy input-file workflow.""" parser.add_argument("input_file", type=str, help="Input file") parser.add_argument( - "-v", "--verbose", action="store_true", help="Verbose output", default=True + "-v", "--verbose", action="store_true", help="Print timing information." ) - return parser def _command_parser(): parser = ArgumentParser(description="ThermoScreening") + parser.add_argument("--version", action="version", version=__version__) subparsers = parser.add_subparsers(dest="command", required=True) + run_parser = subparsers.add_parser( + "run", + help="Run thermochemistry from a ThermoScreening input file.", + ) + _add_run_arguments(run_parser) + setup_parser = subparsers.add_parser( "setup-dftb", help="Download a DFTB+ Slater-Koster parameter set.", @@ -81,9 +87,21 @@ def _command_parser(): help="Download and extract even when the parameter set already exists.", ) - subparsers.add_parser( + doctor_parser = subparsers.add_parser( "doctor", - help="Check DFTB+ executables and Slater-Koster parameter configuration.", + help="Check calculation backends and parameter configuration.", + ) + doctor_parser.add_argument( + "--engine", + choices=["all", "dftb+", "xtb", "xtb-cli"], + default="all", + help="Backend to require. 'all' succeeds when at least one backend is ready.", + ) + doctor_parser.add_argument( + "--parameter-set", + choices=["3ob", "mio"], + default="3ob", + help="DFTB+ parameter set to check (default '3ob').", ) screen_parser = subparsers.add_parser( @@ -169,7 +187,7 @@ def _command_parser(): collect_parser = subparsers.add_parser( "collect", - help="Validate and combine distributed screen shards.", + help="Validate and combine distributed screening shards.", ) collect_parser.add_argument( "shard_directory", @@ -184,7 +202,7 @@ def _command_parser(): slurm_parser = subparsers.add_parser( "slurm", - help="Generate or submit a Slurm array for thermo screen.", + help="Generate or submit a Slurm array for screen or redox.", ) slurm_parser.add_argument("--tasks", type=int, required=True) slurm_parser.add_argument("--script", default="thermoscreening.slurm") @@ -207,7 +225,7 @@ def _command_parser(): slurm_parser.add_argument( "command_args", nargs=REMAINDER, - help="Screen command after '--', for example: -- screen molecules.csv -o out.", + help="Screen or redox command after '--'.", ) redox_parser = subparsers.add_parser( @@ -312,6 +330,18 @@ def _command_parser(): default=1, help="Number of charge-state calculations to run concurrently.", ) + redox_parser.add_argument( + "--shard-index", + type=int, + default=None, + help="Zero-based cluster shard to execute; requires --shard-count.", + ) + redox_parser.add_argument( + "--shard-count", + type=int, + default=None, + help="Total number of deterministic cluster shards.", + ) conf_parser = subparsers.add_parser( "conformers", @@ -351,15 +381,10 @@ def parse_args(argv=None): if argv is None: argv = sys.argv[1:] - if argv and argv[0] in SUBCOMMANDS: - parser = _command_parser() - args = parser.parse_args(argv) - else: - parser = _run_parser() - args = parser.parse_args(argv) - args.command = "run" - - return args + parser = _command_parser() + if argv and argv[0] not in SUBCOMMANDS and not argv[0].startswith("-"): + argv = ["run", *argv] + return parser.parse_args(argv) def run_setup_dftb(parser_args): @@ -385,22 +410,33 @@ def run_setup_dftb(parser_args): ) print("Slater-Koster files: ", parameter_dir) - print("Shell configuration:") - print(dftb_prefix_export(parameter_dir)) + print("The parameter set is ready for automatic discovery.") return 0 -def run_doctor(): +def run_doctor(parser_args): """ Check whether the calculation backends are available. """ - diagnostics = check_dftb_setup() + diagnostics = check_dftb_setup(parameter_set=parser_args.parameter_set) print(format_diagnostics(diagnostics)) - # optional backends (xtb, tblite) do not fail the check - return 0 if all(item.ok for item in diagnostics if not item.optional) else 1 + status = {item.name: item.ok for item in diagnostics} + ready = { + "dftb+": all( + status.get(name, False) + for name in ("dftb+", "modes", "parameters", "C-C.skf") + ), + "xtb": status.get("tblite", False), + "xtb-cli": status.get("xtb", False), + } + if parser_args.engine == "all": + available = ", ".join(name for name, ok in ready.items() if ok) or "none" + print(f"Usable engines: {available}") + return 0 if any(ready.values()) else 1 + return 0 if ready[parser_args.engine] else 1 def run_screen(parser_args): @@ -429,7 +465,7 @@ def run_screen(parser_args): ranked = rank_by_gibbs(results) if ranked: - print("Ranked by Gibbs free energy (most stable first):") + print("Comparable structures ranked by Gibbs free energy:") for position, record in enumerate(ranked, start=1): print(f" {position}. {record['name']} G = {record['G_total_hartree']:.6f} Ha") @@ -453,7 +489,7 @@ def run_screen(parser_args): def run_collect(parser_args): """Combine and validate distributed screening results.""" try: - results = collect_screen_shards(parser_args.shard_directory, out=parser_args.out) + results = collect_shards(parser_args.shard_directory, out=parser_args.out) except TSValueError as exc: print(f"Collection failed: {exc}", file=sys.stderr) return 1 @@ -470,8 +506,8 @@ def run_slurm(parser_args): if command_args and command_args[0] == "--": command_args.pop(0) try: - if not command_args or command_args[0] != "screen": - raise TSValueError("Slurm arrays currently support the screen command.") + if not command_args or command_args[0] not in {"screen", "redox"}: + raise TSValueError("Slurm arrays support the screen and redox commands.") nested = parse_args(command_args) script = write_slurm_array_script( command_args, @@ -534,6 +570,8 @@ def run_redox(parser_args): reference_charge=parser_args.reference_charge, potential_scale=parser_args.potential_scale, max_conformers=parser_args.max_conformers, + shard_index=parser_args.shard_index, + shard_count=parser_args.shard_count, ) except (TSValueError, ValueError) as exc: print(f"Redox screen failed: {exc}", file=sys.stderr) @@ -555,9 +593,16 @@ def run_redox(parser_args): for result in failures: print(f" {result['name']}: {result['error']}") print(f"Processed {len(results)} molecules ({len(failures)} failed).") - print(f"Results: {parser_args.out}.csv, {parser_args.out}.json") - print(f"State results: {parser_args.out}-states.csv, {parser_args.out}-states.json") - print(f"Run metadata: {parser_args.out}-run.json") + if parser_args.shard_index is None: + result_stem = parser_args.out + else: + result_stem = ( + screen_shard_directory(parser_args.out) + / f"shard-{parser_args.shard_index:05d}" + ) + print(f"Results: {result_stem}.csv, {result_stem}.json") + print(f"State results: {result_stem}-states.csv, {result_stem}-states.json") + print(f"Run metadata: {result_stem}-run.json") return 1 if failures else 0 @@ -605,7 +650,7 @@ def main(): return run_setup_dftb(parser_args) if command == "doctor": - return run_doctor() + return run_doctor(parser_args) if command == "screen": return run_screen(parser_args) diff --git a/ThermoScreening/dftb_data.py b/ThermoScreening/dftb_data.py new file mode 100644 index 0000000..1828c9f --- /dev/null +++ b/ThermoScreening/dftb_data.py @@ -0,0 +1,24 @@ +"""Shared locations and names for external DFTB+ parameter data.""" + +from pathlib import Path + + +DEFAULT_PARAMETER_SET = "3ob-3-1" +REQUIRED_PARAMETER_FILE = "C-C.skf" +PARAMETER_SET_ALIASES = {"3ob": "3ob-3-1", "mio": "mio-1-1"} + + +def canonical_parameter_set(parameter_set): + """Return the canonical directory name for a parameter-set alias.""" + return PARAMETER_SET_ALIASES.get(parameter_set, parameter_set) + + +def default_parameter_root(): + """Return the user-local directory containing parameter sets.""" + return Path.home() / ".local" / "share" / "thermoscreening" / "slakos" + + +def default_parameter_dir(parameter_set=DEFAULT_PARAMETER_SET, install_root=None): + """Return the user-local directory for a parameter set.""" + root = Path(install_root).expanduser() if install_root else default_parameter_root() + return root / canonical_parameter_set(parameter_set) diff --git a/ThermoScreening/thermo/__init__.py b/ThermoScreening/thermo/__init__.py index 13aa2b9..e96a420 100644 --- a/ThermoScreening/thermo/__init__.py +++ b/ThermoScreening/thermo/__init__.py @@ -6,7 +6,14 @@ from .inputFileReader import InputFileReader from .system import System from .thermo import Thermo -from .screening import collect_screen_shards, redox_screen, screen, rank_by_gibbs +from .screening import ( + collect_redox_shards, + collect_screen_shards, + collect_shards, + redox_screen, + screen, + rank_by_gibbs, +) from .conformers import generate as generate_conformers, write_conformers, generate_thermo_ensemble from .reactions import ( calibrate_reduction_reference, diff --git a/ThermoScreening/thermo/api.py b/ThermoScreening/thermo/api.py index d86120e..318ccfe 100644 --- a/ThermoScreening/thermo/api.py +++ b/ThermoScreening/thermo/api.py @@ -794,6 +794,7 @@ def dftbplus_thermo( pressure=101325, charge=0.0, directory=None, + parameter_set="3ob", spin=None, spin_constants=None, solvent=None, @@ -819,6 +820,9 @@ def dftbplus_thermo( Working directory to run the DFTB+ steps in (created if needed). The geometry/Hessian/modes files are written here, so separate jobs can run without clobbering each other. Defaults to the current directory. + parameter_set : str + Slater-Koster set to locate when ``slako_dir`` is not supplied. Defaults + to ``"3ob"``. spin : float, optional Spin quantum number S. Defaults to the minimum-spin electron-count guess @@ -886,12 +890,22 @@ def dftbplus_thermo( with _run_in_directory(directory): # run geometry optimization - geoopt = Geoopt(atoms=atoms, charge=charge, **engine_kwargs) + geoopt = Geoopt( + atoms=atoms, + charge=charge, + parameter_set=parameter_set, + **engine_kwargs, + ) potential_energy = geoopt.potential_energy() optimized_atoms = geoopt.read() # run hessian calculation - Hessian(atoms=optimized_atoms, charge=charge, **engine_kwargs) + Hessian( + atoms=optimized_atoms, + charge=charge, + parameter_set=parameter_set, + **engine_kwargs, + ) # run normal mode calculation modes = Modes() diff --git a/ThermoScreening/thermo/screening.py b/ThermoScreening/thermo/screening.py index 838fca9..34ed35b 100644 --- a/ThermoScreening/thermo/screening.py +++ b/ThermoScreening/thermo/screening.py @@ -14,6 +14,7 @@ import logging import math import os +import re from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path @@ -38,6 +39,7 @@ _RESULT_FIELDS = [ "name", "path", + "formula", "charge", "status", "Eelec_hartree", @@ -172,7 +174,7 @@ def _thermo_summary(thermo): def rank_by_gibbs(results): """ - Return the successful screen records sorted by Gibbs free energy. + Sort comparable successful records by Gibbs free energy. Parameters ---------- @@ -182,10 +184,20 @@ def rank_by_gibbs(results): Returns ------- list of dict - The records whose ``status`` is ``"ok"`` (and that carry a - ``G_total_hartree``), sorted by total Gibbs free energy ascending -- - i.e. the most stable molecule first. Failed records are omitted. + Successful records sorted by total Gibbs free energy, or an empty list + when the screen mixes molecular formulas or charges. Absolute molecular + energies are only comparable for equal-composition species calculated + with the same settings. """ + comparison_keys = { + (record.get("formula"), record.get("charge")) for record in results + } + if ( + not results + or any(formula is None for formula, _charge in comparison_keys) + or len(comparison_keys) != 1 + ): + return [] ranked = [ record for record in results @@ -332,7 +344,14 @@ def _write_json(path, payload): ) -def _write_screen_run_metadata(out, source, settings, job_provenance, shard=None): +def _write_screen_run_metadata( + out, + source, + settings, + job_provenance, + shard=None, + input_set_fingerprint=None, +): payload = { "schema_version": 1, "workflow": "thermochemistry_screen", @@ -340,6 +359,8 @@ def _write_screen_run_metadata(out, source, settings, job_provenance, shard=None "settings": settings, "jobs": job_provenance, } + if input_set_fingerprint is not None: + payload["input_set_fingerprint"] = input_set_fingerprint if shard is not None: payload["shard"] = shard path = _sidecar_path(out, "-run.json") @@ -408,6 +429,7 @@ def _run_job( quasi_rrho, parameters, spin_constants, + parameter_set, ): """ Run one screening job and return its result record. @@ -426,6 +448,7 @@ def _run_job( } try: atoms = ase.io.read(str(job.path)) + record["formula"] = atoms.get_chemical_formula() if engine == "xtb-cli": thermo = xtb_cli_thermo( atoms, @@ -458,6 +481,7 @@ def _run_job( directory=str(root / job.name), spin=job.spin, spin_constants=spin_constants, + parameter_set=parameter_set, solvent=solvent, dispersion=dispersion, quasi_rrho=quasi_rrho, @@ -574,7 +598,7 @@ def screen( spin_constants = None if engine == "dftb+": default_parameters, spin_constants = resolve_parameter_set(parameter_set) - parameters = default_parameters if parameters is None else parameters + parameters = default_parameters if parameters is None else dict(parameters) all_jobs = _load_jobs(source, charge, spin) root = Path(directory) @@ -602,12 +626,21 @@ def screen( job_list = [all_jobs[index] for index in selected] out = _screen_shard_stem(out, shard["index"]) root = root / "shards" / f"shard-{shard['index']:05d}" - provenance = [ - _job_provenance(all_jobs[index], settings, input_index=index) - for index in selected + all_provenance = [ + _job_provenance(job, settings, input_index=index) + for index, job in enumerate(all_jobs) ] + provenance = [all_provenance[index] for index in selected] + input_set_fingerprint = _payload_fingerprint(all_provenance) fingerprints = {item["name"]: item["fingerprint"] for item in provenance} - _write_screen_run_metadata(out, source, settings, provenance, shard=shard) + _write_screen_run_metadata( + out, + source, + settings, + provenance, + shard=shard, + input_set_fingerprint=input_set_fingerprint, + ) completed = ( _load_completed(out, expected_fingerprints=fingerprints) if resume else {} ) @@ -624,6 +657,7 @@ def screen( quasi_rrho=quasi_rrho, parameters=parameters, spin_constants=spin_constants, + parameter_set=parameter_set, ) # records keyed by name; the returned list follows the input job order @@ -701,16 +735,26 @@ def _read_json(path, expected_type): return payload +def _shard_metadata_paths(shard_directory): + pattern = re.compile(r"shard-\d+-run\.json") + return sorted( + path + for path in Path(shard_directory).glob("shard-*-run.json") + if pattern.fullmatch(path.name) + ) + + def collect_screen_shards(shard_directory, out="results"): """Validate and combine all shards from a distributed screen.""" shard_directory = Path(shard_directory) - metadata_paths = sorted(shard_directory.glob("shard-*-run.json")) + metadata_paths = _shard_metadata_paths(shard_directory) if not metadata_paths: raise TSValueError(f"No screen shards found in '{shard_directory}'.") expected_count = None expected_settings = None expected_source = None + expected_input_set_fingerprint = None seen_shards = set() records_by_name = {} provenance_by_name = {} @@ -731,14 +775,22 @@ def collect_screen_shards(shard_directory, out="results"): settings = metadata.get("settings") source = metadata.get("source") + input_set_fingerprint = metadata.get("input_set_fingerprint") if expected_count is None: expected_count = shard_count expected_settings = settings expected_source = source + expected_input_set_fingerprint = input_set_fingerprint elif shard_count != expected_count: raise TSValueError("Cluster shards disagree on shard_count.") - elif settings != expected_settings or source != expected_source: - raise TSValueError("Cluster shards were produced from different inputs or settings.") + elif ( + settings != expected_settings + or source != expected_source + or input_set_fingerprint != expected_input_set_fingerprint + ): + raise TSValueError( + "Cluster shards were produced from different inputs or settings." + ) result_name = metadata_path.name.removesuffix("-run.json") + ".json" result_path = metadata_path.with_name(result_name) @@ -827,6 +879,7 @@ def collect_screen_shards(shard_directory, out="results"): "source": expected_source, "settings": expected_settings, "jobs": provenance, + "input_set_fingerprint": expected_input_set_fingerprint, "collection": { "shard_count": expected_count, "shard_directory": str(shard_directory), @@ -1199,11 +1252,14 @@ def _write_redox_run_metadata( potential_scale, settings, max_conformers, + input_set_fingerprint, + candidate_indices=None, + shard=None, ): - def describe(job): + def describe(job, input_index=None): if job is None: return None - return { + description = { "name": job.name, "path": str(job.path), "structure_sha256": _file_sha256(job.path), @@ -1213,6 +1269,12 @@ def describe(job): for state, _offset, index in _REDOX_STATES }, } + if input_index is not None: + description["input_index"] = input_index + return description + + if candidate_indices is None: + candidate_indices = range(len(candidates)) payload = { "schema_version": 1, @@ -1228,7 +1290,11 @@ def describe(job): for state, offset, _index in _REDOX_STATES ], "settings": settings, - "candidates": [describe(job) for job in candidates], + "input_set_fingerprint": input_set_fingerprint, + "candidates": [ + describe(job, input_index) + for job, input_index in zip(candidates, candidate_indices) + ], "reference": { "calculation": describe(reference_job), "experimental_E1_V": reference_e1, @@ -1236,6 +1302,8 @@ def describe(job): "potential_scale": potential_scale, }, } + if shard is not None: + payload["shard"] = shard run_fingerprint = _payload_fingerprint(payload) payload["run_fingerprint"] = run_fingerprint _write_json(_sidecar_path(out, "-run.json"), payload) @@ -1265,6 +1333,8 @@ def redox_screen( reference_charge=None, potential_scale=None, max_conformers=20, + shard_index=None, + shard_count=None, ): """ Run a two-step molecular reduction screen from one starting geometry. @@ -1275,6 +1345,10 @@ def redox_screen( potentials. Each electronic state is optimized independently, but this is not a charge-state conformer search or ensemble-free-energy calculation. A reference molecule and measured E1/E2 may calibrate both steps. + + ``shard_index`` and ``shard_count`` select a deterministic candidate subset + for scheduler arrays. Use :func:`collect_redox_shards` to validate and + combine every shard. """ calibration = (reference, reference_e1, reference_e2) @@ -1307,6 +1381,8 @@ def redox_screen( if engine != "dftb+" and parameter_set is not None: raise TSValueError("parameter_set applies only to the DFTB+ engine.") + shard = _validate_shard(shard_index, shard_count) + resolved_parameter_set = parameter_set or "3ob" resolved_method = method or "GFN2-xTB" charge = _integer_charge(charge, None, "charge") @@ -1317,21 +1393,44 @@ def redox_screen( reference_e1 = _optional_float(reference_e1, None, "reference_e1") reference_e2 = _optional_float(reference_e2, None, "reference_e2") root = Path(directory) + if shard is not None: + out = _screen_shard_stem(out, shard["index"]) + root = root / "shards" / f"shard-{shard['index']:05d}" generated_directory = root / "inputs" - candidates = _load_redox_jobs( + all_candidates = _load_redox_jobs( source, charge, spin, generated_directory, max_conformers, ) + if shard is None: + candidate_indices = list(range(len(all_candidates))) + else: + candidate_indices = [ + index + for index in range(len(all_candidates)) + if index % shard["count"] == shard["index"] + ] + candidates = [all_candidates[index] for index in candidate_indices] + input_set_fingerprint = _payload_fingerprint( + [ + { + "name": job.name, + "structure_sha256": _file_sha256(job.path), + "oxidized_charge": job.charge, + "spins": job.spins, + } + for job in all_candidates + ] + ) reference_job = None calculation_jobs = list(candidates) if reference is not None: reference_job = _resolve_redox_reference( reference, - candidates, + all_candidates, reference_charge, generated_directory, max_conformers, @@ -1379,27 +1478,34 @@ def redox_screen( potential_scale=scale, settings=redox_settings, max_conformers=max_conformers, + input_set_fingerprint=input_set_fingerprint, + candidate_indices=candidate_indices, + shard=shard, ) state_manifest = root / "states.csv" mapping = _write_redox_state_manifest(calculation_jobs, state_manifest) state_out = f"{Path(str(out)).with_suffix('')}-states" - state_results = screen( - state_manifest, - out=state_out, - temperature=temperature, - pressure=pressure, - directory=root / "calculations", - parameters=parameters, - parameter_set=resolved_parameter_set, - solvent=solvent, - dispersion=dispersion, - quasi_rrho=quasi_rrho, - engine=engine, - method=resolved_method, - resume=resume, - jobs=jobs, - ) + if calculation_jobs: + state_results = screen( + state_manifest, + out=state_out, + temperature=temperature, + pressure=pressure, + directory=root / "calculations", + parameters=parameters, + parameter_set=resolved_parameter_set, + solvent=solvent, + dispersion=dispersion, + quasi_rrho=quasi_rrho, + engine=engine, + method=resolved_method, + resume=resume, + jobs=jobs, + ) + else: + _write_results([], state_out) + state_results = [] records = {record["name"]: record for record in state_results} reference_error = "" @@ -1460,3 +1566,243 @@ def redox_screen( csv_path, json_path = _write_redox_results(results, out) logger.info(f"Wrote {csv_path}, {json_path} and the per-state results") return results + + +def _normalized_redox_metadata(metadata): + """Return shard-independent redox metadata for consistency checks.""" + normalized = { + key: metadata.get(key) + for key in ( + "schema_version", + "workflow", + "source", + "single_starting_geometry_approximation", + "smiles_embedding", + "states", + "settings", + "input_set_fingerprint", + ) + } + reference = dict(metadata.get("reference") or {}) + calculation = dict(reference.get("calculation") or {}) + calculation.pop("path", None) + if calculation: + reference["calculation"] = calculation + normalized["reference"] = reference + return normalized + + +def _state_record_value(record): + """Remove location-only fields before comparing duplicated reference states.""" + return { + key: value + for key, value in record.items() + if key not in {"path", "fingerprint"} + } + + +def collect_redox_shards(shard_directory, out="redox-results"): + """Validate and combine all shards from a distributed redox screen.""" + shard_directory = Path(shard_directory) + metadata_paths = _shard_metadata_paths(shard_directory) + if not metadata_paths: + raise TSValueError(f"No redox shards found in '{shard_directory}'.") + + expected_count = None + expected_metadata = None + template = None + seen_shards = set() + records_by_name = {} + candidates_by_name = {} + states_by_name = {} + + for metadata_path in metadata_paths: + metadata = _read_json(metadata_path, dict) + if metadata.get("workflow") != "stepwise_reduction_screen": + raise TSValueError(f"'{metadata_path}' is not redox run metadata.") + shard = metadata.get("shard") + if not isinstance(shard, dict) or not {"index", "count"} <= set(shard): + raise TSValueError(f"'{metadata_path}' has no valid shard metadata.") + shard_index = shard["index"] + shard_count = shard["count"] + _validate_shard(shard_index, shard_count) + if shard_index in seen_shards: + raise TSValueError(f"Duplicate cluster shard index {shard_index}.") + seen_shards.add(shard_index) + + common = _normalized_redox_metadata(metadata) + if expected_count is None: + expected_count = shard_count + expected_metadata = common + template = metadata + elif shard_count != expected_count: + raise TSValueError("Cluster shards disagree on shard_count.") + elif common != expected_metadata: + raise TSValueError( + "Redox shards were produced from different inputs or settings." + ) + + result_path = metadata_path.with_name( + metadata_path.name.removesuffix("-run.json") + ".json" + ) + if not result_path.is_file(): + raise TSValueError(f"Result file is missing for cluster shard {shard_index}.") + shard_records = _read_json(result_path, list) + candidates = metadata.get("candidates") + if not isinstance(candidates, list): + raise TSValueError(f"'{metadata_path}' has invalid candidate metadata.") + candidates_for_shard = { + candidate.get("name"): candidate + for candidate in candidates + if isinstance(candidate, dict) + } + shard_records_by_name = { + record.get("name"): record + for record in shard_records + if isinstance(record, dict) + } + if ( + len(candidates_for_shard) != len(candidates) + or len(shard_records_by_name) != len(shard_records) + or set(candidates_for_shard) != set(shard_records_by_name) + ): + raise TSValueError( + f"Cluster shard {shard_index} results do not match its candidates." + ) + + run_fingerprint = metadata.get("run_fingerprint") + fingerprint_payload = dict(metadata) + fingerprint_payload.pop("run_fingerprint", None) + if _payload_fingerprint(fingerprint_payload) != run_fingerprint: + raise TSValueError( + f"Invalid redox run fingerprint for cluster shard {shard_index}." + ) + for name, candidate in candidates_for_shard.items(): + input_index = candidate.get("input_index") + if not isinstance(input_index, int): + raise TSValueError(f"Missing input index for {name!r}.") + if input_index % shard_count != shard_index: + raise TSValueError( + f"Input position for {name!r} does not belong to " + f"cluster shard {shard_index}." + ) + record = shard_records_by_name[name] + if record.get("run_fingerprint") != run_fingerprint: + raise TSValueError( + f"Run fingerprint mismatch for {name!r} in shard {shard_index}." + ) + if name in records_by_name: + raise TSValueError(f"Duplicate molecule {name!r} across redox shards.") + records_by_name[name] = record + candidates_by_name[name] = candidate + + state_path = metadata_path.with_name( + metadata_path.name.removesuffix("-run.json") + "-states.json" + ) + if not state_path.is_file(): + raise TSValueError( + f"State result file is missing for cluster shard {shard_index}." + ) + shard_states = _read_json(state_path, list) + shard_states_by_name = { + record.get("name"): record + for record in shard_states + if isinstance(record, dict) + } + calculation_names = set(candidates_for_shard) + reference = metadata.get("reference") or {} + reference_calculation = reference.get("calculation") or {} + if reference_calculation.get("name"): + calculation_names.add(reference_calculation["name"]) + expected_states = { + f"{name}--{state}" + for name in calculation_names + for state, _offset, _spin_index in _REDOX_STATES + } + if ( + len(shard_states_by_name) != len(shard_states) + or set(shard_states_by_name) != expected_states + ): + raise TSValueError( + f"Cluster shard {shard_index} state results are incomplete." + ) + for name, record in shard_states_by_name.items(): + previous = states_by_name.get(name) + if ( + previous is not None + and _state_record_value(previous) != _state_record_value(record) + ): + raise TSValueError( + f"Duplicated reference state {name!r} differs across shards." + ) + states_by_name.setdefault(name, record) + + missing = sorted(set(range(expected_count)) - seen_shards) + if missing: + formatted = ", ".join(str(index) for index in missing) + raise TSValueError(f"Missing cluster shard(s): {formatted}.") + + candidates = sorted( + candidates_by_name.values(), key=lambda candidate: candidate["input_index"] + ) + input_indices = [candidate["input_index"] for candidate in candidates] + if input_indices != list(range(len(input_indices))): + raise TSValueError("Redox shards do not cover every input position.") + + combined_metadata = { + key: value + for key, value in template.items() + if key not in {"candidates", "run_fingerprint", "shard"} + } + combined_metadata["candidates"] = candidates + combined_metadata["collection"] = { + "shard_count": expected_count, + "shard_directory": str(shard_directory), + } + run_fingerprint = _payload_fingerprint(combined_metadata) + combined_metadata["run_fingerprint"] = run_fingerprint + + results = [] + state_order = [] + for candidate in candidates: + record = dict(records_by_name[candidate["name"]]) + record["run_fingerprint"] = run_fingerprint + results.append(record) + state_order.extend( + f"{candidate['name']}--{state}" + for state, _offset, _spin_index in _REDOX_STATES + ) + reference_calculation = ( + (combined_metadata.get("reference") or {}).get("calculation") or {} + ) + reference_name = reference_calculation.get("name") + if reference_name and reference_name not in candidates_by_name: + state_order.extend( + f"{reference_name}--{state}" + for state, _offset, _spin_index in _REDOX_STATES + ) + combined_states = [states_by_name[name] for name in state_order] + + csv_path, json_path = _write_redox_results(results, out) + _write_results(combined_states, f"{Path(str(out)).with_suffix('')}-states") + _write_json(_sidecar_path(out, "-run.json"), combined_metadata) + logger.info(f"Collected {len(results)} molecules into {csv_path} and {json_path}") + return results + + +def collect_shards(shard_directory, out="results"): + """Detect the shard workflow and dispatch to its strict collector.""" + metadata_paths = _shard_metadata_paths(shard_directory) + if not metadata_paths: + raise TSValueError(f"No screening shards found in '{shard_directory}'.") + workflows = { + _read_json(path, dict).get("workflow") for path in metadata_paths + } + if len(workflows) != 1: + raise TSValueError("Cluster shard directory mixes different workflows.") + workflow = workflows.pop() + if workflow == "thermochemistry_screen": + return collect_screen_shards(shard_directory, out) + if workflow == "stepwise_reduction_screen": + return collect_redox_shards(shard_directory, out) + raise TSValueError(f"Unsupported cluster workflow {workflow!r}.") diff --git a/conda-recipes/README.md b/conda-recipes/README.md deleted file mode 100644 index 479dcfd..0000000 --- a/conda-recipes/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# conda-forge recipe - -Draft [conda-forge](https://conda-forge.org/) recipe for distributing -ThermoScreening through the `conda-forge` channel. It is kept here for reference -and maintenance; the recipe conda-forge actually builds lives in the -`thermoscreening` *feedstock* created from -[`conda-forge/staged-recipes`](https://github.com/conda-forge/staged-recipes). - -| Recipe | noarch? | Notes | -|--------|---------|-------| -| `thermoscreening/` | yes | Pure Python. | - -## Dependency: PQAnalysis - -`ThermoScreening` depends on `PQAnalysis`, which must be on conda-forge first -(conda-forge packages may only depend on other conda-forge packages). Its recipe -lives in the [PQAnalysis repository](https://github.com/MolarVerse/PQAnalysis) -(`conda-recipes/pqanalysis/`). Every other dependency (`numpy`, `scipy`, -`pymatgen-core`, `beartype`, `ase`, `rdkit`) is already on conda-forge. - -Both recipes were submitted together to `staged-recipes`, which builds sibling -recipes in dependency order (`pqanalysis` first, then `thermoscreening`). - -## Before submitting - -- **Maintainer(s):** `extra.recipe-maintainers` lists `galjos`. Add any other - GitHub usernames who should co-maintain the feedstock. -- **Versions & hashes** are pinned to the current PyPI release - (ThermoScreening 0.1.0). To refresh for a new release, bump `version` and - replace `sha256` with the sdist hash: - - ```bash - # prints the sha256 of the PyPI source tarball - curl -sL https://pypi.org/pypi/ThermoScreening/json \ - | python -c "import json,sys; d=json.load(sys.stdin); \ - print(next(u['digests']['sha256'] for u in d['urls'] if u['packagetype']=='sdist'))" - ``` - - After the feedstock exists, conda-forge's `regro-cf-autotick-bot` opens - version-bump PRs automatically, so this is mainly needed for the initial - submission. - -## Local check (optional) - -If you have `conda-build` installed you can lint/build the recipe before -submitting: - -```bash -conda smithy recipe-lint conda-recipes/thermoscreening -conda build conda-recipes/thermoscreening -c conda-forge -``` - -See the conda-forge [contributing guide](https://conda-forge.org/docs/maintainer/adding_pkgs/) -for the full staged-recipes workflow. diff --git a/conda-recipes/thermoscreening/meta.yaml b/conda-recipes/thermoscreening/meta.yaml deleted file mode 100644 index dbc8b67..0000000 --- a/conda-recipes/thermoscreening/meta.yaml +++ /dev/null @@ -1,66 +0,0 @@ -{% set name = "ThermoScreening" %} -{% set version = "0.1.0" %} -{% set python_min = "3.12" %} - -package: - name: {{ name|lower }} - version: {{ version }} - -source: - url: https://pypi.org/packages/source/{{ name[0]|lower }}/{{ name|lower }}/{{ name|lower }}-{{ version }}.tar.gz - sha256: bf4e0d8d962d849788b7ae3687ec0b4da8abc7300d09278173b674285cff77a1 - -build: - number: 0 - noarch: python - # The PyPI sdist has no .git, so pin the version for setuptools_scm. - script: | - export SETUPTOOLS_SCM_PRETEND_VERSION=${PKG_VERSION} - {{ PYTHON }} -m pip install . -vv --no-deps --no-build-isolation - entry_points: - - thermo = ThermoScreening.cli.thermo:main - -requirements: - host: - - python {{ python_min }} - - pip - - setuptools >=42 - - setuptools_scm >=8 - - wheel - run: - - python >={{ python_min }} - - numpy >=1.26 - - scipy - - pymatgen-core >=2026.5.18 - - beartype - - ase - - rdkit - - pqanalysis >=1.3.0 - -test: - imports: - - ThermoScreening - - ThermoScreening.thermo - commands: - - pip check - - thermo --help - requires: - - pip - - python {{ python_min }} - -about: - home: https://github.com/MolarVerse/ThermoScreening - license: LGPL-2.1-or-later - license_file: LICENSE - summary: Thermochemical property calculation and screening for molecular systems. - description: | - ThermoScreening calculates thermochemical properties for molecular systems - with DFTB+ and xTB backends. It provides conformer generation, batch - screening, and post-processing for reaction/redox free energies and - conformer-ensemble (Boltzmann) thermochemistry. - doc_url: https://molarverse.github.io/ThermoScreening/ - dev_url: https://github.com/MolarVerse/ThermoScreening - -extra: - recipe-maintainers: - - galjos diff --git a/docs/api.rst b/docs/api.rst index d82d80b..e69c4b3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -18,8 +18,10 @@ Screening --------- .. autofunction:: ThermoScreening.thermo.screening.screen +.. autofunction:: ThermoScreening.thermo.screening.collect_shards .. autofunction:: ThermoScreening.thermo.screening.collect_screen_shards .. autofunction:: ThermoScreening.thermo.screening.redox_screen +.. autofunction:: ThermoScreening.thermo.screening.collect_redox_shards .. autofunction:: ThermoScreening.thermo.screening.rank_by_gibbs .. autoclass:: ThermoScreening.thermo.screening.ScreeningJob :members: diff --git a/docs/configuration.rst b/docs/configuration.rst index c06ba03..71d4b20 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -66,6 +66,9 @@ Results are written to ``.csv`` and ``.json`` with the electronic energy (``Eelec_hartree``), the thermal corrections, the absolute Gibbs free energy (``G_total_hartree``), entropy and heat capacity. ``-run.json`` records the structure hashes and scientific settings used for safe resume. +Automatic Gibbs ordering is shown only when every structure has the same +molecular formula and charge. Absolute energies do not rank mixed molecular +compositions. Cluster execution ----------------- @@ -100,6 +103,13 @@ the array and a dependent collection job: --time 04:00:00 --mem 16G --partition compute --submit -- \ screen molecules.csv -o results --engine dftb+ --jobs 2 +The same interface distributes the complete redox workflow by candidate: + +.. code-block:: bash + + thermo slurm --tasks 32 --cpus-per-task 8 --submit -- \ + redox molecules.csv -o potentials --engine xtb-cli --jobs 2 + ``--cpus-per-task`` describes each allocation; ``--jobs`` controls independent processes inside that allocation. The generated script divides available CPUs between those processes through ``OMP_NUM_THREADS`` and common BLAS thread @@ -109,8 +119,8 @@ environment variables. Omitting ``--submit`` only writes the script. All array tasks and the collector require the same shared working directory, Python environment, executables, and parameter files. The shard interface also works with PBS, LSF, and other schedulers by supplying their array index and the -chosen shard count directly. Cluster arrays currently distribute -``thermo screen``; ``thermo redox`` retains local ``--jobs`` parallelism. +chosen shard count directly. Both ``thermo screen`` and ``thermo redox`` expose +``--shard-index`` and ``--shard-count`` for this scheduler-neutral path. Redox workflow input -------------------- @@ -146,7 +156,6 @@ The workflow writes: thermochemistry; and - ``-run.json`` and ``-states-run.json`` with input hashes, calculation settings, reference calibration, and reproducibility fingerprints; - and - ``/states.csv`` as the generated, reproducible charge-state manifest. diff --git a/docs/installation.rst b/docs/installation.rst index 7926d21..f3f9aa0 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -10,21 +10,27 @@ Package only .. code-block:: bash - python -m pip install . - # for development and tests - python -m pip install -e ".[test,lint]" + python -m pip install thermoscreening Full environment (recommended) ------------------------------ -The bundled Conda environment installs the package **and** every backend: +Create an environment containing every backend, then install the released +Python package: .. code-block:: bash - conda env create -f environment.yml + conda create -n thermoscreening -c conda-forge \ + python=3.12 dftbplus xtb tblite-python pip conda activate thermoscreening + python -m pip install thermoscreening -After this, ``thermo doctor`` should report every backend as found. +For development from a checkout, ``conda env create -f environment.yml`` +installs the same backends and an editable package with the test tools. + +After installation, ``thermo doctor`` reports every backend and lists the usable +engines. Use ``thermo doctor --engine dftb+`` (or ``xtb``/``xtb-cli``) when a +specific backend is required. Backends -------- @@ -49,15 +55,15 @@ Backends DFTB+ and Slater-Koster setup ----------------------------- -The DFTB+ engine needs Slater-Koster (``.skf``) parameter files, located via the -``DFTB_PREFIX`` environment variable. ThermoScreening can download the supported -sets and the GBSA solvation parameters for you: +The DFTB+ engine needs Slater-Koster (``.skf``) parameter files. +ThermoScreening downloads supported sets to an automatically discovered +user-local directory: .. code-block:: bash thermo setup-dftb --parameter-set 3ob # or: --parameter-set mio thermo setup-dftb --solvent water # a GBSA solvent parameter file - export DFTB_PREFIX="$HOME/.local/share/thermoscreening/slakos/3ob-3-1/" -``thermo doctor`` checks the executables, ``DFTB_PREFIX``, and the optional xTB -backends, and prints the exact install command for anything missing. +Use ``DFTB_PREFIX`` only to override that directory with a custom installation. +``thermo doctor`` verifies that executables can start, checks the selected +parameter set, and reports the optional xTB backends. diff --git a/docs/usage.rst b/docs/usage.rst index 744dc74..616346f 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -7,6 +7,10 @@ Command line The ``thermo`` command provides calculation, setup, and batch-management subcommands. +``thermo run`` + Run the original input-file workflow. ``thermo run thermo.in`` is preferred; + the historic ``thermo thermo.in`` form remains supported. + ``thermo doctor`` Report which backends (dftb+, modes, DFTB_PREFIX + a Slater-Koster file, and the optional xtb/tblite) are available. @@ -63,6 +67,10 @@ subcommands. thermo redox molecules.csv -o aq-screen \ --parameter-set 3ob --solvent acetonitrile --quasi-rrho --jobs 12 + # submit the complete redox workflow as 32 deterministic candidate shards + thermo slurm --tasks 32 --cpus-per-task 4 --submit -- \ + redox molecules.csv -o aq-screen --jobs 2 + Without an experimental reference, potentials are reported versus SHE using the 4.44 V absolute convention. Semiempirical absolute redox potentials are generally not quantitative. Calibrate both steps against one consistently @@ -91,6 +99,10 @@ subcommands. validation target: inspect conformers, minima, spin, and charge localization before interpreting it as a merged two-electron wave. +``thermo collect`` automatically identifies ordinary thermochemistry and redox +shards. Collection rejects missing shards, mixed settings, and fingerprint +mismatches before writing combined results. + Python API ---------- diff --git a/examples/gibbs_dftb.log b/examples/gibbs_dftb.log deleted file mode 100644 index 6c26cfb..0000000 --- a/examples/gibbs_dftb.log +++ /dev/null @@ -1,74 +0,0 @@ -T: 298.150000 K and P: 101325.000000 Pa - -Spin multiplicity: 0 - -symcalc=true - -Number of atoms: 24 - -o (8): Masses: 15.999000 u, o (8): Masses: 15.999000 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, h (1): Masses: 1.007840 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, c (6): Masses: 12.010700 u, h (1): Masses: 1.007840 u, h (1): Masses: 1.007840 u, h (1): Masses: 1.007840 u, h (1): Masses: 1.007840 u, h (1): Masses: 1.007840 u, h (1): Masses: 1.007840 u, h (1): Masses: 1.007840 u, - -Sum of the Mass: 208.210520 u - -Molecule has the following symmetry elements: (i) 3*(C2) 3*(sigma) -It seems to be the D2h point group -Rotational symmetry number: 4 - -Geometry output: -x axis coordinates in Angstrom: -3.000000e-08 -1.000000e-08 -3.709431e+00 -2.503582e+00 -1.281524e+00 -1.281522e+00 -2.503586e+00 -3.709428e+00 4.000000e-08 -3.000000e-08 1.281523e+00 1.281523e+00 2.503583e+00 2.484947e+00 3.709430e+00 3.709429e+00 2.503585e+00 -4.651515e+00 -2.484947e+00 -2.484947e+00 -4.651515e+00 4.651515e+00 4.651515e+00 2.484947e+00 - -y axis coordinates in Angstrom: --6.000000e-07 3.200000e-07 -4.100000e-07 5.500000e-07 6.400000e-07 -4.800000e-07 -2.180000e-06 -1.960000e-06 2.350000e-06 1.030000e-06 5.640000e-06 6.770000e-06 1.200000e-05 1.319000e-05 1.569000e-05 1.410000e-05 9.240000e-06 1.500000e-07 1.570000e-06 -3.340000e-06 -2.790000e-06 1.973000e-05 1.672000e-05 8.210000e-06 - -z axis coordinates in Angstrom: --2.149063e+00 -7.563760e+00 -5.555415e+00 -6.253440e+00 -5.563296e+00 -4.149528e+00 -3.459384e+00 -4.157407e+00 -6.333219e+00 -3.379604e+00 -4.149527e+00 -5.563296e+00 -6.253440e+00 -7.340303e+00 -5.555415e+00 -4.157407e+00 -3.459384e+00 -6.097876e+00 -7.340303e+00 -2.372520e+00 -3.614947e+00 -6.097876e+00 -3.614947e+00 -2.372520e+00 - -Geometry relocated to the mass center: - -x axis coordinates in Angstrom: -2.187578e-08 -1.812422e-08 -3.709431e+00 -2.503582e+00 -1.281524e+00 -1.281522e+00 -2.503586e+00 -3.709428e+00 3.187578e-08 -3.812422e-08 1.281523e+00 1.281523e+00 2.503583e+00 2.484947e+00 3.709430e+00 3.709429e+00 2.503585e+00 -4.651515e+00 -2.484947e+00 -2.484947e+00 -4.651515e+00 4.651515e+00 4.651515e+00 2.484947e+00 - -y axis coordinates in Angstrom: --4.470185e-06 -3.550185e-06 -4.280185e-06 -3.320185e-06 -3.230185e-06 -4.350185e-06 -6.050185e-06 -5.830185e-06 -1.520185e-06 -2.840185e-06 1.769815e-06 2.899815e-06 8.129815e-06 9.319815e-06 1.181982e-05 1.022982e-05 5.369815e-06 -3.720185e-06 -2.300185e-06 -7.210185e-06 -6.660185e-06 1.585982e-05 1.284982e-05 4.339815e-06 - -z axis coordinates in Angstrom: -2.707349e+00 -2.707348e+00 -6.990035e-01 -1.397029e+00 -7.068844e-01 7.068839e-01 1.397028e+00 6.990044e-01 -1.476808e+00 1.476807e+00 7.068841e-01 -7.068843e-01 -1.397028e+00 -2.483892e+00 -6.990039e-01 6.990041e-01 1.397028e+00 -1.241464e+00 -2.483891e+00 2.483892e+00 1.241464e+00 -1.241464e+00 1.241464e+00 2.483892e+00 - -inertia tensor in Angstrom: -459.257093 -0.002550 0.000022 --0.002550 1612.467955 0.000233 -0.000022 0.000233 1153.210862 - -Eigenvalues in Angstrom: 1612.467954837569 459.257092782463 1153.210862058361 - -rotTempXYZ in K^3: 0.000016707603 - -rotation temperature in K: 0.015041766888 0.052812177475 0.021032031425 - -rotation constants in GHz: 0.313419567580 1.100427227111 0.438236428193 - -qr: 558097.517345334752, Sr: 29.276067416661 cal/ mol K, Er: 0.001416277301 H/Particle, Cr: 2.980806387906 cal/ mol K - -qv: 0.000000000000, Sv: 32.563016022857 cal/ mol K, Ev: 0.184638043389 H/Particle, Cv: 40.891753423744 cal/ mol K, EZP: 0.175859413726 - -qt: 118088604.967066958547, St: 41.904068475251 cal/ mol K, Et: 0.001416277301 H/Particle, Ct: 2.980806387906 cal/ mol K - -qe: 1.000000000000, Se: 0.000000000000 cal/ mol K, Ee: 0.000000000000 H/Particle, Ce: 0.000000000000 cal/ mol K - -Etot: 0.187470597992 H/Particle, Stot: 103.743151914769 cal/ mol K,Ctot: 46.853366199557 cal/ mol K - -Hcorr: 0.188414782860 in H/Particle, Gcorr: 0.139123063735 in H/Particle - -Electronic Energy E0: -33.605245 H/Particle - -Zero-Point Energy: 0.175859413726 in H/Particle - -Sum of electronic and zero-point Energy: -33.429385385874 in H/Particle - -Total Energy: -33.417774201608 in H/Particle - -Total Enthalpy: -33.416830016740 in H/Particle - -Total Free Energy: -33.466121735865 in H/Particle - diff --git a/examples/python.log b/examples/python.log deleted file mode 100644 index b570451..0000000 --- a/examples/python.log +++ /dev/null @@ -1,225 +0,0 @@ -##################################### -############ThermoScreening########## -##################################### -Input file: thermo.in -Output file: output.out -Plot file: plot.png -Verbose: True -Test: False ------------------------------------------------ -Reading input file -coord_file = /home/stk/dev/ThermoScreening/examples/geo_opt.xyz -temperature = 298.15 -pressure = 101325 -engine = dftb+ -vibrational_file = /home/stk/dev/ThermoScreening/examples/frequency.txt -energy = -33.6052447996 -Input file read ------------------------------------------------ -Reading coordinate file -coord_file: /home/stk/dev/ThermoScreening/examples/geo_opt.xyz -engine: dftb+ -Coordinate file read ------------------------------------------------ -Reading vibrational file -Vibrational file read ------------------------------------------------ -Reading temperature -Temperature read: T = 298.15 K ------------------------------------------------ -Reading pressure -Pressure read: p = 101325.0 Pa ------------------------------------------------ -Reading energy -Energy read: E = -33.6052447996 H ------------------------------------------------ -Initializing Atom objects -Atom objects initialized ------------------------------------------------ -Initializing System object -Cell(a=10000, b=10000, c=10000, alpha=90, beta=90, gamma=90) -0.0 -#################################################################### -##################### All values of system_info: ###################### -##################################################################### -Atoms: ['O', 'O', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'H', 'C', 'C', 'C', 'H', 'H', 'H', 'H', 'H', 'H', 'H'] -Coordinates: [[ 3.00000000e-08 -6.00000000e-07 -2.14906255e+00] - [-1.00000000e-08 3.20000000e-07 -7.56375986e+00] - [-3.70943130e+00 -4.10000000e-07 -5.55541507e+00] - [-2.50358224e+00 5.50000000e-07 -6.25344004e+00] - [-1.28152402e+00 6.40000000e-07 -5.56329596e+00] - [-1.28152226e+00 -4.80000000e-07 -4.14952765e+00] - [-2.50358591e+00 -2.18000000e-06 -3.45938372e+00] - [-3.70942822e+00 -1.96000000e-06 -4.15740715e+00] - [ 4.00000000e-08 2.35000000e-06 -6.33321909e+00] - [-3.00000000e-08 1.03000000e-06 -3.37960436e+00] - [ 1.28152286e+00 5.64000000e-06 -4.14952745e+00] - [ 1.28152346e+00 6.77000000e-06 -5.56329581e+00] - [ 2.50358346e+00 1.20000000e-05 -6.25343979e+00] - [ 2.48494730e+00 1.31900000e-05 -7.34030311e+00] - [ 3.70943026e+00 1.56900000e-05 -5.55541540e+00] - [ 3.70942928e+00 1.41000000e-05 -4.15740743e+00] - [ 2.50358473e+00 9.24000000e-06 -3.45938351e+00] - [-4.65151464e+00 1.50000000e-07 -6.09787561e+00] - [-2.48494736e+00 1.57000000e-06 -7.34030301e+00] - [-2.48494723e+00 -3.34000000e-06 -2.37251966e+00] - [-4.65151524e+00 -2.79000000e-06 -3.61494721e+00] - [ 4.65151483e+00 1.97300000e-05 -6.09787570e+00] - [ 4.65151507e+00 1.67200000e-05 -3.61494731e+00] - [ 2.48494732e+00 8.21000000e-06 -2.37251974e+00]] in Angstrom -Electronic energy: -33.6052447996 in Hartree -Cell: Cell(a=10000, b=10000, c=10000, alpha=90, beta=90, gamma=90) in Angstrom -Vibrational frequencies: [-3.57000e+01 -1.43400e+01 -3.23000e+00 -3.12000e+00 -1.40000e-01 - 2.51700e+01 4.06000e+01 1.08630e+02 1.18200e+02 1.41000e+02 - 2.09640e+02 2.19950e+02 2.76260e+02 3.58970e+02 3.77280e+02 - 3.79240e+02 3.88190e+02 4.11700e+02 4.27830e+02 4.64460e+02 - 4.85290e+02 6.18690e+02 6.35090e+02 6.44300e+02 6.61540e+02 - 6.98610e+02 6.98950e+02 7.04770e+02 7.40130e+02 7.81930e+02 - 7.82460e+02 8.05890e+02 8.61010e+02 8.66130e+02 9.09290e+02 - 9.11390e+02 9.14830e+02 9.15450e+02 9.46390e+02 9.90700e+02 - 1.07651e+03 1.07801e+03 1.11202e+03 1.11359e+03 1.14851e+03 - 1.14987e+03 1.18588e+03 1.22359e+03 1.22434e+03 1.26423e+03 - 1.30454e+03 1.31670e+03 1.43352e+03 1.44301e+03 1.52197e+03 - 1.52672e+03 1.55594e+03 1.56526e+03 1.67486e+03 1.67535e+03 - 1.71720e+03 1.73293e+03 1.73620e+03 1.75852e+03 3.00916e+03 - 3.00932e+03 3.01625e+03 3.01805e+03 3.02230e+03 3.02416e+03 - 3.02900e+03 3.03106e+03] in cm^-1 -Real vibrational frequencies: [ 40.6 108.63 118.2 141. 209.64 219.95 276.26 358.97 377.28 - 379.24 388.19 411.7 427.83 464.46 485.29 618.69 635.09 644.3 - 661.54 698.61 698.95 704.77 740.13 781.93 782.46 805.89 861.01 - 866.13 909.29 911.39 914.83 915.45 946.39 990.7 1076.51 1078.01 - 1112.02 1113.59 1148.51 1149.87 1185.88 1223.59 1224.34 1264.23 1304.54 - 1316.7 1433.52 1443.01 1521.97 1526.72 1555.94 1565.26 1674.86 1675.35 - 1717.2 1732.93 1736.2 1758.52 3009.16 3009.32 3016.25 3018.05 3022.3 - 3024.16 3029. 3031.06] in cm^-1 -Number of atoms: 24 -Dimension: 3 -Degree of freedom: 66 -Charge: 0.0 -Mass: 208.21212 in amu -Spin: 0.0 -Rotational symmetry number: 4 -Rotational Group: D2h -Spacegroup number: P1 -Spacegroup: P1 -Periodicity: False -PBC: False -Solvation: False -Solvent: -Center of mass: [ 8.12422444e-09 3.87017993e-06 -4.85641153e+00] in Angstrom -############################################### -System object initialized ------------------------------------------------ -Initializing Thermo object -Thermo object initialized ------------------------------------------------ -Calculating thermo -######################################## -######################################## -Rotational contribution: -Inertia tensor: [[ 5.36989330e+03 -2.55037257e-03 2.98895019e-05] - [-2.55037257e-03 6.52311528e+03 4.14643902e-03] - [ 2.98895019e-05 4.14643902e-03 1.15322199e+03]] -Eigenvalues of inertia tensor: [5369.89329594 6523.11528262 1153.22198668] in amu * Angstrom^2 or - [8.91691760e-44 1.08318878e-43 1.91497016e-44] in kg * m^2 -Eigenvectors of inertia tensor: [[ 1.00000000e+00 2.21151919e-06 -7.08887866e-09] - [ 2.21151918e-06 -1.00000000e+00 -7.72164140e-07] - [ 7.09058631e-09 -7.72164125e-07 1.00000000e+00]] in Angstrom -######################################## -Rotational temperature: [0.00451673 0.00371822 0.02103183] in K -Rotational temperature xyz: 3.5321262259171875e-07 in K -Rotational constant: [0.09411342 0.0774751 0.4382322 ] in GHz -Rotational partition function: 3838389.6348655513 -Rotational entropy: 33.10794267969091 in cal/(mol*K) or - 5.276086505167894e-05 in H/T per particel -Rotational energy: 888.7274245542662 in cal/mol or - 0.0014162773014403315 in H per particle -Rotational heat capacity: 2.9808063879063096 in cal/(mol*K) or - 4.7502173450958625e-06 in H per particle - - - - -######################################## -######################################## -Vibrational temperature: 55532.01073241853 in K -Vibrational partition function: 1.545165916633664e-78 -Vibrational entropy: 32.563016022856814 in cal/(mol*K) or - 5.189246914793942e-05 in H per particle -Vibrational energy: 115862.12149908998 in cal or - 0.1846380433890429 in H per particle or - 27.69171163936185 in kcal -Vibrational heat capacity: 40.89175342374449 in cal/(mol*K) or - 6.516515704372529e-05 in H/T per particle -######################################### -Zero point energy correction: 110353.4482163166 in cal or - 0.17585941372612274 in H per particle or - 26.3751071262707 in kcal -Zero point vibrational energy: 110353.4482163166 in cal/mol or - 0.17585941372612274 in H per particle in cal/mol or - 26.3751071262707 in kcal/mol - - - - -######################################## -######################################## -Electronic partition function: 1.0 -Electronic entropy: 0.0 in cal/(mol*K) or - 0.0 in H/T per particle -Electronic energy: 0.0 in cal/mol or - 0.0 in H per particle -Electronic heat capacity: 0.0 in cal/(mol*K) or - 0.0 in H per particle - - - - -######################################## -######################################## -Translational partition function: 118089966.15283428 -Translational entropy: 41.904091381259384 in cal/(mol*K) or - 6.67784202681992e-05 in H per particle -Translational energy: 888.7274245542662 in cal or - 0.0014162773014403315 in H per particle or - 0.21241095233132556 in kcal -Translational heat capacity: 2.9808063879063096 in cal/(mol*K) or - 4.7502173450958625e-06 in H per particle - - - - -######################################## -######################################## -#################Summary:############### -Total energy: 117639.5763481985 in cal or - 117.6395763481985 in kcal or - 0.18747059799192353 in Hartree per mol -Total entropy: 107.5750500838071 in cal/(mol*K) or - 0.0001714317544678175 in Hartree per mol -Total enthalpy: 118232.06129790135 in cal or - 118.23206129790135 in kcal or - 0.1884147828595504 in Hartree per mol -Total Gibbs free energy: 85566.07516571142 in cal or - 85.56607516571141 in kcal or - 0.13635822039734372 in Hartree per mol -Total heat capacity: 46.853366199557115 in cal/(mol*K) -######################################## -######################################## -######################################## -Sum of electronic energy and zero point energy correction: - -33.42938538587388 Hartree per particle -Sum of electronic energy and total energy: - -33.417774201608076 Hartree per particle -Sum of electronic energy and total enthalpy: - -33.41683001674045 Hartree per particle -Sum of electronic energy and total Gibbs free energy: - -33.46888657920266 Hartree per particle -######################################## -######################################## -######################################## -Thermo calculated ------------------------------------------------ -##################################### - -Time elapsed: 0.07253336906433105 s diff --git a/examples/thermo_old.log b/examples/thermo_old.log deleted file mode 100644 index 1a321e3..0000000 --- a/examples/thermo_old.log +++ /dev/null @@ -1,133 +0,0 @@ -Enter the symmetry number for rotation sigma_r: 4 -Enter the electronic spin multiplicity of the molecule s: 0 -If it is a linear Molecule enter 5, if it is not enter 6: 6 -Total Energy: -33.6052447996 in H - - -Atomsorten: ['O' 'O' 'C' 'C' 'C' 'C' 'C' 'C' 'C' 'C' 'C' 'C' 'C' 'H' 'C' 'C' 'C' 'H' - 'H' 'H' 'H' 'H' 'H' 'H'] - - -x-Koordinaten in Angstrom: [ 3.00000000e-08 -1.00000000e-08 -3.70943130e+00 -2.50358224e+00 - -1.28152402e+00 -1.28152226e+00 -2.50358591e+00 -3.70942822e+00 - 4.00000000e-08 -3.00000000e-08 1.28152286e+00 1.28152346e+00 - 2.50358346e+00 2.48494730e+00 3.70943026e+00 3.70942928e+00 - 2.50358473e+00 -4.65151464e+00 -2.48494736e+00 -2.48494723e+00 - -4.65151524e+00 4.65151483e+00 4.65151507e+00 2.48494732e+00] - - -y-Koordinaten in Angstrom: [-6.000e-07 3.200e-07 -4.100e-07 5.500e-07 6.400e-07 -4.800e-07 - -2.180e-06 -1.960e-06 2.350e-06 1.030e-06 5.640e-06 6.770e-06 - 1.200e-05 1.319e-05 1.569e-05 1.410e-05 9.240e-06 1.500e-07 - 1.570e-06 -3.340e-06 -2.790e-06 1.973e-05 1.672e-05 8.210e-06] - - -z-Koordinaten in Angstrom: [-2.14906255 -7.56375986 -5.55541507 -6.25344004 -5.56329596 -4.14952765 - -3.45938372 -4.15740715 -6.33321909 -3.37960436 -4.14952745 -5.56329581 - -6.25343979 -7.34030311 -5.5554154 -4.15740743 -3.45938351 -6.09787561 - -7.34030301 -2.37251966 -3.61494721 -6.0978757 -3.61494731 -2.37251974] - - -Anzahl der Atome: 24.0 -Anzahl der Zeilen: 24 - - -Masse der Atome: [['H' 'O' 'C' 'N'] - ['1.00783' '15.99491' '12.0' '14.01']] in u - - -Zudordnung der Massen (in u) zu den Atomen:[15.99491 15.99491 12. 12. 12. 12. 12. 12. - 12. 12. 12. 12. 12. 1.00783 12. 12. - 12. 1.00783 1.00783 1.00783 1.00783 1.00783 1.00783 1.00783] - - -Summe Masse (in u):208.05246 - - -Massenträgheitstensor in u °A²: -[[ 5.36588604e+03 -2.54828915e-03 2.98640639e-05] - [-2.54828915e-03 6.51816831e+03 4.14298579e-03] - [ 2.98640639e-05 4.14298579e-03 1.15228227e+03]] - - -Eigenwerte und Eigenvektoren in u °A²: EigResult(eigenvalues=array([5365.88603989, 6518.16830903, 1152.28226914]), eigenvectors=array([[ 1.00000000e+00, 2.21151466e-06, -7.08800188e-09], - [ 2.21151465e-06, -1.00000000e+00, -7.72097240e-07], - [ 7.08970938e-09, -7.72097225e-07, 1.00000000e+00]])) - - -Eigenwert berechnet in u °A²:[5365.88603989 6518.16830903 1152.28226914] -Eigenvektor in u °A²: [[ 1.00000000e+00 2.21151466e-06 -7.08800188e-09] - [ 2.21151465e-06 -1.00000000e+00 -7.72097240e-07] - [ 7.08970938e-09 -7.72097225e-07 1.00000000e+00]] - - -Rot_temp_xyz: 3.540331624415244e-07 -Rotationsanteil: Rot_temp in K:[0.0045201 0.00372104 0.02104898], Rot_const in GHz:[0.0941837 0.0775339 0.43858959] - - -q_r: 3833938.9519502223, E_r: 0.0014162773014403315 in H/Particle, S_r: 33.10563714312922 in cal/(mol K), C_r: 2.9808063879063096 in cal/(mol K) - - -Frequenzen in cm⁻1:[ 40.6 108.63 118.2 141. 209.64 219.95 276.26 358.97 377.28 - 379.24 388.19 411.7 427.83 464.46 485.29 618.69 635.09 644.3 - 661.54 698.61 698.95 704.77 740.13 781.93 782.46 805.89 861.01 - 866.13 909.29 911.39 914.83 915.45 946.39 990.7 1076.51 1078.01 - 1112.02 1113.59 1148.51 1149.87 1185.88 1223.59 1224.34 1264.23 1304.54 - 1316.7 1433.52 1443.01 1521.97 1526.72 1555.94 1565.26 1674.86 1675.35 - 1717.2 1732.93 1736.2 1758.52 3009.16 3009.32 3016.25 3018.05 3022.3 - 3024.16 3029. 3031.06] - - -Summe vib_temp_K:55532.01073241853 -vib_temp: [ 58.41434123 156.2943322 170.06342692 202.86753973 301.6251846 - 316.45897421 397.47650018 516.47773572 542.82174034 545.64174302 - 558.51879608 592.34444047 615.5519115 668.25430853 698.22403088 - 890.15686634 913.75280713 927.00394218 951.80845554 1005.14391439 - 1005.63309853 1014.00677996 1064.88193035 1125.02280383 1125.78535557 - 1159.49589781 1238.8012793 1246.16781691 1308.26542695 1311.28685839 - 1316.23625085 1317.12829251 1361.6440491 1425.39625254 1548.8576964 - 1551.01586172 1599.94866332 1602.20754302 1652.44963158 1654.40636814 - 1706.21672349 1760.47299955 1761.5520822 1818.94489185 1876.94198778 - 1894.43751461 2062.51542944 2076.16942201 2189.77524425 2196.60943442 - 2238.65049478 2252.05989528 2409.74984106 2410.45484173 2470.66765405 - 2493.29961433 2498.00441472 2530.11791463 4329.50982871 4329.74003301 - 4339.71075677 4342.30055515 4348.41535688 4351.09148187 4358.05516196 - 4361.01904233] in K,len:66 - - -q_v: 1.5451659166336724e-78, E_v: 0.1846380433890429 in H/Particle, S_v: 32.563016022856814 in cal/(mol K), C_v: 40.8917534237445 in cal/(mol K) - - -Zero Point correction: 0.17585941372612274 in H/Particle - - -Zero Point vibrational energy: 110353.4482163166 in cal/mol - - -q_t: 117954162.6094914, E_t: 0.0014162773014403315 in H/Particle, S_t: 41.9018047799289 in cal/(mol K), C_t: 2.9808063879063096 in cal/(mol K) - - -q_e: 1, E_e: 0.0 in H/Particle, S_e: 0.0 in cal/(mol K), C_e: 0 in cal/(mol K) -E_corr: 0.18747059799192356 in H/particle, E_corr_cal:117639.5763481985 in cal/mol - - -S_corr: 107.57045794591494 in cal/(mol K) - - -H_corr: 0.18841478285955043 in H/Particle - - -G_corr: 0.1373045871378652 in H/Particle - - -C_corr: 46.853366199557115 in cal/(mol K) - - -Sum of electronic and zero-point Energies: -33.42938538587388 in H/Particle -Sum of electronic and thermal Energies: -33.417774201608076 in H/Particle - - -Sum of electronic and thermal Enthalpies: -33.41683001674045 in H/Particle - - -Sum of electronic and thermal Free Energies: -33.46794021246214 in H/Particle \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 1155c2a..0000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[metadata] -long_description = file: README.md -long_description_content_type = text/markdown \ No newline at end of file diff --git a/tests/calculator/test_dftbplus.py b/tests/calculator/test_dftbplus.py index 1a43b39..6a66982 100644 --- a/tests/calculator/test_dftbplus.py +++ b/tests/calculator/test_dftbplus.py @@ -59,8 +59,29 @@ def test_slako_dir_uses_environment(monkeypatch, tmp_path): assert _slako_dir() == str(tmp_path.resolve()) + os.sep -def test_slako_dir_requires_external_parameters(monkeypatch): +def test_slako_dir_uses_downloaded_default(monkeypatch, tmp_path): + downloaded = tmp_path / "3ob-3-1" + downloaded.mkdir() + (downloaded / dftbplus_module.REQUIRED_PARAMETER_FILE).write_text( + "parameters", encoding="utf-8" + ) monkeypatch.delenv("DFTB_PREFIX", raising=False) + monkeypatch.setattr( + dftbplus_module, + "default_parameter_dir", + lambda parameter_set: downloaded, + ) + + assert _slako_dir() == str(downloaded.resolve()) + os.sep + + +def test_slako_dir_requires_external_parameters(monkeypatch, tmp_path): + monkeypatch.delenv("DFTB_PREFIX", raising=False) + monkeypatch.setattr( + dftbplus_module, + "default_parameter_dir", + lambda parameter_set: tmp_path / "missing", + ) with pytest.raises(FileNotFoundError, match="Slater-Koster files are not bundled"): _slako_dir() diff --git a/tests/cli/test_dftb_setup.py b/tests/cli/test_dftb_setup.py index 562b69c..a64812d 100644 --- a/tests/cli/test_dftb_setup.py +++ b/tests/cli/test_dftb_setup.py @@ -1,5 +1,6 @@ import io import tarfile +from types import SimpleNamespace import pytest @@ -211,10 +212,13 @@ def test_check_dftb_setup_reports_ready_environment(monkeypatch, tmp_path): marker_file = tmp_path / REQUIRED_PARAMETER_FILE marker_file.write_text("parameter data", encoding="utf-8") - def fake_which(command): - return f"/usr/bin/{command}" - - monkeypatch.setattr(dftb_setup.shutil, "which", fake_which) + monkeypatch.setattr( + dftb_setup, + "_executable_diagnostic", + lambda name, *args, **kwargs: Diagnostic( + name, True, f"/usr/bin/{name}", optional=kwargs.get("optional", False) + ), + ) diagnostics = check_dftb_setup({"DFTB_PREFIX": str(tmp_path)}) @@ -222,13 +226,24 @@ def fake_which(command): assert required == [ ("dftb+", True), ("modes", True), - ("DFTB_PREFIX", True), + ("parameters", True), (REQUIRED_PARAMETER_FILE, True), ] -def test_check_dftb_setup_reports_missing_environment(monkeypatch): - monkeypatch.setattr(dftb_setup.shutil, "which", lambda command: None) +def test_check_dftb_setup_reports_missing_environment(monkeypatch, tmp_path): + monkeypatch.setattr( + dftb_setup, + "_executable_diagnostic", + lambda name, *args, **kwargs: Diagnostic( + name, False, "not found", optional=kwargs.get("optional", False) + ), + ) + monkeypatch.setattr( + dftb_setup, + "default_parameter_dir", + lambda **kwargs: tmp_path / "missing", + ) diagnostics = check_dftb_setup({}) @@ -236,14 +251,25 @@ def test_check_dftb_setup_reports_missing_environment(monkeypatch): assert required == [ ("dftb+", False), ("modes", False), - ("DFTB_PREFIX", False), + ("parameters", False), (REQUIRED_PARAMETER_FILE, False), ] def test_check_dftb_setup_reports_xtb_toolchain_as_optional(monkeypatch): # xtb resolved via XTB_COMMAND; tblite importable - monkeypatch.setattr(dftb_setup.shutil, "which", lambda command: "/bin/" + command) + monkeypatch.setattr( + dftb_setup.shutil, + "which", + lambda command: command if command.startswith("/") else "/bin/" + command, + ) + monkeypatch.setattr( + dftb_setup.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + stdout="xtb version 6", stderr="", returncode=0 + ), + ) monkeypatch.setattr( dftb_setup.importlib.util, "find_spec", lambda name: object() if name == "tblite" else None, @@ -266,6 +292,21 @@ def test_check_dftb_setup_reports_xtb_toolchain_as_optional(monkeypatch): assert missing["tblite"].ok is False +def test_executable_diagnostic_rejects_loader_failure(monkeypatch): + monkeypatch.setattr(dftb_setup.shutil, "which", lambda command: "/bin/dftb+") + monkeypatch.setattr( + dftb_setup.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + stdout="DFTB+", stderr="Library not loaded", returncode=1 + ), + ) + + diagnostic = dftb_setup._executable_diagnostic("dftb+", expected="DFTB+") + + assert diagnostic.ok is False + + def test_format_diagnostics_aligns_statuses(): output = format_diagnostics( [ diff --git a/tests/cli/test_import.py b/tests/cli/test_import.py new file mode 100644 index 0000000..eeb79f5 --- /dev/null +++ b/tests/cli/test_import.py @@ -0,0 +1,16 @@ +"""Command-line package import behavior.""" + +import subprocess +import sys + + +def test_importing_cli_is_silent(): + completed = subprocess.run( + [sys.executable, "-c", "import ThermoScreening.cli"], + check=True, + capture_output=True, + text=True, + ) + + assert completed.stdout == "" + assert completed.stderr == "" diff --git a/tests/cli/test_slurm.py b/tests/cli/test_slurm.py index 0dd571b..4f2b5a8 100644 --- a/tests/cli/test_slurm.py +++ b/tests/cli/test_slurm.py @@ -40,6 +40,20 @@ def test_write_slurm_array_script_sets_resources_and_shards(tmp_path): assert script.stat().st_mode & 0o111 +def test_write_slurm_array_script_supports_redox(tmp_path): + script = slurm.write_slurm_array_script( + ["redox", "molecules.csv", "-o", "potentials"], + tasks=4, + script=tmp_path / "redox.slurm", + working_directory=tmp_path, + python_executable="/shared/python", + ) + + text = script.read_text(encoding="utf-8") + assert "/shared/python -m ThermoScreening redox molecules.csv" in text + assert '--shard-index "$SLURM_ARRAY_TASK_ID"' in text + + @pytest.mark.parametrize( ("kwargs", "message"), [ @@ -137,7 +151,7 @@ def test_cli_collect_reports_failures(monkeypatch, capsys): monkeypatch.setattr( cli, - "collect_screen_shards", + "collect_shards", lambda *args, **kwargs: [ {"name": "ok", "status": "ok"}, {"name": "bad", "status": "error", "error": "failed"}, @@ -170,3 +184,14 @@ def test_slurm_parser_accepts_nested_screen_command(): assert args.command == "slurm" assert args.tasks == 8 assert args.command_args[-3:] == ["molecules.csv", "--jobs", "2"] + + +def test_slurm_parser_accepts_nested_redox_command(): + import ThermoScreening.cli.thermo as cli + + args = cli.parse_args( + ["slurm", "--tasks", "8", "--", "redox", "molecules.csv", "--jobs", "2"] + ) + + assert args.command == "slurm" + assert args.command_args[-3:] == ["molecules.csv", "--jobs", "2"] diff --git a/tests/thermo/.coverage b/tests/thermo/.coverage deleted file mode 100644 index 271901fb420fe0cf0534bbb77feabf4c60444b74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI4Uu+yl9mi+?-2c6uvoEO=tddm)b*wseoK!+dS`sHsL)w(kH01$AF6+Csz4-3d zx!p@*wIO#&rASaD=mP?&LcAe?!~=*Is34+ms1z#kfCRL_1F8^zT1tV^D&aS?yS}q8 z{&z1`n(x}TvpYM#-~4```OVDD-R(Z{_z5>~tQoISvjb}{D==->zJ_6xh>MCurDZrxEV+M@}7`I(^hSJ$3lRQ7g=|c4jTwcI~pJtcG{N zT5uZHj9Yaqw_b6}cHq`$tzgcHOE-O|A|^V@qp4059GCLqsN_~iDsW~=#X`fa*^NbO z-dP-z1)|yl=VB0MkP^q8t&0-Y&PaUJYB)1a!>N}YKeS?}TN%xcUcH=64Hb*ZHAnhn zH_G&%TjmXAbX++;LyF2?qav)IZrJtmoa2vK_L7HXkL=wOp;WW(o@+WwjX!2} zB*&y1b#KfPPEZ7VG?yIOS5z29rE}3gSEct`CA%4T^1MWiUz((y@ytLvHMC=ga_yW9 z6oIES?FRnRL1tOdEM>0}I^z23aU*)k@*okts9LqW8QEoov=ufEMRRwbnTTQ74bjmo zFDgiuzOT_Hj3pba>8dxqJl15y_zrat8VllLGtGKgbQNFtQ1;F{4SUuZkFJKp!uuUa z6grccPMpCdEE^^|Pup`Pz0#zea<;cxlT_7C9Z0A9WUY zowutrnk?9EgSsq@!Ql*GiGmfI3ft3OGm!BsPGxwCxu;`qsGxHmv!_y{vN(0`PkFxY z(zGh3Eq{NUYmLm=z8Ho45o_A>s*YU`=LV8E?o*wvZ%G?^XQhK39sZ)wF?)VQ+7wSN zVwgWooi6TQbWp3=^-45fiD@|0N@Kt1oT8p^5FtTpggT{9U37)cSZQ=sj99V$@daWc zwASoO*xf~=bZNXW=k%xxXQPMgzzN)%V~HZt7#Ua>Y~Lz39J)wz>F9{IRv3uZDou*L zSS6>$Te-=+ktNBawih0aT=6{yIoPW@G$M}v>GbVFy=f0miN5!y#00@8p z2!H?xfB*=9z@0|mfuhDPPt18WXTlHWCo0bQ3A)L-QS+XlJB6J(J-1^baQwiZp!;ws zSDuvU2I;`5(GAK62X)rg))rr>u3U8^Wu#zgfE1)Qub@uyjgk&68VjXkxthF%Y9 zwp$mnybsy3Q>~Kh0aIg_He0V9cr~AdAIp>9JJyz72`*}Zg}Fta9tcxCLeF~$>^eQE zVqx*HLFzJnsSCA|jzc-pk?2cDL}V(v`NoW#in$!l^zBF|DAS~4lR+7qvEUMkeJPDC zt_`9$qHHPpXp-t4UR(C`%KjDhEVsVU3?8Oi{7*S_!+(S9cql>2@_i}m>K_m5q-9`j zExqm>@3Cf|wmB+YChSo)Hop-I=wTT%!WMFVkFr6Tu6o4x|Ha(>Ecb(4!~B(*%D3_# zH?JDs&A({;kN<*ygP-AdnV&G~#-7~2sTej8009sH0T2KI5C8!XxZ?;ss9jbz^42_B zA&GbAo%Mh5klxnT5ns97S+@QkJgm15Z=clSlP-zCUGM{UdQqZx5`arPrO_ z^}oJJDqSXMd$suuSwKaWTHRF-p8t2pO9~%gO00@8p2!H?xfB*=900@8p2!Ox`kbtJ;GxGj_^D)NX z;eY3U=D*{=<}dOe^RM!I%zv6Mna`VFF~4A*H|Nc=`6Ly?1_B@e0w4eaAOHd&00JNY z0w4eaj3tY@k}->_F-Yl$2G9KIr$i;491yt!YNkNxmck29zIQUoMHW{JCZ+e7Ke_Rr zORxR@%~$;kYBo)lL(qaXs(?N?_X6G=69F>10_Xrg~M6b6F(vGp~%;@^x)*8$7fhuBGYM*>=V(_Qr2- z>QwQ~%k-k{EwR&TnS_#Qey!@aufC}!HIbvK8I{s4>dUO9BosZ9 zF;(&Xf018e{9XPp{x|vr;5Ggm{tEv&eG2db{~`Y#|2F>?Jq6%7{w4kl6~hJsAOHd& z00JNY0w4eaAOHd&00JPeSps6=t`v(h861?!z<^8&1(|TUwpUD3p5^m0F$|gHax%$g zWs=FrB%PK?DkYO-QYMLnOmtl)nkExf)#(EO`Tc+RvBqYNM|ltc0T2KI5C8!X009sH z0T2KI5CDPOg@E|}AM5|yrL|xu2!H?xfB*=900@8p2!H?xfB*>eM?l>FkM)0l>>vdK aAOHd&00JNY0w4eaAOHd&00OrQf&T+_P9x3$ diff --git a/tests/thermo/test_main.py b/tests/thermo/test_main.py index fcf0355..48f61ec 100644 --- a/tests/thermo/test_main.py +++ b/tests/thermo/test_main.py @@ -12,11 +12,6 @@ import ThermoScreening.cli.thermo as thermo from ThermoScreening.utils.header import print_header -sys.path.append("/home/stk/dev/ThermoScreening/tests/data/thermo/") - -path = "/home/stk/dev/ThermoScreening/tests/data/thermo/" -# - class TestMain(unittest.TestCase): def test_parse_args(self): @@ -33,6 +28,25 @@ def test_module_help_entrypoint(self): runpy.run_module("ThermoScreening.__main__", run_name="__main__") self.assertEqual(e.exception.code, 0) + def test_top_level_help_lists_workflows(self): + with self.assertRaises(SystemExit) as error: + thermo.parse_args(["--help"]) + + self.assertEqual(error.exception.code, 0) + + def test_explicit_run_command(self): + args = thermo.parse_args(["run", "input_file"]) + + self.assertEqual(args.command, "run") + self.assertEqual(args.input_file, "input_file") + self.assertFalse(args.verbose) + + def test_version_option(self): + with self.assertRaises(SystemExit) as error: + thermo.parse_args(["--version"]) + + self.assertEqual(error.exception.code, 0) + def test_print_header_to_file(self): output = StringIO() print_header(file=output) @@ -102,6 +116,8 @@ def test_parse_args_doctor_command(): args = thermo.parse_args(["doctor"]) assert args.command == "doctor" + assert args.engine == "all" + assert args.parameter_set == "3ob" def test_main_runs_setup_dftb(monkeypatch, tmp_path, capsys): @@ -127,7 +143,7 @@ def test_main_runs_setup_dftb(monkeypatch, tmp_path, capsys): output = capsys.readouterr().out assert "Slater-Koster files:" in output - assert "export DFTB_PREFIX=" in output + assert "ready for automatic discovery" in output def test_main_setup_dftb_downloads_solvent(monkeypatch, tmp_path, capsys): @@ -154,14 +170,21 @@ def test_main_setup_dftb_downloads_solvent(monkeypatch, tmp_path, capsys): def test_main_runs_doctor(monkeypatch, capsys): - diagnostic = type("DiagnosticStub", (), {"ok": False, "optional": False})() + diagnostics = [ + type("D", (), {"name": name, "ok": False, "optional": False})() + for name in ("dftb+", "modes", "parameters", "C-C.skf") + ] monkeypatch.setattr( thermo, "parse_args", - lambda: argparse.Namespace(command="doctor"), + lambda: argparse.Namespace( + command="doctor", engine="dftb+", parameter_set="3ob" + ), + ) + monkeypatch.setattr( + thermo, "check_dftb_setup", lambda parameter_set: diagnostics ) - monkeypatch.setattr(thermo, "check_dftb_setup", lambda: [diagnostic]) monkeypatch.setattr(thermo, "format_diagnostics", lambda diagnostics: "not ready") assert thermo.main() == 1 @@ -214,14 +237,22 @@ def fake_run_conformers(args): def test_main_doctor_ignores_missing_optional_backend(monkeypatch, capsys): - required_ok = type("D", (), {"ok": True, "optional": False})() - optional_missing = type("D", (), {"ok": False, "optional": True})() + diagnostics = [ + type("D", (), {"name": name, "ok": True, "optional": False})() + for name in ("dftb+", "modes", "parameters", "C-C.skf") + ] + diagnostics.extend( + type("D", (), {"name": name, "ok": False, "optional": True})() + for name in ("xtb", "tblite") + ) monkeypatch.setattr( - thermo, "parse_args", lambda: argparse.Namespace(command="doctor") + thermo, + "parse_args", + lambda: argparse.Namespace(command="doctor", engine="all", parameter_set="3ob"), ) monkeypatch.setattr( - thermo, "check_dftb_setup", lambda: [required_ok, optional_missing] + thermo, "check_dftb_setup", lambda parameter_set: diagnostics ) monkeypatch.setattr(thermo, "format_diagnostics", lambda diagnostics: "ok") diff --git a/tests/thermo/test_redox_workflow.py b/tests/thermo/test_redox_workflow.py index 0bcbefc..0e2b979 100644 --- a/tests/thermo/test_redox_workflow.py +++ b/tests/thermo/test_redox_workflow.py @@ -79,11 +79,46 @@ def fake_screen(source, **kwargs): "G_total_hartree": energies[molecule][state], } ) + if "out" in kwargs: + screening._write_results(results, kwargs["out"]) + screening._write_json( + screening._sidecar_path(kwargs["out"], "-run.json"), + {"workflow": "thermochemistry_screen"}, + ) return results return fake_screen +def _minimal_shard_metadata(index=0, count=1, candidates=None, reference=None): + payload = { + "schema_version": 1, + "workflow": "stepwise_reduction_screen", + "source": "molecules.csv", + "single_starting_geometry_approximation": True, + "smiles_embedding": {}, + "states": [], + "settings": {"engine": "xtb"}, + "candidates": candidates or [], + "reference": {"calculation": reference}, + "shard": {"index": index, "count": count}, + } + payload["run_fingerprint"] = screening._payload_fingerprint(payload) + return payload + + +def _write_shard(directory, metadata, records=None, states=None): + directory.mkdir(parents=True, exist_ok=True) + index = metadata.get("shard", {}).get("index", 0) + stem = directory / f"shard-{index:05d}" + screening._write_json(stem.with_name(stem.name + "-run.json"), metadata) + if records is not None: + screening._write_json(stem.with_suffix(".json"), records) + if states is not None: + screening._write_json(stem.with_name(stem.name + "-states.json"), states) + return stem + + def test_redox_screen_calculates_all_states_in_parallel(monkeypatch, tmp_path): _write_xyz(tmp_path / "a.xyz") _write_xyz(tmp_path / "b.xyz") @@ -657,6 +692,256 @@ def test_redox_screen_writes_machine_readable_aggregate(monkeypatch, tmp_path): assert metadata["run_fingerprint"] == results[0]["run_fingerprint"] +def test_redox_shards_collect_with_shared_reference(monkeypatch, tmp_path): + molecule_directory = tmp_path / "molecules" + molecule_directory.mkdir() + for name in ("a", "b"): + _write_xyz(molecule_directory / f"{name}.xyz") + energies = { + "a": { + "oxidized": -100.0, + "reduced_once": -100.10, + "reduced_twice": -100.18, + }, + "b": { + "oxidized": -200.0, + "reduced_once": -200.11, + "reduced_twice": -200.20, + }, + } + monkeypatch.setattr(screening, "screen", _fake_state_screen(energies)) + + for shard_index in range(3): + screening.redox_screen( + molecule_directory, + out=tmp_path / "redox", + directory=tmp_path / "work", + reference="a", + reference_e1=-0.75, + reference_e2=-1.40, + shard_index=shard_index, + shard_count=3, + ) + + results = screening.collect_shards( + tmp_path / "redox-shards", out=tmp_path / "combined" + ) + + assert [result["name"] for result in results] == ["a", "b"] + assert results[0]["E1_V"] == pytest.approx(-0.75) + assert results[1]["E1_V"] == pytest.approx(-0.75 + 0.01 * HARTREE_TO_EV) + assert len({result["run_fingerprint"] for result in results}) == 1 + states = json.loads( + (tmp_path / "combined-states.json").read_text(encoding="utf-8") + ) + assert len(states) == 6 + metadata = json.loads( + (tmp_path / "combined-run.json").read_text(encoding="utf-8") + ) + assert metadata["collection"]["shard_count"] == 3 + assert metadata["input_set_fingerprint"] + assert [candidate["input_index"] for candidate in metadata["candidates"]] == [0, 1] + fingerprint_payload = dict(metadata) + fingerprint = fingerprint_payload.pop("run_fingerprint") + assert screening._payload_fingerprint(fingerprint_payload) == fingerprint + + +def test_collect_redox_shards_requires_every_shard(monkeypatch, tmp_path): + _write_xyz(tmp_path / "molecule.xyz") + energies = { + "molecule": { + "oxidized": -100.0, + "reduced_once": -100.1, + "reduced_twice": -100.18, + } + } + monkeypatch.setattr(screening, "screen", _fake_state_screen(energies)) + screening.redox_screen( + tmp_path / "molecule.xyz", + out=tmp_path / "redox", + directory=tmp_path / "work", + shard_index=0, + shard_count=2, + ) + + with pytest.raises(TSValueError, match="Missing cluster shard"): + screening.collect_redox_shards(tmp_path / "redox-shards") + + +def test_collect_redox_shards_rejects_inputs_changed_between_tasks( + monkeypatch, tmp_path +): + molecule_directory = tmp_path / "molecules" + molecule_directory.mkdir() + for name in ("a", "b"): + _write_xyz(molecule_directory / f"{name}.xyz") + energies = { + name: { + "oxidized": -100.0, + "reduced_once": -100.1, + "reduced_twice": -100.18, + } + for name in ("a", "b") + } + monkeypatch.setattr(screening, "screen", _fake_state_screen(energies)) + screening.redox_screen( + molecule_directory, + out=tmp_path / "redox", + directory=tmp_path / "work", + shard_index=0, + shard_count=2, + ) + + (molecule_directory / "b.xyz").write_text( + "1\nchanged\nH 0.0 0.0 1.0\n", encoding="utf-8" + ) + screening.redox_screen( + molecule_directory, + out=tmp_path / "redox", + directory=tmp_path / "work", + shard_index=1, + shard_count=2, + ) + + with pytest.raises(TSValueError, match="different inputs or settings"): + screening.collect_redox_shards(tmp_path / "redox-shards") + + +def test_collect_redox_shards_without_reference(monkeypatch, tmp_path): + _write_xyz(tmp_path / "molecule.xyz") + energies = { + "molecule": { + "oxidized": -100.0, + "reduced_once": -100.1, + "reduced_twice": -100.18, + } + } + monkeypatch.setattr(screening, "screen", _fake_state_screen(energies)) + for shard_index in range(2): + screening.redox_screen( + tmp_path / "molecule.xyz", + out=tmp_path / "redox", + directory=tmp_path / "work", + shard_index=shard_index, + shard_count=2, + ) + + results = screening.collect_redox_shards( + tmp_path / "redox-shards", out=tmp_path / "combined" + ) + + assert [result["name"] for result in results] == ["molecule"] + + +def test_redox_collector_rejects_missing_and_malformed_metadata(tmp_path): + with pytest.raises(TSValueError, match="No redox shards"): + screening.collect_redox_shards(tmp_path / "missing") + + wrong_workflow = _minimal_shard_metadata() + wrong_workflow["workflow"] = "other" + _write_shard(tmp_path / "wrong-workflow", wrong_workflow) + with pytest.raises(TSValueError, match="not redox run metadata"): + screening.collect_redox_shards(tmp_path / "wrong-workflow") + + no_shard = _minimal_shard_metadata() + no_shard.pop("shard") + _write_shard(tmp_path / "no-shard", no_shard) + with pytest.raises(TSValueError, match="no valid shard metadata"): + screening.collect_redox_shards(tmp_path / "no-shard") + + missing_result = _minimal_shard_metadata() + _write_shard(tmp_path / "missing-result", missing_result) + with pytest.raises(TSValueError, match="Result file is missing"): + screening.collect_redox_shards(tmp_path / "missing-result") + + +def test_redox_collector_rejects_invalid_payloads(tmp_path): + invalid_candidates = _minimal_shard_metadata() + invalid_candidates["candidates"] = None + invalid_candidates["run_fingerprint"] = screening._payload_fingerprint( + {key: value for key, value in invalid_candidates.items() if key != "run_fingerprint"} + ) + _write_shard(tmp_path / "invalid-candidates", invalid_candidates, records=[]) + with pytest.raises(TSValueError, match="invalid candidate metadata"): + screening.collect_redox_shards(tmp_path / "invalid-candidates") + + mismatched = _minimal_shard_metadata(candidates=[{"name": "a", "input_index": 0}]) + _write_shard(tmp_path / "mismatched", mismatched, records=[], states=[]) + with pytest.raises(TSValueError, match="do not match its candidates"): + screening.collect_redox_shards(tmp_path / "mismatched") + + bad_fingerprint = _minimal_shard_metadata() + bad_fingerprint["run_fingerprint"] = "wrong" + _write_shard(tmp_path / "bad-fingerprint", bad_fingerprint, records=[]) + with pytest.raises(TSValueError, match="Invalid redox run fingerprint"): + screening.collect_redox_shards(tmp_path / "bad-fingerprint") + + missing_states = _minimal_shard_metadata() + _write_shard(tmp_path / "missing-states", missing_states, records=[]) + with pytest.raises(TSValueError, match="State result file is missing"): + screening.collect_redox_shards(tmp_path / "missing-states") + + +@pytest.mark.parametrize( + ("candidate", "record_fingerprint", "message"), + [ + ({"name": "a"}, "valid", "Missing input index"), + ({"name": "a", "input_index": 1}, "valid", "does not belong"), + ({"name": "a", "input_index": 0}, "wrong", "Run fingerprint mismatch"), + ], +) +def test_redox_collector_rejects_invalid_candidate_provenance( + tmp_path, candidate, record_fingerprint, message +): + shard_count = 2 if message == "does not belong" else 1 + metadata = _minimal_shard_metadata(count=shard_count, candidates=[candidate]) + fingerprint = metadata["run_fingerprint"] + record = { + "name": "a", + "status": "ok", + "run_fingerprint": fingerprint if record_fingerprint == "valid" else "wrong", + } + directory = tmp_path / message.replace(" ", "-") + _write_shard(directory, metadata, records=[record], states=[]) + + with pytest.raises(TSValueError, match=message): + screening.collect_redox_shards(directory) + + +def test_redox_collector_rejects_incomplete_state_results(tmp_path): + candidate = {"name": "a", "input_index": 0} + metadata = _minimal_shard_metadata(candidates=[candidate]) + record = { + "name": "a", + "status": "ok", + "run_fingerprint": metadata["run_fingerprint"], + } + _write_shard(tmp_path / "incomplete", metadata, records=[record], states=[]) + + with pytest.raises(TSValueError, match="state results are incomplete"): + screening.collect_redox_shards(tmp_path / "incomplete") + + +def test_collect_shards_rejects_unknown_and_mixed_workflows(tmp_path): + with pytest.raises(TSValueError, match="No screening shards"): + screening.collect_shards(tmp_path / "missing") + + unsupported = _minimal_shard_metadata() + unsupported["workflow"] = "unsupported" + _write_shard(tmp_path / "unsupported", unsupported) + with pytest.raises(TSValueError, match="Unsupported cluster workflow"): + screening.collect_shards(tmp_path / "unsupported") + + first = _minimal_shard_metadata(index=0, count=2) + second = _minimal_shard_metadata(index=1, count=2) + second["workflow"] = "thermochemistry_screen" + directory = tmp_path / "mixed" + _write_shard(directory, first) + _write_shard(directory, second) + with pytest.raises(TSValueError, match="mixes different workflows"): + screening.collect_shards(directory) + + def test_cli_parses_and_runs_redox(monkeypatch, capsys): import ThermoScreening.cli.thermo as cli @@ -698,10 +983,12 @@ def fake_redox(source, **kwargs): assert args.charge is None assert args.parameter_set is None assert args.method is None + assert args.shard_index is None assert cli.run_redox(args) == 0 assert captured["kwargs"]["jobs"] == 6 assert captured["kwargs"]["resume"] is True assert captured["kwargs"]["reference_e1"] == -0.75 + assert captured["kwargs"]["shard_index"] is None assert "E1=-0.6000 V" in capsys.readouterr().out diff --git a/tests/thermo/test_screening.py b/tests/thermo/test_screening.py index ee12843..f1a7d7e 100644 --- a/tests/thermo/test_screening.py +++ b/tests/thermo/test_screening.py @@ -457,6 +457,32 @@ def test_collect_screen_shards_rejects_mixed_settings(monkeypatch, tmp_path): screening.collect_screen_shards(screening.screen_shard_directory(out), out=out) +def test_collect_screen_shards_rejects_inputs_changed_between_tasks( + monkeypatch, tmp_path +): + inputs = tmp_path / "inputs" + inputs.mkdir() + _write_xyz(inputs / "a.xyz") + _write_xyz(inputs / "b.xyz") + monkeypatch.setattr( + screening, "dftbplus_thermo", lambda atoms, **kwargs: _FakeThermo() + ) + out = tmp_path / "results" + screening.screen( + inputs, out=out, shard_index=0, shard_count=2, directory=tmp_path / "runs" + ) + + (inputs / "b.xyz").write_text( + "1\nchanged\nH 0.0 0.0 1.0\n", encoding="utf-8" + ) + screening.screen( + inputs, out=out, shard_index=1, shard_count=2, directory=tmp_path / "runs" + ) + + with pytest.raises(TSValueError, match="different inputs or settings"): + screening.collect_screen_shards(screening.screen_shard_directory(out), out=out) + + def test_screen_dispatches_to_xtb_engine(monkeypatch, tmp_path): _write_xyz(tmp_path / "mol.xyz") @@ -759,23 +785,55 @@ def test_rank_by_gibbs_sorts_and_filters(): from ThermoScreening.thermo.screening import rank_by_gibbs results = [ - {"name": "a", "status": "ok", "G_total_hartree": -1.0}, - {"name": "b", "status": "ok", "G_total_hartree": -3.0}, - {"name": "c", "status": "error", "error": "boom"}, - {"name": "d", "status": "ok", "G_total_hartree": -2.0}, - {"name": "e", "status": "ok"}, # ok but no Gibbs value -> dropped + {"name": "a", "formula": "H2O", "charge": 0, "status": "ok", "G_total_hartree": -1.0}, + {"name": "b", "formula": "H2O", "charge": 0, "status": "ok", "G_total_hartree": -3.0}, + {"name": "c", "formula": "H2O", "charge": 0, "status": "error", "error": "boom"}, + {"name": "d", "formula": "H2O", "charge": 0, "status": "ok", "G_total_hartree": -2.0}, + {"name": "e", "formula": "H2O", "charge": 0, "status": "ok"}, ] ranked = rank_by_gibbs(results) assert [record["name"] for record in ranked] == ["b", "d", "a"] +@pytest.mark.parametrize( + "results", + [ + [ + {"formula": "H2O", "charge": 0, "status": "ok", "G_total_hartree": -1}, + {"formula": "NH3", "charge": 0, "status": "ok", "G_total_hartree": -2}, + ], + [ + {"formula": "H2O", "charge": 0, "status": "ok", "G_total_hartree": -1}, + {"formula": "H2O", "charge": -1, "status": "ok", "G_total_hartree": -2}, + ], + [{"charge": 0, "status": "ok", "G_total_hartree": -1}], + ], +) +def test_rank_by_gibbs_rejects_incomparable_results(results): + from ThermoScreening.thermo.screening import rank_by_gibbs + + assert rank_by_gibbs(results) == [] + + def test_cli_run_screen_returns_failure_count(monkeypatch, capsys): import ThermoScreening.cli.thermo as cli monkeypatch.setattr( cli, "screen", lambda *args, **kwargs: [ - {"name": "m1", "status": "ok", "G_total_hartree": -2.0}, - {"name": "m2", "status": "error", "error": "boom"}, + { + "name": "m1", + "formula": "H2O", + "charge": 0, + "status": "ok", + "G_total_hartree": -2.0, + }, + { + "name": "m2", + "formula": "H2O", + "charge": 0, + "status": "error", + "error": "boom", + }, ] ) @@ -788,7 +846,7 @@ def test_cli_run_screen_returns_failure_count(monkeypatch, capsys): assert cli.run_screen(args) == 1 # one molecule failed out = capsys.readouterr().out - assert "Ranked by Gibbs free energy" in out + assert "Comparable structures ranked by Gibbs free energy" in out assert "1. m1" in out assert "m2: boom" in out @@ -798,8 +856,20 @@ def test_cli_run_screen_all_ok_returns_zero(monkeypatch, capsys): monkeypatch.setattr( cli, "screen", lambda *args, **kwargs: [ - {"name": "m1", "status": "ok", "G_total_hartree": -2.0}, - {"name": "m2", "status": "ok", "G_total_hartree": -3.0}, + { + "name": "m1", + "formula": "H2O", + "charge": 0, + "status": "ok", + "G_total_hartree": -2.0, + }, + { + "name": "m2", + "formula": "H2O", + "charge": 0, + "status": "ok", + "G_total_hartree": -3.0, + }, ] ) args = Namespace(