From fbfdcd95587433e2085b9a85185d65418329af43 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 07:07:34 -0400 Subject: [PATCH 01/14] Refactor into an installable pyquiver package with a modern Python API Restructure the flat src/ scripts into an installable, importable package and modernize the API, while preserving all numerical results (locked by a new regression suite) and backward-compatible imports. Packaging & tooling - move src/*.py into a pyquiver/ package; pyproject.toml (PyPI name pyquiver-kie, import name pyquiver), console script `pyquiver`, weights.dat as package data, optional [pandas]/[dev] extras - GitHub Actions: CI (uv, py3.9-3.13, build + twine check) and PyPI publish via Trusted Publishing - back-compat shims in src/ for old `import quiver`/`from kie import ...` Library hygiene - raise exceptions instead of sys.exit/print; route diagnostics through logging; silent import; remove dead code (utility.py, dump_debug, etc.) - split parsing into pyquiver/parsers/{gaussian,orca,native} behind a style registry; `gaussian` is the preferred style (g16/g09 aliases); one shared periodic table API features - structured results: KIEResult/EIEResult, Results, to_dict/to_csv/to_dataframe - Config.from_dict for building configs in code (no .config file) - polymorphic isotopic replacement: a weights.dat label OR a numeric mass - optional thread parallelism (n_jobs; eigvalsh releases the GIL) - Skodje-Truhlar correction (pyquiver.tunneling + KIE_Calculation.skodje_truhlar) - batch(config, pairs, ...) -> BatchResults (replaces autoquiver + its CLI); pairs as dict/triples/2-tuples; optional energies for batch Skodje-Truhlar - deprecate the `pyquiver` command line in favour of the Python API Bug fixes found along the way - .qin trailing-comma parser, missing numpy import, config % format string, KIE_Calculation Config-object branch, eie_flag NameError, get_row/__str__ mass-override crash, KIE.__str__ missing return, autoquiver match-predicate checking the wrong variable, autoquiver cwd leak, gaussian AttributeError Tests & docs - pytest suite, 189 tests, 100% coverage (pragmas only on unreachable defensive branches) - README/DETAILS/AUTOQUIVER/TUTORIAL build the config in Python; rebuilt scripts/pyquiver.ipynb (supported file types, config in Python, batch, ORCA, tunnelling equations, RPFR diagnostics; folds in the deleted ST notebook) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 43 ++ .github/workflows/publish.yml | 48 ++ .gitignore | 9 + DETAILS.md | 40 +- README.md | 118 ++++- pyproject.toml | 53 +++ pyquiver/__init__.py | 48 ++ pyquiver/__main__.py | 6 + pyquiver/batch.py | 128 +++++ pyquiver/cli.py | 55 +++ pyquiver/config.py | 216 +++++++++ pyquiver/constants.py | 178 +++++++ pyquiver/kie.py | 458 ++++++++++++++++++ pyquiver/orca.py | 9 + pyquiver/parsers/__init__.py | 43 ++ pyquiver/parsers/_common.py | 31 ++ pyquiver/parsers/gaussian.py | 108 +++++ pyquiver/parsers/native.py | 58 +++ pyquiver/parsers/orca.py | 70 +++ pyquiver/quiver.py | 128 +++++ pyquiver/results.py | 76 +++ pyquiver/settings.py | 13 + pyquiver/tunneling.py | 59 +++ {src => pyquiver}/weights.dat | 0 pytest.ini | 5 + scripts/pyquiver.ipynb | 686 ++++++--------------------- scripts/skodgetruhlar.ipynb | 406 ---------------- src/AUTOQUIVER.md | 92 ++-- src/autoquiver.py | 97 ---- src/config.py | 184 +------- src/constants.py | 155 +----- src/kie.py | 387 +-------------- src/orca.py | 107 +---- src/quiver.py | 420 +---------------- src/settings.py | 19 +- src/utility.py | 44 -- test/README.md | 27 ++ test/conftest.py | 38 ++ test/test_batch.py | 92 ++++ test/test_cli.py | 34 ++ test/test_config.py | 238 ++++++++++ test/test_constants.py | 112 +++++ test/test_edge_cases.py | 232 +++++++++ test/test_gaussian_errors.py | 88 ++++ test/test_isotopologue.py | 97 ++++ test/test_kie.py | 92 ++++ test/test_package.py | 27 ++ test/test_parallel.py | 46 ++ test/test_parsers.py | 69 +++ test/test_physics.py | 99 ++++ test/test_regression.py | 79 ++++ test/test_results.py | 62 +++ test/test_serialization.py | 29 ++ test/test_tunneling.py | 93 ++++ tutorial/TUTORIAL.md | 16 +- uv.lock | 856 ++++++++++++++++++++++++++++++++++ 56 files changed, 4646 insertions(+), 2377 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 pyproject.toml create mode 100644 pyquiver/__init__.py create mode 100644 pyquiver/__main__.py create mode 100644 pyquiver/batch.py create mode 100644 pyquiver/cli.py create mode 100644 pyquiver/config.py create mode 100644 pyquiver/constants.py create mode 100644 pyquiver/kie.py create mode 100644 pyquiver/orca.py create mode 100644 pyquiver/parsers/__init__.py create mode 100644 pyquiver/parsers/_common.py create mode 100644 pyquiver/parsers/gaussian.py create mode 100644 pyquiver/parsers/native.py create mode 100644 pyquiver/parsers/orca.py create mode 100644 pyquiver/quiver.py create mode 100644 pyquiver/results.py create mode 100644 pyquiver/settings.py create mode 100644 pyquiver/tunneling.py rename {src => pyquiver}/weights.dat (100%) create mode 100644 pytest.ini delete mode 100644 scripts/skodgetruhlar.ipynb delete mode 100644 src/autoquiver.py delete mode 100644 src/utility.py create mode 100644 test/README.md create mode 100644 test/conftest.py create mode 100644 test/test_batch.py create mode 100644 test/test_cli.py create mode 100644 test/test_config.py create mode 100644 test/test_constants.py create mode 100644 test/test_edge_cases.py create mode 100644 test/test_gaussian_errors.py create mode 100644 test/test_isotopologue.py create mode 100644 test/test_kie.py create mode 100644 test/test_package.py create mode 100644 test/test_parallel.py create mode 100644 test/test_parsers.py create mode 100644 test/test_physics.py create mode 100644 test/test_regression.py create mode 100644 test/test_results.py create mode 100644 test/test_serialization.py create mode 100644 test/test_tunneling.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..459aff9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + name: test (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + - name: Install package + dev/pandas extras + run: uv pip install --system -e ".[dev,pandas]" + - name: Run tests with coverage + run: | + coverage run --source=pyquiver -m pytest -q + coverage report -m + + build: + name: build & check distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + - name: Build sdist + wheel and validate + run: | + uv pip install --system build twine + python -m build + python -m twine check dist/* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9df0d9e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,48 @@ +name: Publish to PyPI + +# Publishes pyquiver-kie to PyPI when a GitHub Release is published. +# Uses PyPI Trusted Publishing (OIDC) — no API token/secret is stored. +# +# One-time PyPI setup (https://pypi.org/manage/account/publishing/): +# PyPI project name: pyquiver-kie +# Owner: ekwan +# Repository: PyQuiver +# Workflow name: publish.yml +# Environment: pypi + +on: + release: + types: [published] + +jobs: + build: + name: build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Build sdist + wheel + run: | + python -m pip install --upgrade pip build twine + python -m build + python -m twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # required for Trusted Publishing + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 8a32ccc..9320050 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,12 @@ *.csv *DS_Store* .ipynb_checkpoints + +# packaging / build artifacts +build/ +dist/ +*.egg-info/ +.coverage +.pytest_cache/ +__pycache__/ +*.pyc diff --git a/DETAILS.md b/DETAILS.md index 923fada..917798a 100644 --- a/DETAILS.md +++ b/DETAILS.md @@ -16,16 +16,16 @@ The development of *PyQuiver* was inspired by the [original](#ref2) Fortran prog *PyQuiver* can be controlled from the command line or its Python API. -To run *PyQuiver* from the command line, issue the following command from the `src/` directory: +Once installed (`pip install pyquiver-kie`), run *PyQuiver* from the command line: ``` -python quiver.py config_file ground_state_file transition_state_file +pyquiver config_file ground_state_file transition_state_file ``` -For more details, run `python quiver.py -h` to display the following help message: +For more details, run `pyquiver -h` to display the following help message: ``` -usage: quiver.py [-h] [-v] [-s STYLE] config gs ts +usage: pyquiver [-h] [-v] [-s STYLE] [-j JOBS] config gs ts PyQuiver calculates KIEs and EIEs based on a ground and transition state file. @@ -36,17 +36,27 @@ positional arguments: optional arguments: -h, --help show this help message and exit - -v, --verbose when the verbose flag is set debug information is - printed + -v, --verbose print progress/diagnostic logging (repeatable: -vv) -s STYLE, --style STYLE - style of input files (g09, orca, or pyquiver) + style of input files (gaussian, orca, or pyquiver) + -j JOBS, --jobs JOBS worker threads for the isotopologue computations + (default 1; -1 uses all cores) ``` -This command will calculate the KIEs or EIEs associated with the isotopic substitutions specified in the configuration file. For details, see the tutorial above. +This command will calculate the KIEs or EIEs associated with the isotopic substitutions specified in the configuration file. For details, see the tutorial above. (The legacy `python src/quiver.py ...` invocation still works but is deprecated.) -*PyQuiver* also has a Python API. The advantage of this interface is that it exposes the underlying Python objects. This allows you to run custom calculations, automate routine calculations, and inspect the internal data directly. +*PyQuiver* also has a Python API that exposes the underlying objects, so you can run custom calculations, scan parameters, and inspect or export results directly: -An IPython Notebook is provided as a demonstration. To try it out, move to the `scripts/` directory and run the command `ipython notebook`. Then open the `pyquiver.ipynb` notebook file. An IPython Notebook `skodgetruhlar.ipynb` is also provided to calculate KIEs corrected with the Skodge Truhlar tunnelling equation. (see ref. 4) +```python +from pyquiver import Config, KIE_Calculation +config = Config.from_dict(isotopologues={"C1": [(1, 1, "13C")]}, + temperature=393, scaling=0.9614, imag_threshold=50) +calc = KIE_Calculation(config, "gs.out", "ts.out", style="gaussian") +calc.to_dataframe() # structured results (pandas) +calc.results["C1"].wigner # named access +``` + +See the README for the full API summary (structured results, `Config.from_dict`, the `n_jobs` parameter, and `batch`). The IPython Notebook `pyquiver.ipynb` in `scripts/` demonstrates the API end to end, including the tunnelling corrections (Wigner, Bell, and Skodje-Truhlar; see ref. 4). ### .config Files @@ -72,14 +82,14 @@ Valid configuration files have all of the following directives: *PyQuiver* assumes that the ground state file and transition state file are the outputs of a Gaussian09 `freq` job. To change this assumption *PyQuiver* can accept an additional command-line argument corresponding to the input file style. Currently, *PyQuiver* can automatically read output files from the following formats: -* Gaussian 2009. Style name: `g09` (This also works with Gaussian 2016.) +* Gaussian. Style name: `gaussian` (aliases: `g16`, `g09`; works with Gaussian 2009 and 2016.) * ORCA. Style name: `orca` (Reads the `.hess` files generated by any frequency job.) -* PyQuiver Standard. Style name: `pyquiver` +* PyQuiver Standard. Style name: `pyquiver` (aliases: `native`, `qin`.) -To specify a format other than `g09` from the command line, run with the `-s` flag. For instance: +To specify a format other than `gaussian` from the command line, run with the `-s` flag. For instance: ``` -python quiver.py -s pyquiver ../tutorial/pyquiver/claisen_demo.config ../tutorial/pyquiver/claisen_gs.qin ../pyquiver/claisen_ts.qin +pyquiver -s pyquiver tutorial/pyquiver/claisen_demo.config tutorial/pyquiver/claisen_gs.qin tutorial/pyquiver/claisen_ts.qin ``` would execute the example *PyQuiver* job on the Claisen system using the *PyQuiver* standard input files. This allows you to adapt PyQuiver to other electronic structure programs. (We would be pleased to offer some advice on how to accomplish this.) @@ -120,7 +130,7 @@ The internal workings of *PyQuiver* are relatively simple. Essentially, a KIE c The frequencies can optionally be scaled (see ref. 3), but this will probably not help much (the harmonic approximation is usually quite adequate). Note that *PyQuiver* is recognizees linear vs. non-linear molecules and removes the appropriate number of small translational/rotational modes. (The `frequency_threshold` keyword has been deprecated and is now ignored.) -The performance of *PyQuiver* is generally excellent, even for large systems. This is largely because of the efficiency of the `np.eighvalsh` implementation. Note that when multiple isotopomers are calculated using the same configuration file, *PyQuiver* will recalculate the frequencies for the reference isotopomer repeatedly (i.e., once for every isotopomer). This should not be relevant for routine use. However, it could be avoided by using the *PyQuiver* API. +The performance of *PyQuiver* is generally excellent, even for large systems. This is largely because of the efficiency of the `np.eigvalsh` implementation. When multiple isotopomers are calculated using the same configuration file, the reference isotopomer's frequencies are computed only once and cached (`Isotopologue.calculate_frequencies` short-circuits once `self.frequencies` is set), so the reference is not re-diagonalized for each isotopomer. There is a known issue with getting PyQuiver to work on Cygwin systems due to a problem processing file paths correctly. Additionally it seems to be hard to get NumPy installed properly on such systems. We are working on a fix--please contact me if you require this urgently. (The program works properly on all other kinds unix/linux, as far as we know). diff --git a/README.md b/README.md index 877cd85..2cf4950 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,125 @@ # *PyQuiver* +[![CI](https://github.com/ekwan/PyQuiver/actions/workflows/ci.yml/badge.svg)](https://github.com/ekwan/PyQuiver/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/pyquiver-kie.svg)](https://pypi.org/project/pyquiver-kie/) + *A user-friendly program for calculating isotope effects.* *PyQuiver* is an open-source Python program for calculating kinetic isotope effects (KIEs) and equilibrium isotope effects (EIEs) using harmonic frequencies and the Bigeleisen-Mayer equation. *PyQuiver* requires Cartesian Hessian matrices, which can be calculated using any electronic structure program. ### Features -* automatically read frequencies from [`Gaussian`](http://www.gaussian.com/g_prod/g09.htm) and [`ORCA`](https://orcaforum.kofo.mpg.de/app.php/portal) output files -* excellent performance for larger systems +* automatically read frequencies from [`Gaussian`](https://gaussian.com) (g09/g16) and [`ORCA`](https://orcaforum.kofo.mpg.de/app.php/portal) output files +* excellent performance for larger systems, with optional multi-threading * custom isotopic substitutions and arbitrary temperature -* tunnelling corrections: Wigner and Bell inverted parabola -* run via command line or simple Python API +* tunnelling corrections: Wigner, Bell inverted parabola, and Skodje-Truhlar +* command line **or** a clean Python API with structured (pandas-ready) results ### Installation -*PyQuiver* requires Python 3 and [`numpy`](https://numpy.org). No other libraries are necessary. +*PyQuiver* requires Python 3.9+ and [`numpy`](https://numpy.org). [`pandas`](https://pandas.pydata.org) is optional (only for `to_dataframe()`). + +Install from PyPI: + +```bash +pip install pyquiver-kie # core (numpy only) +pip install "pyquiver-kie[pandas]" # adds DataFrame output +``` + +The distribution is named **`pyquiver-kie`** on PyPI, but you still import it as `pyquiver`. -1. Install [Python](https://www.continuum.io/downloads) if necesary. The standard Anaconda distribution will contain the necessary dependencies. -2. Install [git](https://git-scm.com/downloads). git comes pre-installed on most Linux distributions and Macs. -3. Clone the repository: `git clone https://github.com/ekwan/PyQuiver.git`. (If you don't want to deal with `git`, click on the green "clone or download" button on this github repository page and click on "Download ZIP" to receive an archive.) +Or install from a checkout: + +```bash +git clone https://github.com/ekwan/PyQuiver.git +cd PyQuiver +pip install -e . +``` *PyQuiver* has been tested on PC, Mac, and Linux platforms. +### Command line (deprecated) + +The `pyquiver` command still works but is **deprecated** in favour of the Python API below: + +```bash +pyquiver demo.config gs.out ts.out # gaussian by default; prints a deprecation notice +``` + +Add `-v`/`-vv` for logging, `-s` for the style (`gaussian`/`g16`/`g09`, `orca`, `pyquiver`), `-j` for threads. + +### Python API + +Build the configuration in Python with `Config.from_dict` (or load a `.config` +file with `Config(path)`), then run it on a ground- and transition-state file: + +```python +from pyquiver import Config, KIE_Calculation + +config = Config.from_dict( + isotopologues={"C1": [(1, 1, "13C")], "H/D": [(7, 7, "2D"), (8, 8, "2D")]}, + temperature=393, scaling=0.9614, imag_threshold=50, +) +calc = KIE_Calculation(config, "gs.out", "ts.out", style="gaussian") + +print(calc) # human-readable table +calc.to_dict() # {name: {uncorrected, wigner, inverted_parabola}} +calc.to_csv("kies.csv") # CSV text / file +calc.to_dataframe() # pandas DataFrame (needs the pandas extra) +calc.results["C1"].wigner # named access — no positional indexing +``` + +`System` objects hold a parsed geometry and Hessian, so you can read each file +once and reuse it — handy for a parameter scan: + +```python +from pyquiver import System + +gs = System("gs.out", style="gaussian") # parse once +ts = System("ts.out", style="gaussian") +for T in (298, 350, 393): + cfg = Config.from_dict(isotopologues={"C1": [(1, 1, "13C")]}, + temperature=T, scaling=0.9614, imag_threshold=50) + print(T, KIE_Calculation(cfg, gs, ts).results["C1"].uncorrected) +``` + +`KIE_Calculation(..., n_jobs=N)` parallelizes the per-isotopologue work across +`N` threads (the heavy `eigvalsh` step releases the GIL). The default is serial; +it pays off for large systems and many isotopologues. + +The uncorrected, Wigner, and Bell corrections are reported automatically. For +the Skodje-Truhlar correction, supply the reactant/product/TS single-point +energies (it needs the barrier height): + +```python +# energies in hartree by default (or unit="J") +calc.skodje_truhlar(reactant_energy=E_sm, product_energy=E_pr, ts_energy=E_ts) +# -> {isotopologue: corrected_KIE} +``` + +To run one configuration over many structures, use `batch` — you build the +ground/transition-state pairs from your own file globbing (no naming +convention assumed) and get back a table: + +```python +from pyquiver import batch + +results = batch("demo.config", { + "b3lyp": ("b3lyp_gs.out", "b3lyp_ts.out"), + "m06": ("m06_gs.out", "m06_ts.out"), +}) +results.to_dataframe() # label, isotopologue, uncorrected, wigner, inverted_parabola +results["b3lyp"] # the KIE_Calculation for that pair +``` + +Pairs may also be `(label, gs, ts)` triples or bare `(gs, ts)` tuples (label +inferred from the filename). Pass `energies={label: (reactant, ts, product)}` +to add a Skodje-Truhlar column. + +PyQuiver logs through the standard `logging` module (logger name `pyquiver`) and +raises exceptions on bad input — it never prints on import or calls `sys.exit`, +so it is safe to use inside scripts and notebooks. + ## Tutorial To learn how to use *PyQuiver*, please look at the [tutorial](tutorial/TUTORIAL.md). @@ -29,7 +127,7 @@ To learn how to use *PyQuiver*, please look at the [tutorial](tutorial/TUTORIAL. ## Further Reading * [Technical Details](DETAILS.md) (compatibility with QUIVER, how to invoke PyQuiver, `.config` files, input files, the PyQuiver Standard format, miscellaneous notes) -* [AutoQuiver](src/AUTOQUIVER.md) (how to run PyQuiver on many related files) +* [Batch processing](src/AUTOQUIVER.md) (how to run PyQuiver on many related files) * [Snipping Utility](scripts/SNIP.md) (how to "snip" out only the relevant parts of a Gaussian output file for publication or archiving purposes) ## Authors @@ -40,7 +138,7 @@ We thank Gregor Giesen for writing the code to read ORCA output. We thank Corin ## How to Cite -Anderson, T.L.; Kwan, E.E. *PyQuiver* **2020**, `www.github.com/ekwan/PyQuiver` +Anderson, T.L.; Kwan, E.E. *PyQuiver*, `www.github.com/ekwan/PyQuiver` (`pip install pyquiver-kie`) ## License diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..60f6aec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +# distribution name on PyPI (the import package is still `pyquiver`) +name = "pyquiver-kie" +version = "1.1.0" +description = "Calculate kinetic and equilibrium isotope effects from Hessian matrices using the Bigeleisen-Mayer equation." +readme = "README.md" +requires-python = ">=3.9" +license = { file = "LICENSE.txt" } +authors = [ + { name = "Eugene Kwan" }, + { name = "Thayer Anderson" }, +] +keywords = ["chemistry", "kinetic isotope effect", "KIE", "EIE", "Bigeleisen-Mayer", "computational chemistry"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Chemistry", +] +dependencies = [ + "numpy", +] + +[project.optional-dependencies] +pandas = ["pandas"] +dev = ["pytest", "coverage"] + +[project.urls] +Homepage = "https://github.com/ekwan/PyQuiver" +Repository = "https://github.com/ekwan/PyQuiver" + +[project.scripts] +pyquiver = "pyquiver.cli:main" + +[tool.setuptools] +packages = ["pyquiver", "pyquiver.parsers"] + +[tool.coverage.run] +omit = ["pyquiver/__main__.py"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", +] + +[tool.setuptools.package-data] +pyquiver = ["weights.dat"] diff --git a/pyquiver/__init__.py b/pyquiver/__init__.py new file mode 100644 index 0000000..eea9b07 --- /dev/null +++ b/pyquiver/__init__.py @@ -0,0 +1,48 @@ +"""PyQuiver: kinetic and equilibrium isotope effects from Hessian matrices. + +PyQuiver computes KIEs/EIEs with the Bigeleisen-Mayer equation from Cartesian +Hessians produced by electronic-structure programs (Gaussian, ORCA, or the +native ``.qin`` format). + +Typical programmatic use:: + + from pyquiver import KIE_Calculation + calc = KIE_Calculation("demo.config", "gs.out", "ts.out", style="g09") + print(calc) + calc.KIES["C1"].value # (raw, Wigner, inverted-parabola) KIE +""" + +import logging as _logging + +from .quiver import System, Isotopologue +from .config import Config +from .kie import KIE_Calculation, KIE +from .results import Results, KIEResult, EIEResult +from .batch import batch, BatchResults +from . import tunneling + +# a library should not configure logging itself; attach a no-op handler so +# importing pyquiver never prints "No handlers could be found" and callers +# stay in full control of log output +_logging.getLogger("pyquiver").addHandler(_logging.NullHandler()) + +try: # populated from package metadata once installed (PyPI name: pyquiver-kie) + from importlib.metadata import version as _version + __version__ = _version("pyquiver-kie") +except Exception: # pragma: no cover - source checkout that isn't installed + __version__ = "0+unknown" + +__all__ = [ + "System", + "Isotopologue", + "Config", + "KIE_Calculation", + "KIE", + "Results", + "KIEResult", + "EIEResult", + "batch", + "BatchResults", + "tunneling", + "__version__", +] diff --git a/pyquiver/__main__.py b/pyquiver/__main__.py new file mode 100644 index 0000000..be3ab0f --- /dev/null +++ b/pyquiver/__main__.py @@ -0,0 +1,6 @@ +"""Allow ``python -m pyquiver ...`` to run a single KIE calculation.""" + +from .cli import main + +if __name__ == "__main__": + main() diff --git a/pyquiver/batch.py b/pyquiver/batch.py new file mode 100644 index 0000000..58d50f7 --- /dev/null +++ b/pyquiver/batch.py @@ -0,0 +1,128 @@ +"""Run a KIE/EIE calculation over many ground-state/transition-state pairs. + +File discovery and pairing are left to ordinary Python (glob, pathlib, ...); +``batch`` just maps one configuration over the pairs you give it and collects +the results into a table. No filename convention is assumed:: + + import glob + from pyquiver import batch + + gs = sorted(glob.glob("*_reactant.out")) + ts = sorted(glob.glob("*_ts.out")) + results = batch("demo.config", zip(gs, ts)) # labels inferred from filenames + results.to_dataframe() + +Pairs may be given as a mapping ``{label: (gs, ts)}``, an iterable of +``(label, gs, ts)`` triples, or an iterable of ``(gs, ts)`` pairs (in which +case the label is the ground-state filename without its extension). +""" + +import os +from collections import OrderedDict + +from .config import Config +from .kie import KIE_Calculation + + +def _normalize_pairs(pairs): + """Yield (label, gs, ts) from any of the supported pair formats.""" + items = pairs.items() if hasattr(pairs, "items") else pairs + for item in items: + if hasattr(pairs, "items"): + label, (gs, ts) = item + elif len(item) == 3: + label, gs, ts = item + elif len(item) == 2: + gs, ts = item + label = os.path.splitext(os.path.basename(str(gs)))[0] + else: + raise ValueError("each pair must be (gs, ts) or (label, gs, ts)") + yield label, gs, ts + + +class BatchResults(object): + """Results of a :func:`batch` run: one KIE_Calculation per label.""" + + def __init__(self, calcs, skodje_truhlar=None): + self._calcs = OrderedDict(calcs) # label -> KIE_Calculation + self._st = skodje_truhlar # label -> {isotopologue: corrected} or None + + def __getitem__(self, label): + return self._calcs[label] + + def __iter__(self): + return iter(self._calcs) + + def __len__(self): + return len(self._calcs) + + def items(self): + return self._calcs.items() + + def to_records(self): + """Tidy long-form rows: one dict per (label, isotopologue).""" + rows = [] + for label, calc in self._calcs.items(): + for result in calc.results: + row = {"label": label} + row.update(result._asdict()) + if self._st is not None: + row["skodje_truhlar"] = self._st[label].get(result.name) + rows.append(row) + return rows + + def to_dataframe(self): + """Return the results as a pandas DataFrame (optional pandas extra).""" + try: + import pandas as pd + except ImportError: + raise ImportError("to_dataframe requires pandas; install with " + "`pip install pyquiver-kie[pandas]`") + return pd.DataFrame(self.to_records()) + + def to_csv(self, path=None, float_format="%.4f"): + """Render the results as CSV text; write to ``path`` if given.""" + records = self.to_records() + if not records: + return "" + columns = list(records[0].keys()) + + def cell(v): + return float_format % v if isinstance(v, float) else str(v) + + lines = [",".join(columns)] + lines += [",".join(cell(rec[c]) for c in columns) for rec in records] + text = "\n".join(lines) + "\n" + if path is not None: + with open(path, "w") as f: + f.write(text) + return text + + +def batch(config, pairs, style="gaussian", n_jobs=1, energies=None): + """Run a KIE calculation for each ground-state/transition-state pair. + + ``config`` is a path to a .config file or a :class:`~pyquiver.Config`. + ``pairs`` is a mapping ``{label: (gs, ts)}`` or an iterable of ``(gs, ts)`` + or ``(label, gs, ts)`` (see the module docstring). Returns + :class:`BatchResults`. + + If ``energies`` is given, a Skodje-Truhlar correction is computed for every + pair and included in the results. It maps each label to the three + single-point electronic energies the barrier needs, in hartree, ordered + along the reaction coordinate: + ``{label: (reactant_energy, ts_energy, product_energy)}``. (If your reaction + is effectively a single well, pass the reactant energy for the product too.) + """ + if isinstance(config, str): + config = Config(config) # parse once, reuse for every pair + + calcs = OrderedDict() + st = OrderedDict() if energies is not None else None + for label, gs, ts in _normalize_pairs(pairs): + calc = KIE_Calculation(config, gs, ts, style=style, n_jobs=n_jobs) + calcs[label] = calc + if energies is not None: + reactant, ts_energy, product = energies[label] + st[label] = calc.skodje_truhlar(reactant, product, ts_energy) + return BatchResults(calcs, st) diff --git a/pyquiver/cli.py b/pyquiver/cli.py new file mode 100644 index 0000000..b0172f8 --- /dev/null +++ b/pyquiver/cli.py @@ -0,0 +1,55 @@ +"""Command-line entry point for PyQuiver (the ``pyquiver`` console script). + +The command line is deprecated; use the Python API (pyquiver.KIE_Calculation) +instead. Batch processing over many files is Python-only; see pyquiver.batch. +""" + +import sys +import argparse +import logging +import warnings + +from .kie import KIE_Calculation + + +def _configure_logging(verbosity): + """Map a -v count to a logging level and install a simple handler. + + 0 -> WARNING (quiet), 1 -> INFO, 2+ -> DEBUG. + """ + level = logging.WARNING + if verbosity == 1: + level = logging.INFO + elif verbosity and verbosity >= 2: + level = logging.DEBUG + logging.basicConfig(level=level, format="%(message)s") + + +def main(argv=None): + """Run a single KIE/EIE calculation (the ``pyquiver`` command).""" + parser = argparse.ArgumentParser( + description="PyQuiver calculates KIEs and EIEs based on a ground " + "and transition state file.") + parser.add_argument('-v', '--verbose', dest="debug", action='count', + help='print debug information (repeatable)') + parser.add_argument('-s', '--style', dest="style", default='gaussian', + help='style of input files (gaussian, orca, or pyquiver)') + parser.add_argument('-j', '--jobs', dest="jobs", type=int, default=1, + help='number of worker threads for the isotopologue ' + 'computations (default 1; -1 uses all cores)') + parser.add_argument('config', help='configuration file path') + parser.add_argument('gs', help='ground state file path') + parser.add_argument('ts', help='transition state file path') + + args = parser.parse_args(argv) + _configure_logging(args.debug or 0) + + _msg = ("the 'pyquiver' command line is deprecated and will be removed in a " + "future release; use the Python API (pyquiver.KIE_Calculation) instead.") + warnings.warn(_msg, DeprecationWarning, stacklevel=2) + print("warning: " + _msg, file=sys.stderr) + + calc = KIE_Calculation(args.config, args.gs, args.ts, style=args.style, + n_jobs=args.jobs) + print(calc) + return calc diff --git a/pyquiver/config.py b/pyquiver/config.py new file mode 100644 index 0000000..8cd6a5b --- /dev/null +++ b/pyquiver/config.py @@ -0,0 +1,216 @@ +# This file reads PyQuiver configuration files. +import logging +from .constants import REPLACEMENTS_Z, replacement_mass +from collections import OrderedDict + +logger = logging.getLogger("pyquiver") + +EXPECTED_FIELDS = "scaling temperature mass_override_isotopologue reference_isotopologue imag_threshold frequency_threshold".split(" ") + + +# Reads PyQuiver .config files. +class Config(object): + def __init__(self,filename): + config = { i : None for i in EXPECTED_FIELDS } + config["filename"] = filename + + logger.debug("Reading configuration from %s", filename) + + # a list of isotopologues + # each entry is a list of tuples + # each tuple is (from_atom_number, to_atom_number, replacement_isotope) + # this format allows for multiple replacements in one isotopologue + isotopologues = OrderedDict() + expected_fields = EXPECTED_FIELDS + + # read file + with open(filename, "r") as _config_file: + config_lines = _config_file.readlines() + for line in config_lines: + # ignore comments and blank lines + line = line.strip() + if len(line) == 0 or line[0] == "#": + continue + line = line.split("#", 1)[0] + + # read space-delimited data + fields = [_f for _f in line.split(" ") if _f] + + # for backwards compatibility with quiver + if fields[0] == "isotopomer": + fields[0] = "isotopologue" + elif fields[0] == "reference_isotopomer": + fields[0] = "reference_isotopologue" + elif fields[0] == "mass_override_isotopomer": + fields[0] = "mass_override_isotopologue" + + + # parse + if fields[0] == "isotopologue": + # parse isotopologues, checking for sanity + if len(fields) != 5: + raise ValueError("unexpected number of fields for isotopologue in config file:\n%s" % line) + isotopologue_id, from_atom_number, to_atom_number = str(fields[1]), int(fields[2]), int(fields[3]) + # a replacement is a standard isotope label (e.g. 13C) or a + # bare number for an unusual mass (e.g. 5000.0) + try: + replacement = float(fields[4]) + except ValueError: + replacement = str(fields[4]) + if isotopologue_id == "default": + raise ValueError("name default is reserved.") + if from_atom_number < 1 or to_atom_number < 1: + raise ValueError("check atom numbers:\n%s" % line) + try: + replacement_mass(replacement) # validates label or numeric mass + except ValueError: + raise ValueError("invalid isotopic replacement:\n%s" % line) + + # allows for the fact that isotopologues can make multiple replacements + try: + isotopologues[isotopologue_id].append((from_atom_number, to_atom_number, replacement)) + except KeyError: + isotopologues[isotopologue_id] = [(from_atom_number, to_atom_number, replacement)] + + elif len(fields) == 2: + # read regular configuration fields that have only one entry + fields = [ str(i) for i in fields ] + if fields[0] not in expected_fields: + raise ValueError("unexpected config field:\n%s" % line) + config[fields[0]] = fields[1] + else: + raise ValueError("unexpected number of fields in config file:\n%s" % line) + + self._finalize(config, isotopologues) + + @classmethod + def from_dict(cls, isotopologues, temperature, scaling, imag_threshold, + reference_isotopologue="none", + mass_override_isotopologue="default", filename=""): + """Build a Config programmatically, without a .config file. + + ``isotopologues`` maps each isotopologue name to a list of + ``(gs_atom, ts_atom, replacement)`` tuples (1-indexed atoms). + ``replacement`` is either a standard isotope label from weights.dat + (e.g. ``"13C"``) or a number giving an unusual mass directly (e.g. + ``5000.0``). + """ + self = cls.__new__(cls) + config = {i: None for i in EXPECTED_FIELDS} + config["filename"] = filename + config["temperature"] = temperature + config["scaling"] = scaling + config["imag_threshold"] = imag_threshold + config["reference_isotopologue"] = reference_isotopologue + config["mass_override_isotopologue"] = mass_override_isotopologue + + normalized = OrderedDict() + for name, rules in isotopologues.items(): + if str(name) == "default": + raise ValueError("name default is reserved.") + for (from_atom, to_atom, replacement) in rules: + if from_atom < 1 or to_atom < 1: + raise ValueError("check atom numbers for isotopologue %s" % name) + replacement_mass(replacement) # validates label or numeric mass + # preserve the replacement's type (label str or numeric mass) + normalized[str(name)] = [(int(f), int(t), r) for (f, t, r) in rules] + + self._finalize(config, normalized) + return self + + def _finalize(self, config, isotopologues): + # ensure we have all the fields we are supposed to + for k,v in config.items(): + if k == "frequency_threshold" and v is not None: + logger.warning("frequency_threshold is now deprecated and will be ignored.") + if v is None: + if k == "frequency_threshold": + config["frequency_threshold"] = 0.0 + else: + raise ValueError("missing config file field: %s" % k) + if len(isotopologues) == 0: + raise ValueError("must specify at least one isotopologue") + + # check some of the other fields + config["temperature"] = float(config["temperature"]) + if config["temperature"] < 0.0: + raise ValueError("check temperature") + config["scaling"] = float(config["scaling"]) + if config["scaling"] < 0.5 or config["scaling"] > 1.5: + raise ValueError("check scaling factor") + + config["reference_isotopologue"] = str(config["reference_isotopologue"]) + config["mass_override_isotopologue"] = str(config["mass_override_isotopologue"]) + + config["frequency_threshold"] = float(config["frequency_threshold"]) + + config["imag_threshold"] = float(config["imag_threshold"]) + if config["imag_threshold"] > 300.0: + raise ValueError("imag threshold is too high") + + # store all the information in the object dictionary + config["isotopologues"] = isotopologues + self.__dict__ = config + + # checks if this config file is compatible with a pair of ground and transition state systems + def check(self, gs, ts, verbose=False): + # check that the isotopic replacements make sense + isotopologues = self.isotopologues + for i,isotopologue in isotopologues.items(): + for r in range(len(isotopologue)): + # this replacement changes gs atom number from_atom + # and ts atom number to_atom + # to replacement (like "2D") + from_atom = isotopologue[r][0] + to_atom = isotopologue[r][1] + replacement = isotopologue[r][2] + replacement_string = "%s %d %d %s" % (i, from_atom, to_atom, replacement) + # get the atomic numbers of from_atom and to_atom + # must subtract one to convert from atom numbers to indices + from_atomZ = gs.atomic_numbers[from_atom-1] + to_atomZ = ts.atomic_numbers[to_atom-1] + # a custom numeric mass carries no element identity, so the + # element-match check only applies to standard isotope labels + if replacement not in REPLACEMENTS_Z: + continue + replacementZ = REPLACEMENTS_Z[replacement] + if from_atomZ != replacementZ: + raise ValueError("gs atomic number and replacement atomic number do not match for {0}\n".format(replacement_string)) + if to_atomZ != replacementZ: + raise ValueError("ts atomic number and replacement atomic number do not match for {0}\n".format(replacement_string)) + # unreachable: if we get here both gs and ts atomic numbers + # already equal replacementZ, so they equal each other + if (from_atomZ != to_atomZ): # pragma: no cover + raise ValueError("gs and ts atomic number do not match for %s\n" % replacement_string) + + if verbose: + logger.info("Config file %s makes sense with gs file %s and ts file %s.", + self.filename, gs.filename, ts.filename) + + # convert to human-readable format + def __str__(self): + to_string = "Config file: %s\nTemperature: %.1f K\nScaling: %.3f\nReference Isotopologue: %s\nImag threshold (cm-1): %d\n" % \ + (self.filename, self.temperature, self.scaling, self.reference_isotopologue, self.imag_threshold) + if self.frequency_threshold != 0: + to_string += "Frequency threshold (cm-1): %d\n" % self.frequency_threshold + + keys = list(self.isotopologues.keys()) + if self.reference_isotopologue != "default" and self.reference_isotopologue != "none": + try: + keys.remove(self.reference_isotopologue) + except ValueError: + raise ValueError("Could not find the reference isotopologue: %s" + % self.reference_isotopologue) + keys.sort() + + if self.reference_isotopologue == "default" or self.reference_isotopologue == "none": + to_string += " No reference isotopologue.\n" + else: + keys = [self.reference_isotopologue] + keys + for i in keys: + isotopologue = self.isotopologues[i] + + for j in range(len(isotopologue)): + to_string += " Isotopologue {0: >10s}, replacement {1: >2d}: replace gs atom {2: ^3d} and ts atom {3: ^3d} with {4: >3s}\n".format(i, j+1, isotopologue[j][0], isotopologue[j][1], isotopologue[j][2]) + + return to_string[:-1] diff --git a/pyquiver/constants.py b/pyquiver/constants.py new file mode 100644 index 0000000..a853ad4 --- /dev/null +++ b/pyquiver/constants.py @@ -0,0 +1,178 @@ +# This file holds physical constants and reads atomic weights. +import os +import re +import logging + +logger = logging.getLogger("pyquiver") +############### + +# Physical Constants + +PHYSICAL_CONSTANTS = { + 'h' : 6.626070E-34, # Planck's constants in J * s + 'c' : 2.997925E+10, # speed of light in units of cm/s + 'Eh' : 4.359745E-18, # energy of a hartree in units of J = kg m^2/s^2 + 'a0' : 5.291772E-11, # bohr radius in m + 'atb': 5.291772E-01, # angstroms per bohr + 'amu': 1.660468E-27, # atomic mass unit in units kg + 'kB' : 1.380649E-23 # Boltzmann's constant in J/K +} + #CM/2.998E10/,EM/1.440E13/,HBC/1.4387/ + +############### + +# Atomic Weight Information + +class Element(object): + def __init__(self, full_name, atomic_number, symbol, default_mass): + # the name of this element, like "hydrogen" + full_name = str(full_name) + self.full_name = full_name + if re.match("[^a-z]", full_name): + raise ValueError("Unexpected non-lowercase character in element " + "name: %s" % full_name) + + # the symbol of this element, like "H" + symbol = str(symbol) + self.symbol = symbol + if re.match("[^a-zA-Z]", symbol): + raise ValueError("Unexpected non-letter character in element " + "symbol: %s" % symbol) + if len(symbol) < 1 or len(symbol) > 2: + raise ValueError("Unexpected length of element symbol (must be 1 " + "or 2): %s" % symbol) + + # the atomic number of this element, like 1 + atomic_number = int(atomic_number) + self.atomic_number = atomic_number + if atomic_number < 1 or atomic_number > 200: + raise ValueError("Unexpected atomic number: %d" % atomic_number) + + # the average weight for this element, like 1.00783 + default_mass = float(default_mass) + self.default_mass = default_mass + if default_mass < 0.0 or default_mass > 500.0: + raise ValueError("Unexpected default mass: %f" % default_mass) + + # pairs of tuples strings (like "2H") to masses (like 2.0141) + self.replacements = [] + + def __str__(self): + string = "%s (%s, Z=%d, default mass = %.4f" % (self.full_name.capitalize(), self.symbol, self.atomic_number, self.default_mass) + if len(self.replacements) == 0: + string += ", no isotopic replacements possible)\n" + else: + string += ")\n" + for s,m in self.replacements: + string += " %2s : %.4f\n" % (s,m) + return string[:-1] + + def add_replacement(self, symbol, mass): + symbol = str(symbol) + if re.match("[^a-zA-Z0-9]", symbol): + raise ValueError("Unexpected non-letter character in isotopic " + "replacement symbol: %s" % symbol) + if len(symbol) < 1 or len(symbol) > 4: + raise ValueError("Unexpected length of element symbol in " + "replacement (must be 1-4 inclusive, found %d): " + "%s" % (len(symbol), symbol)) + for s,m in self.replacements: + if s == symbol: + raise ValueError("Must use a unique symbol for every isotopic " + "replacement: %s" % s) + mass = float(mass) + if mass < 0.0 or mass > 500.0: + raise ValueError("Unexpected isotopic replacement mass: %f" % mass) + self.replacements.append((symbol,mass)) + +# read in atomic weight data +elements = [] + +root = os.path.split(os.path.abspath(__file__))[0] + +with open(root + "/weights.dat", "r") as _weights_file: + weights_lines = _weights_file.readlines() +for line in weights_lines: + # ignore comments and blank lines + line = line.strip() + if len(line) == 0 or line[0] == "#": + continue + line = line.split("#",1)[0] + + # parse (the shipped weights.dat is well-formed, so the guards below are + # defensive against a corrupted data file) + fields = line.split(",") + if len(fields) < 4: # pragma: no cover + raise ValueError("Not enough data on this line of weights.dat: %s" + % line) + element = Element(*fields[0:4]) + if (len(fields)-4) % 2 != 0: # pragma: no cover + raise ValueError("Unexpected number of isotopic replacement fields on " + "this line of weights.dat (the number of fields after " + "the first four must be a multiple of 2, found %d): %s" + % (len(fields)-4, line)) + if (len(fields) > 4): + for i in range(4, len(fields), 2): + element.add_replacement(fields[i], fields[i+1]) + elements.append(element) +logger.info("Read atomic weight data for %d elements.", len(elements)) + +# map from atomic number to default masses +DEFAULT_MASSES = { e.atomic_number : e.default_mass for e in elements } + +# map from valid isotopic replacements to masses +REPLACEMENTS = {} +for e in elements: + for replacement,mass in e.replacements: + REPLACEMENTS[replacement] = mass + +# map from isotopic replacements to atomic numbers +REPLACEMENTS_Z = {} +for e in elements: + for replacement,mass in e.replacements: + REPLACEMENTS_Z[replacement]=e.atomic_number + + +def replacement_mass(replacement): + """Resolve an isotopic replacement to a mass (amu). + + A number is used directly as the mass (for unusual/non-standard isotopes); + a string is looked up among the standard isotope labels in weights.dat + (e.g. "13C", "2D", "17O"). Raises ValueError for anything else. + """ + if isinstance(replacement, bool): + raise ValueError("invalid isotopic replacement: %r" % replacement) + if isinstance(replacement, (int, float)): + if replacement <= 0.0: + raise ValueError("isotopic replacement mass must be positive: %r" % replacement) + return float(replacement) + if replacement in REPLACEMENTS: + return REPLACEMENTS[replacement] + raise ValueError("invalid isotopic replacement: %r" % replacement) + + +# threshold to separate linear molecules from non-linear molecules +LINEARITY_THRESHOLD = 1e-06 +DROP_NUM_LINEAR = 5 +# DROP_NUM_NONLINEAR = 6 + +# canonical element symbol -> atomic number map (the full periodic table; the +# masses in weights.dat only cover the subset PyQuiver ships isotope data for) +SYMBOL_TO_Z = { + "H": 1, "He": 2, + "Li": 3, "Be": 4, "B": 5, "C": 6, "N": 7, "O": 8, "F": 9, "Ne": 10, + "Na": 11, "Mg": 12, "Al": 13, "Si": 14, "P": 15, "S": 16, "Cl": 17, "Ar": 18, + "K": 19, "Ca": 20, + "Sc": 21, "Ti": 22, "V": 23, "Cr": 24, "Mn": 25, "Fe": 26, "Co": 27, "Ni": 28, "Cu": 29, "Zn": 30, + "Ga": 31, "Ge": 32, "As": 33, "Se": 34, "Br": 35, "Kr": 36, + "Rb": 37, "Sr": 38, + "Y": 39, "Zr": 40, "Nb": 41, "Mo": 42, "Tc": 43, "Ru": 44, "Rh": 45, "Pd": 46, "Ag": 47, "Cd": 48, + "In": 49, "Sn": 50, "Sb": 51, "Te": 52, "I": 53, "Xe": 54, + "Cs": 55, "Ba": 56, + "La": 57, "Ce": 58, "Pr": 59, "Nd": 60, "Pm": 61, "Sm": 62, "Eu": 63, "Gd": 64, "Tb": 65, "Dy": 66, "Ho": 67, "Er": 68, "Tm": 69, "Yb": 70, "Lu": 71, + "Hf": 72, "Ta": 73, "W": 74, "Re": 75, "Os": 76, "Ir": 77, "Pt": 78, "Au": 79, "Hg": 80, + "Tl": 81, "Pb": 82, "Bi": 83, "Po": 84, "At": 85, "Rn": 86, + "Fr": 87, "Ra": 88, + "Ac": 89, "Th": 90, "Pa": 91, "U": 92, "Np": 93, "Pu": 94, "Am": 95, "Cm": 96, "Bk": 97, "Cf": 98, "Es": 99, "Fm": 100, "Md": 101, "No": 102, "Lr": 103, + "Rf": 104, "Db": 105, "Sg": 106, "Bh": 107, "Hs": 108, "Mt": 109, "Ds": 110, "Rg": 111, "Cn": 112, +} diff --git a/pyquiver/kie.py b/pyquiver/kie.py new file mode 100644 index 0000000..86e42ac --- /dev/null +++ b/pyquiver/kie.py @@ -0,0 +1,458 @@ +# This calculates KIEs based on the Bigeleisen-Mayer equation. +import logging +import numpy as np +from . import quiver +from .config import Config +from .constants import DEFAULT_MASSES +from .results import Results, KIEResult, EIEResult +from collections import OrderedDict + +# load constants +from .constants import PHYSICAL_CONSTANTS, replacement_mass + +logger = logging.getLogger("pyquiver") +h = PHYSICAL_CONSTANTS["h"] # in J . s +c = PHYSICAL_CONSTANTS["c"] # in cm . s +kB = PHYSICAL_CONSTANTS["kB"] # in J/K + +class KIE_Calculation(object): + def __init__(self, config, gs, ts, style="gaussian", n_jobs=1): + # n_jobs controls optional parallelism over isotopologues (default + # serial). The heavy step is np.linalg.eigvalsh, which releases the + # GIL, so threads give real speedup for large systems / many + # isotopologues. n_jobs < 0 lets the pool pick a default worker count. + self.n_jobs = n_jobs + + # accept either file paths (str) or already-built objects + if isinstance(config, str): + self.config = Config(config) + elif isinstance(config, Config): + self.config = config + else: + raise TypeError("config argument must be either a filepath or Config object.") + + if isinstance(gs, str): + self.gs_system = quiver.System(gs, style=style) + elif isinstance(gs, quiver.System): + self.gs_system = gs + else: + raise TypeError("gs argument must be either a filepath or quiver.System object.") + + if isinstance(ts, str): + self.ts_system = quiver.System(ts, style=style) + elif isinstance(ts, quiver.System): + self.ts_system = ts + else: + raise TypeError("ts argument must be either a filepath or quiver.System object.") + + # set the eie_flag to the recognized uninitialized value (used for checking if there are inconsistent calculation types) + self.eie_flag = -1 + + logger.debug("%s", self.config) + + KIES = OrderedDict() + + if self.config.frequency_threshold: + logger.warning("config file uses the frequency_threshold parameter. This has been deprecated and low frequencies are dropped by linearity detection.") + + iso_pairs = list(self.make_isotopologues()) + + # pre-compute the reference ("default") isotopologue's frequencies once, + # serially: every pair shares those objects, so warming the cache here + # both preserves the single-diagonalization optimization and avoids a + # cache race when the per-isotopologue work runs on multiple threads + if iso_pairs: + (default_gs, _), (default_ts, _) = iso_pairs[0] + default_gs.calculate_frequencies(self.config.imag_threshold, + scaling=self.config.scaling) + default_ts.calculate_frequencies(self.config.imag_threshold, + scaling=self.config.scaling) + + def _build(pair): + gs_tuple, ts_tuple = pair + name = gs_tuple[1].name + return name, KIE(name, gs_tuple, ts_tuple, self.config.temperature, + self.config.scaling, self.config.imag_threshold) + + if self.n_jobs == 1 or len(iso_pairs) <= 1: + built = [_build(p) for p in iso_pairs] + else: + from concurrent.futures import ThreadPoolExecutor + max_workers = self.n_jobs if self.n_jobs > 0 else None + with ThreadPoolExecutor(max_workers=max_workers) as pool: + built = list(pool.map(_build, iso_pairs)) # preserves order + + for name, kie in built: + KIES[name] = kie + + + for name,k in KIES.items(): + if name != self.config.reference_isotopologue: + if self.eie_flag == -1: + eie_flag_iso = name + self.eie_flag = k.eie_flag + else: + # defensive: all isotopologues share the same TS, so they + # are all KIE or all EIE; a mismatch shouldn't occur + if self.eie_flag != k.eie_flag: # pragma: no cover + if self.eie_flag == 1: + raise ValueError("quiver attempted to run a KIE calculation (isotopomer {0}) after an EIE calculation (isotopomer {1}). Check the frequency threshold.".format(name, eie_flag_iso)) + else: + raise ValueError("quiver attempted to run an EIE calculation (isotopomer {0}) after a KIE calculation (isotopomer {1}). Check the frequency threshold.".format(name, eie_flag_iso)) + + if self.config.reference_isotopologue != "default" and self.config.reference_isotopologue != "none": + k.apply_reference(KIES[self.config.reference_isotopologue]) + + self.KIES = KIES + + # retrieves KIEs for autoquiver output + # if report_tunnelling = True, the first number will be the inverted parabola KIE + # and the second number will be the tunnelling correction + def get_row(self, report_tunnelling=False): + title_row = "" + row = "" + keys = list(self.KIES.keys()) + + # don't report the reference isotopologue (the mass-override one is + # already excluded from KIES upstream) + if self.config.reference_isotopologue in keys: + keys.remove(self.config.reference_isotopologue) + + # for each isotopologue, add the KIEs + for name in keys: + if report_tunnelling: + title_row += "%s,%s," % (name + "_uncorr", name + "_inf_para") + else: + title_row += "{0},".format(name) + + if self.eie_flag == 0: + # this is a KIE calculation + if report_tunnelling: + row += "%.4f,%.4f," % ( self.KIES[name].value[-1], self.KIES[name].value[-1]-self.KIES[name].value[-3] ) + else: + row += "{0:.4f},".format(self.KIES[name].value[-1]) + else: + # this is an EIE calculation + row += "{0:.4f},".format(self.KIES[name].value) + + return (title_row, row, self.eie_flag) + + @property + def results(self): + """Structured, tabular view of the results (see pyquiver.results).""" + return Results(self) + + def to_dict(self): + """Map isotopologue name -> result value(s).""" + return self.results.to_dict() + + def to_csv(self, path=None): + """Render the results as CSV text (and optionally write to ``path``).""" + return self.results.to_csv(path) + + def to_dataframe(self): + """Return the results as a pandas DataFrame (optional pandas extra).""" + return self.results.to_dataframe() + + def skodje_truhlar(self, reactant_energy, product_energy, ts_energy, + unit="hartree"): + """Skodje-Truhlar tunnelling-corrected KIEs, one per isotopologue. + + Supply the single-point electronic energies of the reactant, product, + and transition state (in hartree by default, or joules with + ``unit='J'``); the barrier is V_ts - max(V_reactant, V_product). Each + isotopologue's transition-state imaginary frequencies are taken from the + calculation. Non-reference isotopologues are referenced exactly as in + the printed table; the reference (if any) is returned as its absolute + corrected KIE. Returns ``{name: corrected_kie}``. + """ + from .tunneling import skodje_truhlar as _st_ratio + + if self.eie_flag != 0: + raise ValueError("Skodje-Truhlar correction applies to KIE (not " + "EIE) calculations") + if unit == "hartree": + scale = PHYSICAL_CONSTANTS["Eh"] + elif unit in ("J", "joule", "joules"): + scale = 1.0 + else: + raise ValueError("unit must be 'hartree' or 'J'") + barrier = (ts_energy - max(reactant_energy, product_energy)) * scale + + def _imag(kie): + light = kie.ts_tuple[0].calculate_frequencies( + self.config.imag_threshold, scaling=self.config.scaling)[1] + heavy = kie.ts_tuple[1].calculate_frequencies( + self.config.imag_threshold, scaling=self.config.scaling)[1] + return min(heavy), min(light) + + ref = self.config.reference_isotopologue + has_ref = ref not in ("default", "none") + if has_ref: + h_heavy, h_light = _imag(self.KIES[ref]) + ref_ratio = _st_ratio(h_heavy, h_light, self.config.temperature, barrier) + else: + ref_ratio = 1.0 + + corrected = OrderedDict() + for name, kie in self.KIES.items(): + heavy, light = _imag(kie) + ratio = _st_ratio(heavy, light, self.config.temperature, barrier) + if name == ref and has_ref: + corrected[name] = float(kie.value[0]) * ratio # absolute + else: + corrected[name] = float(kie.value[0]) * ratio / ref_ratio + return corrected + + def build_mass_override_masses(self): + config = self.config + gs_system = self.gs_system + ts_system = self.ts_system + + gs_masses = self.build_default_masses(gs_system) + ts_masses = self.build_default_masses(ts_system) + + if config.mass_override_isotopologue != "default": + iso = self.config.isotopologues[config.mass_override_isotopologue] + gs_rules, ts_rules = self.compile_mass_rules(iso) + + gs_masses = self.apply_mass_rules(gs_masses, gs_rules) + ts_masses = self.apply_mass_rules(ts_masses, ts_rules) + + return gs_masses, ts_masses + + def build_default_masses(self, system): + masses = [] + for i in range(system.number_of_atoms): + if not system.atomic_numbers[i] in DEFAULT_MASSES: + raise ValueError("Default mass not available for atomic number %d at atom number %d in %s!" % (system.atomic_numbers[i], i+1, system.filename)) + masses.append(DEFAULT_MASSES[system.atomic_numbers[i]]) + return np.array(masses) + + def apply_mass_rules(self, prev_masses, rules): + masses = list(prev_masses) + for k,v in rules.items(): + masses[k] = v + return masses + + def compile_mass_rules(self, iso_rules): + gs_rules = {} + ts_rules = {} + for replacement in iso_rules: + gs_atom_number, ts_atom_number, replacement_label = replacement + mass = replacement_mass(replacement_label) + gs_rules[gs_atom_number-1] = mass + ts_rules[ts_atom_number-1] = mass + return gs_rules, ts_rules + + # make the requested isotopic substitutions + # yields tuples of tuples of the form ((gs_sub, gs_ref), (ts_sub, ts_ref)) + def make_isotopologues(self): + config = self.config + gs_system = self.gs_system + ts_system = self.ts_system + config.check(gs_system, ts_system) + + mass_override_gs_masses, mass_override_ts_masses = self.build_mass_override_masses() + + default_gs = quiver.Isotopologue("default", gs_system, mass_override_gs_masses) + default_ts = quiver.Isotopologue("default", ts_system, mass_override_ts_masses) + + for id_,iso in config.isotopologues.items(): + if id_ != config.mass_override_isotopologue: + gs_rules, ts_rules = self.compile_mass_rules(iso) + gs_masses = self.apply_mass_rules(mass_override_gs_masses, gs_rules) + ts_masses = self.apply_mass_rules(mass_override_ts_masses, ts_rules) + sub_gs = quiver.Isotopologue(id_, gs_system, gs_masses) + sub_ts = quiver.Isotopologue(id_, ts_system, ts_masses) + yield ((default_gs, sub_gs), (default_ts, sub_ts)) + + def __str__(self): + string = "\n=== PyQuiver Analysis ===\n" + if self.eie_flag == 0: + string += "Isotopologue uncorrected Wigner inverted parabola\n" + string += " KIE KIE KIE" + else: + string += "Isotopologue EIE" + keys = list(self.KIES.keys()) + if self.config.reference_isotopologue in keys: + keys.remove(self.config.reference_isotopologue) + for name in keys: + string += "\n" + str(self.KIES[name]) + + if self.config.reference_isotopologue != "default" and self.config.reference_isotopologue != "none": + string += "\n\nKIEs referenced to isotopologue {0}, whose absolute KIEs are:".format(self.config.reference_isotopologue) + string += "\n" + str(self.KIES[self.config.reference_isotopologue]) + else: + string += "\n\nAbsolute KIEs are given." + + return string + +class KIE(object): + # the constructor expects a tuple of the form yielded by make_isotopologue + def __init__(self, name, gs_tuple, ts_tuple, temperature, scaling, imag_threshold): + # copy fields + # the associated calculation object useful for pulling config fields etc. + self.eie_flag = -1 + self.name = name + self.imag_threshold = imag_threshold + self.gs_tuple, self.ts_tuple = gs_tuple, ts_tuple + self.temperature = temperature + self.scaling = scaling + + logger.debug("Calculating KIE for isotopologue %s.", name) + self.value = self.calculate_kie() + + def calculate_kie(self): + logger.debug(" Calculating Reduced Partition Function Ratio for Ground State.") + rpfr_gs, gs_imag_ratios, gs_heavy_freqs, gs_light_freqs = calculate_rpfr(self.gs_tuple, self.imag_threshold, self.scaling, self.temperature) + logger.debug(" rpfr_gs: %s", np.prod(rpfr_gs)) + logger.debug(" Calculating Reduced Partition Function Ratio for Transition State.") + + rpfr_ts, ts_imag_ratios, ts_heavy_freqs, ts_light_freqs = calculate_rpfr(self.ts_tuple, self.imag_threshold, self.scaling, self.temperature) + logger.debug(" rpfr_ts: %s", np.prod(rpfr_ts)) + + # eie_flag is -1 here (set once per KIE in __init__), so the else + # branches below are defensive only + if ts_imag_ratios is not None: + if self.eie_flag == -1: + self.eie_flag = 0 + else: # pragma: no cover + raise ValueError("quiver attempted to run a KIE calculation after an EIE calculation. Check the frequency threshold.") + + kies = ts_imag_ratios * rpfr_gs/rpfr_ts + return kies + else: + if self.eie_flag == -1: + self.eie_flag = 1 + else: # pragma: no cover + raise ValueError("quiver attempted to run a KIE calculation after an EIE calculation. Check the frequency threshold.") + + eie = rpfr_gs/rpfr_ts + return eie + + def apply_reference(self, reference_kie): + self.value /= reference_kie.value + return self.value + + @property + def result(self): + """Named view of this isotopologue's result (KIEResult or EIEResult).""" + if self.eie_flag == 1: + return EIEResult(self.name, float(self.value)) + return KIEResult(self.name, float(self.value[0]), + float(self.value[1]), float(self.value[2])) + + def __str__(self): + if self.value is not None: + if self.eie_flag == 1: + return "Isotopologue {1: >10s} {0: >33s} {2: ^12.4f} ".format("", self.name, self.value) + else: + return "Isotopologue {1: >10s} {0: >33s} {2: ^12.4f} {3: ^14.4f} {4: ^17.4f}".format("", self.name, self.value[0], self.value[1], self.value[2]) + else: # pragma: no cover - value is always set in __init__ + return "KIE Object for isotopomer {0}. No value has been calculated yet.".format(self.name) + +# utility function to calculate u terms +# u = hv/kT where h = Planck's constant, v = frequency, kB = Boltzmann's constant, T = temperature +# if using v in wavenumber, w +# w = v/c, with c in cm/s +# v = cw +# u = hcw/kT, with c in cm/s +def u(wavenumber, temperature): + return h*c*wavenumber/(kB*temperature) + +# calculates the reduced isotopic function ratio for a species (Wolfsberg eqn 4.79) +# assuming the symmetry ratio is 1/1 +# uses the Teller-Redlich product rule +# returns 1 x n array of the partition function ratios, where n is the number of frequencies +# frequencies below frequency_threshold will be ignored and will not be included in the array +def partition_components(freqs_heavy, freqs_light, temperature): + # vectorized over all modes at once; u() is elementwise so numpy arrays + # flow straight through (Wolfsberg eqn 4.79, one row per frequency) + freqs_light = np.asarray(freqs_light, dtype=float) + freqs_heavy = np.asarray(freqs_heavy, dtype=float) + + product_factor = freqs_heavy / freqs_light + u_light = u(freqs_light, temperature) + u_heavy = u(freqs_heavy, temperature) + excitation_factor = (1.0 - np.exp(-u_light)) / (1.0 - np.exp(-u_heavy)) + ZPE_factor = np.exp(0.5 * (u_light - u_heavy)) + + components = np.column_stack([product_factor, excitation_factor, ZPE_factor]) + + if logger.isEnabledFor(logging.DEBUG): + overall = product_factor * excitation_factor * ZPE_factor + for i in range(len(freqs_light)): + logger.debug("MODE %3d LIGHT: %9.3f cm-1 HEAVY: %9.3f cm-1 FRQ RATIO: %9.5f ZPE FACTOR: %9.5f CONTRB TO RIPF: %9.5f", + i+1, freqs_light[i], freqs_heavy[i], product_factor[i], ZPE_factor[i], overall[i]) + + return components + +# tup is a tuple of a form (light_isotopologue, heavy_isotopologue) +def calculate_rpfr(tup, imag_threshold, scaling, temperature): + # calculate_frequencies gives tuples of the form (small_freqs, imaginary_freqs, freqs) + light_small_freqs, light_imag_freqs, light_freqs, light_num_small = tup[0].calculate_frequencies(imag_threshold, scaling=scaling) + heavy_small_freqs, heavy_imag_freqs, heavy_freqs, heavy_num_small = tup[1].calculate_frequencies(imag_threshold, scaling=scaling) + + if len(heavy_freqs) != len(light_freqs): # pragma: no cover - defensive + raise ValueError("mismatch in the number of frequencies between isotopomers!") + if len(light_imag_freqs) != len(heavy_imag_freqs): + logger.warning("mismatch in the number of imaginary frequencies between isotopomers, ignoring imaginary mode") + light_imag_freqs = [] + heavy_imag_freqs = [] + + if logger.isEnabledFor(logging.DEBUG): + def _fmt(values, spec="%.3f"): + return " ".join(spec % v for v in values) if len(values) else "none" + logger.debug("light imaginary frequencies: %s", _fmt(light_imag_freqs)) + logger.debug("light small frequencies: %s", _fmt(light_small_freqs, "%.1f")) + logger.debug("heavy imaginary frequencies: %s", _fmt(heavy_imag_freqs)) + logger.debug("heavy small frequencies: %s", _fmt(heavy_small_freqs, "%.1f")) + + raw_imag_ratio = None + imag_ratios = None + try: + raw_imag_ratio = light_imag_freqs[0]/heavy_imag_freqs[0] + except IndexError: + pass + + if raw_imag_ratio is not None: + wigner_imag_ratio = raw_imag_ratio * wigner(heavy_imag_freqs[0], light_imag_freqs[0], temperature) + bell_imag_ratio = raw_imag_ratio * bell(heavy_imag_freqs[0], light_imag_freqs[0], temperature) + imag_ratios = np.array([raw_imag_ratio, wigner_imag_ratio, bell_imag_ratio]) + + partition_factors = partition_components(heavy_freqs, light_freqs, temperature) + + if logger.isEnabledFor(logging.DEBUG): + factors = np.prod(partition_factors, axis=0) + logger.debug("Product Factor: %s Excitation Factor: %s ZPE Factor: %s", + factors[0], factors[1], factors[2]) + + return (np.prod(partition_factors), imag_ratios, np.array(heavy_freqs), np.array(light_freqs)) + +# calculates the Wigner tunnelling correction +# multiplies the KIE by a factor of (1+u_H^2/24)/(1+u_D^2/24) +# assumes the frequencies are sorted in ascending order +def wigner(ts_imag_heavy, ts_imag_light, temperature): + # calculate correction factor + if ts_imag_heavy < 0.0 and ts_imag_light < 0.0: + u_H = u(ts_imag_light, temperature) + u_D = u(ts_imag_heavy, temperature) + correction_factor = (1.0 + u_H**2 / 24.0) / (1.0 + u_D**2 / 24.0) + else: + raise ValueError("imaginary frequency passed to Wigner correction was real") + + return correction_factor + +# calculates the Bell inverted parabola tunneling correction +# multiplies the KIE by a factor of (u_H/u_D)*(sin(u_D/2)/sin(u_H/2)) +# assumes the frequencies are sorted in ascending order +def bell(ts_imag_heavy, ts_imag_light, temperature): + # calculate correction factor + if ts_imag_heavy < 0.0 and ts_imag_light < 0.0: + u_H = u(ts_imag_light, temperature) + u_D = u(ts_imag_heavy, temperature) + correction_factor = (u_H/u_D)*(np.sin(u_D/2.0)/np.sin(u_H/2.0)) + else: + raise ValueError("imaginary frequency passed to Bell correction was real") + return correction_factor diff --git a/pyquiver/orca.py b/pyquiver/orca.py new file mode 100644 index 0000000..fc0f7c3 --- /dev/null +++ b/pyquiver/orca.py @@ -0,0 +1,9 @@ +"""Backward-compatibility re-export. + +The ORCA parser now lives in :mod:`pyquiver.parsers.orca`. This module keeps +``pyquiver.orca.parse_orca_output`` importable for older code. +""" + +from .parsers.orca import parse_orca_output, parse + +__all__ = ["parse_orca_output", "parse"] diff --git a/pyquiver/parsers/__init__.py b/pyquiver/parsers/__init__.py new file mode 100644 index 0000000..38386ca --- /dev/null +++ b/pyquiver/parsers/__init__.py @@ -0,0 +1,43 @@ +"""Input-file parsers and the style registry. + +Each parser exposes ``parse(path) -> ParsedSystem``. Styles are looked up +through ``_ALIASES`` so that, e.g., ``gaussian``, ``g16`` and ``g09`` all map +to the Gaussian parser. ``gaussian`` is the preferred name; ``g09`` is kept as +an alias for backward compatibility. +""" + +from . import gaussian, orca, native +from ._common import ParsedSystem + +__all__ = ["ParsedSystem", "parse", "supported_styles"] + +_PARSERS = { + "gaussian": gaussian.parse, + "orca": orca.parse, + "native": native.parse, +} + +# user-facing style name -> canonical parser key +_ALIASES = { + "gaussian": "gaussian", + "g16": "gaussian", + "g09": "gaussian", # deprecated spelling, still accepted + "orca": "orca", + "native": "native", + "pyquiver": "native", # the .qin format + "qin": "native", +} + + +def supported_styles(): + """Return the sorted list of accepted style names.""" + return sorted(_ALIASES) + + +def parse(path, style): + """Parse ``path`` using the parser registered for ``style``.""" + key = _ALIASES.get(style) + if key is None: + raise ValueError("specified style, {0}, not supported (choose from {1})" + .format(style, ", ".join(supported_styles()))) + return _PARSERS[key](path) diff --git a/pyquiver/parsers/_common.py b/pyquiver/parsers/_common.py new file mode 100644 index 0000000..a256e73 --- /dev/null +++ b/pyquiver/parsers/_common.py @@ -0,0 +1,31 @@ +"""Shared helpers and the common return type for input-file parsers.""" + +from collections import namedtuple + +import numpy as np + +# the normalized result every parser returns: +# atomic_numbers - list[int], one per atom +# positions_angstrom - np.ndarray, shape (n_atoms, 3) +# hessian - np.ndarray, shape (3*n_atoms, 3*n_atoms), in hartree/bohr^2 +ParsedSystem = namedtuple("ParsedSystem", + ["atomic_numbers", "positions_angstrom", "hessian"]) + + +def parse_serial_lower_hessian(fields, number_of_atoms): + """Expand a serialized lower-triangular Hessian into the full matrix. + + ``fields`` is the comma-split lower triangle (row-major). A trailing comma + in the source leaves an empty final field, so blanks are dropped before + converting to float. + """ + fields = [f for f in fields if f.strip() != ""] + + # map every (i, j) of the full grid onto its lower-triangle serial index + range1d = np.arange(3 * number_of_atoms, dtype=int) + xgrid, ygrid = np.meshgrid(range1d, range1d) + agrid = np.minimum(xgrid, ygrid) + bgrid = np.maximum(xgrid, ygrid) + idxs = (bgrid * (bgrid + 1) / 2 + agrid).astype(int) + + return np.array(fields, dtype=float)[idxs] diff --git a/pyquiver/parsers/gaussian.py b/pyquiver/parsers/gaussian.py new file mode 100644 index 0000000..1374196 --- /dev/null +++ b/pyquiver/parsers/gaussian.py @@ -0,0 +1,108 @@ +"""Parser for Gaussian output files (g09 / g16).""" + +import os +import re +import logging + +import numpy as np + +from ._common import ParsedSystem, parse_serial_lower_hessian + +logger = logging.getLogger("pyquiver") + + +def _tail(filename): + """Return the last line of a file (used to check normal termination).""" + # https://stackoverflow.com/questions/46258499 + with open(filename, 'rb') as f: + try: # catch OSError in case of a one-line file + f.seek(-2, os.SEEK_END) + while f.read(1) != b'\n': + f.seek(-2, os.SEEK_CUR) + except OSError: + f.seek(0) + return f.readline().decode() + + +def _valid_geom_line(split_line): + if len(split_line) == 6: + try: + int(split_line[0]); int(split_line[1]); int(split_line[2]) + return True + except ValueError: + return False + return False + + +def _parse_hessian(data, filename): + # the force constant matrix lives in the archive between 1\1\GINC and \@ + raw_archive = re.findall(r"1\\1\\GINC(.+?)\\(\s*)@", data, re.DOTALL) + archive = None + for candidate in raw_archive: + squashed = re.sub(r'[\s+]', '', candidate[0]) + archive = re.search(r"NImag\=(.+?)$", squashed, re.DOTALL) + if archive: + break + if not archive: + raise ValueError("No frequency job detected in %s." % filename) + + return archive.group(0).split('\\')[2].split(',') + + +def parse(path): + """Parse a Gaussian output file into a :class:`ParsedSystem`.""" + # require a normally terminated job (snippet files are exempt) + if not path.endswith(".snip") and "Normal termination" not in _tail(path): + raise ValueError("Gaussian job %s terminated in an error" % path) + + with open(path, 'r') as f: + data = f.read() + + # the verbose route card (#p) is required for the archive to be present + if re.search(r" *#[pP] ", data) is None and not path.lower().endswith(".snip"): + raise ValueError( + "Gaussian output file %s was not run with the verbose flag, so it " + "does not contain enough information for PyQuiver to run. Please " + "re-run this calculation with a route card that starts with #p" + % path) + + m = re.search(r"NAtoms\= +([0-9]+)", data) + if not m: + raise ValueError("Number of atoms not detected.") + number_of_atoms = int(m.group(1)) + + atomic_numbers = [0] * number_of_atoms + positions = np.zeros((number_of_atoms, 3)) + + # prefer standard orientation; fall back to input orientation (nosymm) + m = None + for m in re.finditer(r"Standard orientation(.+?)Rotational constants \(GHZ\)", + data, re.DOTALL): + pass + if m is None: + for m in re.finditer(r"Input orientation(.+?)Distance matrix", + data, re.DOTALL): + pass + if m is not None: + logger.info("Couldn't find standard orientation so used input orientation instead.") + if m is None: + for m in re.finditer(r"Input orientation(.+?)Rotational constants \(GHZ\)", + data, re.DOTALL): + pass + if m is not None: + logger.info("Couldn't find standard orientation so used input orientation instead.") + if m is None: + raise ValueError("Geometry table not detected.") + + for line in m.group(1).split('\n'): + fields = [x for x in line.split(' ') if x] + if _valid_geom_line(fields): + center = int(fields[0]) - 1 + atomic_numbers[center] = int(fields[1]) + for e in range(3): + positions[center][e] = fields[3 + e] + + raw_fcm = _parse_hessian(data, path) + hessian = parse_serial_lower_hessian(raw_fcm, number_of_atoms) + + return ParsedSystem(atomic_numbers, positions, hessian) diff --git a/pyquiver/parsers/native.py b/pyquiver/parsers/native.py new file mode 100644 index 0000000..99e5760 --- /dev/null +++ b/pyquiver/parsers/native.py @@ -0,0 +1,58 @@ +"""Parser and serializer for PyQuiver's native ``.qin`` format. + +The format is:: + + +
,,,, (one line per atom, angstroms) + +""" + +import numpy as np + +from ._common import ParsedSystem, parse_serial_lower_hessian + + +def parse(path): + """Parse a ``.qin`` file into a :class:`ParsedSystem`.""" + with open(path, 'r') as f: + lines = f.read().split("\n") + + try: + number_of_atoms = int(lines[0]) + except ValueError: + raise ValueError("first line must contain integer number of atoms.") + + atomic_numbers = [0] * number_of_atoms + positions = np.zeros((number_of_atoms, 3)) + for line in lines[1:number_of_atoms + 1]: + fields = line.split(',') + try: + center_number, atomic_number, x, y, z = fields + except ValueError: + raise ValueError("the following line in the geometry did not have " + "the appropriate number of fields: {0}".format(line)) + center = int(center_number) + atomic_numbers[center] = int(atomic_number) + positions[center][0] = x + positions[center][1] = y + positions[center][2] = z + + fcm_fields = lines[number_of_atoms + 1].split(',') + hessian = parse_serial_lower_hessian(fcm_fields, number_of_atoms) + + return ParsedSystem(atomic_numbers, positions, hessian) + + +def serialize(system): + """Serialize a System to native ``.qin`` text.""" + serial = "%d\n" % system.number_of_atoms + for i in range(system.number_of_atoms): + serial += "{0},{1},{2},{3},{4}\n".format( + i, system.atomic_numbers[i], + system.positions_angstrom[i, 0], + system.positions_angstrom[i, 1], + system.positions_angstrom[i, 2]) + for i in range(system.number_of_atoms * 3): + for j in range(0, i + 1): + serial += str(system.hessian[i, j]) + "," + return serial diff --git a/pyquiver/parsers/orca.py b/pyquiver/parsers/orca.py new file mode 100644 index 0000000..0d8a1e8 --- /dev/null +++ b/pyquiver/parsers/orca.py @@ -0,0 +1,70 @@ +"""Parser for ORCA .hess files.""" + +import re +import logging +from io import StringIO + +import numpy as np + +from ..constants import SYMBOL_TO_Z, PHYSICAL_CONSTANTS +from ._common import ParsedSystem + +logger = logging.getLogger("pyquiver") + +_BOHR_TO_ANGSTROM = PHYSICAL_CONSTANTS["atb"] + + +def parse_orca_output(out_data): + """Parse the text of an ORCA .hess file. + + Returns ``(atomic_numbers, positions_angstrom, hessian)``. + """ + lines = out_data.split('\n') + + # geometry + try: + atoms_line = next(n for n, line in enumerate(lines) + if line.startswith('$atoms')) + except StopIteration: + raise ValueError("Could not find '$atoms' in output data.") + + number_of_atoms = int(lines[atoms_line + 1]) + + atomic_numbers = [] + positions = np.zeros((number_of_atoms, 3)) + for i, line in enumerate(lines[atoms_line + 2:atoms_line + number_of_atoms + 2]): + _, atom, _, x, y, z = re.split(r'\s+', line) + atomic_numbers.append(SYMBOL_TO_Z[atom]) + positions[i][0] = float(x) * _BOHR_TO_ANGSTROM + positions[i][1] = float(y) * _BOHR_TO_ANGSTROM + positions[i][2] = float(z) * _BOHR_TO_ANGSTROM + + # hessian + try: + hessian_line = next(n for n, line in enumerate(lines) + if line.startswith('$hessian')) + except StopIteration: + raise ValueError("Could not find '$hessian' in output data.") + + hessian_size = int(lines[hessian_line + 1]) + hessian = None + for i, batch in enumerate(range(0, hessian_size, 5)): + batch_size = min(5, hessian_size - batch) + s = StringIO(u"\n".join( + lines[hessian_line + (i * (hessian_size + 1) + 2): + hessian_line + ((i + 1) * (hessian_size + 1)) + 2])) + h = np.loadtxt(s, skiprows=1, + converters=dict((j + 1, float) for j in range(batch_size)), + usecols=tuple(j + 1 for j in range(batch_size)), + ndmin=2) + hessian = h if hessian is None else np.hstack((hessian, h)) + + hessian = (hessian + hessian.T) / 2 + return atomic_numbers, positions, hessian + + +def parse(path): + """Parse an ORCA .hess file at ``path`` into a :class:`ParsedSystem`.""" + with open(path, 'r') as f: + atomic_numbers, positions, hessian = parse_orca_output(f.read()) + return ParsedSystem(atomic_numbers, positions, hessian) diff --git a/pyquiver/quiver.py b/pyquiver/quiver.py new file mode 100644 index 0000000..04e8cda --- /dev/null +++ b/pyquiver/quiver.py @@ -0,0 +1,128 @@ +import os +import logging + +import numpy as np + +from .constants import PHYSICAL_CONSTANTS, LINEARITY_THRESHOLD, DROP_NUM_LINEAR +from . import parsers +from .parsers import native + +logger = logging.getLogger("pyquiver") + +# represents a geometric arrangement of atoms with specific masses +class Isotopologue(object): + def __init__(self, id_, system, masses): + self.name = id_ + self.system = system + self.masses = masses + self.frequencies = None + + self.number_of_atoms = system.number_of_atoms + self.mw_hessian = self.calculate_mw_hessian() + + def __str__(self): + returnString = "Isotopologue: %s\n" % self.name + returnString += " masses: %s" % self.masses.__str__() + return returnString + + def calculate_mw_hessian(self): + # mass-weight the Hessian: H'_ij = H_ij / sqrt(m_i m_j), where each + # atom's mass is repeated across its three Cartesian coordinates + masses_ij = np.outer(np.array(self.masses), np.array(self.masses)) + inv_sqrt_masses = 1 / np.sqrt(masses_ij) + inv_sqrt_masses = np.repeat(inv_sqrt_masses, 3, 0) + inv_sqrt_masses = np.repeat(inv_sqrt_masses, 3, 1) + return self.system.hessian * inv_sqrt_masses + + def calculate_frequencies(self, imag_threshold, scaling=1.0, method="mass weighted hessian"): + # short circuit if frequencies have already been calculated + if self.frequencies is not None: + return self.frequencies + + if method == "mass weighted hessian": + imaginary_freqs = [] + small_freqs = [] + conv_factor = PHYSICAL_CONSTANTS['Eh']/(PHYSICAL_CONSTANTS['a0']**2 * PHYSICAL_CONSTANTS['amu']) + + v = np.linalg.eigvalsh(self.mw_hessian*conv_factor) + + constant = scaling / (2*np.pi*PHYSICAL_CONSTANTS['c']) + freqs = np.sqrt(np.abs(v)) * np.sign(v) * constant + + #freqs2 = [ np.copysign(np.sqrt(np.abs(freq)),freq) * constant for freq in v ] + #assert np.allclose(np.array(freqs), freqs2) + + freqs.sort() + + imaginary_freqs = [] + small_freqs = [] + regular_freqs = [] + + # detect imaginary frequencies + for f in freqs: + if f < -imag_threshold: + imaginary_freqs.append(f) + + if len(imaginary_freqs) > 1: + logger.warning("multiple imaginary frequencies detected") + + # strip the imaginary frequencies + freqs = freqs[len(imaginary_freqs):] + + if self.system.is_linear: + small_freqs = freqs[:DROP_NUM_LINEAR] + regular_freqs = freqs[DROP_NUM_LINEAR:] + else: + small_freqs = freqs[:1+DROP_NUM_LINEAR] + regular_freqs = freqs[1+DROP_NUM_LINEAR:] + + # bugfix 2/6/20: third argument is regular_freqs, not freqs! + self.frequencies = (small_freqs, imaginary_freqs, np.array(regular_freqs), len(small_freqs)) + return self.frequencies + else: + raise ValueError("unknown frequency calculation type") + + +class System(object): + # represents a molecule's geometry and Cartesian Hessian, read from an + # electronic-structure output file via the parsers package + def __init__(self, outfile, style="gaussian"): + self.filename = outfile + self.is_linear = True + + logger.info("Reading data from %s with style %s", outfile, style) + + parsed = parsers.parse(outfile, style) + self.atomic_numbers = parsed.atomic_numbers + self.number_of_atoms = len(parsed.atomic_numbers) + self.hessian = parsed.hessian + self.positions_angstrom = parsed.positions_angstrom + # geometry in bohr, as needed for the mass-weighted analysis + self.positions = parsed.positions_angstrom / PHYSICAL_CONSTANTS['atb'] + + self._detect_linear() + + def _detect_linear(self): + # take every pair of bonds sharing atom 0; if any pair is sufficiently + # non-parallel the molecule is non-linear + positions = self.positions_angstrom + if self.number_of_atoms > 2: + for i in range(1, len(positions) - 1): + for j in range(i + 1, len(positions)): + diff0i = positions[0] - positions[i] + diff0j = positions[0] - positions[j] + u = diff0i / np.linalg.norm(diff0i) + v = diff0j / np.linalg.norm(diff0j) + if 1 - np.abs(np.inner(u, v)) > LINEARITY_THRESHOLD: + self.is_linear = False + break + if not self.is_linear: + break + logger.debug("Molecule is %s.", "linear" if self.is_linear else "not linear") + + def dump_pyquiver_input_file(self, extension=".qin"): + path = os.path.splitext(self.filename)[0] + extension + serial = native.serialize(self) + with open(path, 'w') as f: + f.write(serial) + return serial diff --git a/pyquiver/results.py b/pyquiver/results.py new file mode 100644 index 0000000..913ed64 --- /dev/null +++ b/pyquiver/results.py @@ -0,0 +1,76 @@ +"""Structured result objects for KIE/EIE calculations. + +These give named access to results instead of positional indexing into +``KIE.value``, and convert a whole calculation to a dict, CSV, or pandas +DataFrame. pandas is optional: ``to_dict``/``to_csv`` are dependency-free, +``to_dataframe`` requires ``pandas`` (``pip install pyquiver-kie[pandas]``). +""" + +from collections import namedtuple + +# named view of a kinetic isotope effect (the three reported columns) +KIEResult = namedtuple("KIEResult", + ["name", "uncorrected", "wigner", "inverted_parabola"]) + +# named view of an equilibrium isotope effect +EIEResult = namedtuple("EIEResult", ["name", "value"]) + + +class Results(object): + """Ordered, tabular view of a KIE_Calculation's isotopologue results.""" + + def __init__(self, calc): + self.is_eie = (calc.eie_flag == 1) + self.reference_isotopologue = calc.config.reference_isotopologue + self._items = [k.result for k in calc.KIES.values()] + + def __iter__(self): + return iter(self._items) + + def __len__(self): + return len(self._items) + + def __getitem__(self, name): + for r in self._items: + if r.name == name: + return r + raise KeyError(name) + + @property + def columns(self): + return ["name", "value"] if self.is_eie else \ + ["name", "uncorrected", "wigner", "inverted_parabola"] + + def to_records(self): + """Return a list of dicts, one per isotopologue.""" + return [r._asdict() for r in self._items] + + def to_dict(self): + """Map isotopologue name -> value (EIE) or column dict (KIE).""" + if self.is_eie: + return {r.name: r.value for r in self._items} + return {r.name: {"uncorrected": r.uncorrected, + "wigner": r.wigner, + "inverted_parabola": r.inverted_parabola} + for r in self._items} + + def to_dataframe(self): + """Return a pandas DataFrame (requires the optional pandas extra).""" + try: + import pandas as pd + except ImportError: + raise ImportError("to_dataframe requires pandas; install with " + "`pip install pyquiver-kie[pandas]`") + return pd.DataFrame(self.to_records(), columns=self.columns) + + def to_csv(self, path=None, float_format="%.4f"): + """Render results as CSV text; write to ``path`` if given, else return.""" + lines = [",".join(self.columns)] + for r in self._items: + cells = [r.name] + [float_format % v for v in tuple(r)[1:]] + lines.append(",".join(cells)) + text = "\n".join(lines) + "\n" + if path is not None: + with open(path, "w") as f: + f.write(text) + return text diff --git a/pyquiver/settings.py b/pyquiver/settings.py new file mode 100644 index 0000000..4e00400 --- /dev/null +++ b/pyquiver/settings.py @@ -0,0 +1,13 @@ +"""Deprecated. PyQuiver now uses the standard ``logging`` module instead of a +global DEBUG level. + +To see diagnostics, configure logging in your own code, e.g.:: + + import logging + logging.basicConfig(level=logging.INFO) # or DEBUG for more detail + +``DEBUG`` is kept only so that old code importing it does not break; it no +longer controls any output. +""" + +DEBUG = 0 diff --git a/pyquiver/tunneling.py b/pyquiver/tunneling.py new file mode 100644 index 0000000..cc6e8eb --- /dev/null +++ b/pyquiver/tunneling.py @@ -0,0 +1,59 @@ +"""Tunnelling corrections for transition-state imaginary modes. + +This module gathers the three corrections PyQuiver supports: + +* :func:`wigner` and :func:`bell` (re-exported from :mod:`pyquiver.kie`) need + only the imaginary frequencies and the temperature. +* :func:`skodje_truhlar` additionally needs the reaction barrier height + (J. Phys. Chem. 1981, 85, 624-626). It is more appropriate than the inverted + parabola for very large imaginary frequencies. + +All three take the imaginary frequencies as negative wavenumbers (cm^-1), the +convention PyQuiver uses internally, and return the *ratio* of the light to the +heavy correction (i.e. the factor a KIE is multiplied by). +""" + +import numpy as np + +from .constants import PHYSICAL_CONSTANTS +from .kie import wigner, bell # re-export so all corrections share one namespace + +h = PHYSICAL_CONSTANTS["h"] # J s +c = PHYSICAL_CONSTANTS["c"] # cm / s +kB = PHYSICAL_CONSTANTS["kB"] # J / K +Eh = PHYSICAL_CONSTANTS["Eh"] # J / hartree + +__all__ = ["wigner", "bell", "skodje_truhlar", "skodje_truhlar_kappa"] + + +def skodje_truhlar_kappa(imag_wavenumber, temperature, barrier): + """Skodje-Truhlar transmission coefficient for a single imaginary mode. + + ``barrier`` is the barrier height in joules (V = V_ts - max(V_reactant, + V_product)). ``imag_wavenumber`` is the imaginary mode in cm^-1 (sign is + ignored; its magnitude is the natural frequency). + """ + imag_hz = np.abs(imag_wavenumber * c) + alpha = 2.0 * np.pi / (h * imag_hz) + beta = 1.0 / (kB * temperature) + bpa = beta * np.pi / alpha + + if alpha > beta: + return bpa / np.sin(bpa) - beta / (alpha - beta) * np.exp((beta - alpha) * barrier) + elif alpha < beta: + return beta / (beta - alpha) * (np.exp((beta - alpha) * barrier) - 1.0) + else: # pragma: no cover - alpha == beta is a measure-zero edge case + return bpa / np.sin(bpa) + + +def skodje_truhlar(ts_imag_heavy, ts_imag_light, temperature, barrier): + """Skodje-Truhlar tunnelling correction: kappa_light / kappa_heavy. + + ``ts_imag_heavy``/``ts_imag_light`` are the heavy/light transition-state + imaginary frequencies (negative cm^-1); ``barrier`` is in joules. + """ + if not (ts_imag_heavy < 0.0 and ts_imag_light < 0.0): + raise ValueError("imaginary frequency passed to Skodje-Truhlar correction was real") + light = skodje_truhlar_kappa(ts_imag_light, temperature, barrier) + heavy = skodje_truhlar_kappa(ts_imag_heavy, temperature, barrier) + return light / heavy diff --git a/src/weights.dat b/pyquiver/weights.dat similarity index 100% rename from src/weights.dat rename to pyquiver/weights.dat diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..55151b0 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = test +python_files = test_*.py +python_functions = test_* +addopts = -ra diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index 97f31eb..afb07bf 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -1,689 +1,281 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# *PyQuiver*\n", - "This is an IPython Notebook interface for the *PyQuiver* package. The code below will guide you through using *PyQuiver* through a native Python interface. The same steps could be reproduced in the Python interpreter or in a `.py` file." - ] - }, { "cell_type": "code", - "execution_count": 1, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Read atomic weight data for 32 elements.\n" - ] - } - ], - "source": [ - "# import the necessary package elements\n", - "import numpy as np\n", - "import sys\n", - "sys.path.append(\"../src\")\n", - "from kie import KIE_Calculation" - ] - }, - { - "cell_type": "markdown", "metadata": {}, "source": [ - "## A Simple Calculation\n", + "import numpy as np\n", + "from IPython.display import display\n", "\n", - "We can reproduce the command-line interface by creating a KIE_Calculation object:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Reading configuration from ../tutorial/gaussian/claisen_demo.config\n", - "Reading data from ../tutorial/gaussian/claisen_gs.out... with style g09\n", - "Molecule is not linear.\n", - "Reading data from ../tutorial/gaussian/claisen_ts.out... with style g09\n", - "Molecule is not linear.\n", - "Config file: ../tutorial/gaussian/claisen_demo.config\n", - "Temperature: 393.0 K\n", - "Scaling: 0.961\n", - "Reference Isotopologue: C5\n", - "Imag threshold (cm-1): 50\n", - " Isotopologue C5, replacement 1: replace gs atom 5 and ts atom 5 with 13C\n", - " Isotopologue C1, replacement 1: replace gs atom 1 and ts atom 1 with 13C\n", - " Isotopologue C2, replacement 1: replace gs atom 2 and ts atom 2 with 13C\n", - " Isotopologue C4, replacement 1: replace gs atom 4 and ts atom 4 with 13C\n", - " Isotopologue C6, replacement 1: replace gs atom 6 and ts atom 6 with 13C\n", - " Isotopologue H/D, replacement 1: replace gs atom 7 and ts atom 7 with 2D\n", - " Isotopologue H/D, replacement 2: replace gs atom 8 and ts atom 8 with 2D\n", - " Isotopologue O3, replacement 1: replace gs atom 3 and ts atom 3 with 17O\n" - ] - } + "from pyquiver import KIE_Calculation, System, Config, Isotopologue, batch, parsers\n", + "from pyquiver.constants import DEFAULT_MASSES, REPLACEMENTS\n", + "from pyquiver.kie import calculate_rpfr, partition_components" ], - "source": [ - "claisen_calculation = KIE_Calculation(\"../tutorial/gaussian/claisen_demo.config\", \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\", style=\"g09\")" - ] + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "To print the KIEs:" + "## Supported input files\n", + "\n", + "PyQuiver reads Cartesian Hessians from several electronic-structure programs. Pass the matching `style` (the first name; the rest are aliases):\n", + "\n", + "| Program | `style` | files |\n", + "|---------|---------|-------|\n", + "| Gaussian | `gaussian` (`g16`, `g09`) | `.out` |\n", + "| ORCA | `orca` | `.hess` |\n", + "| PyQuiver native | `pyquiver` (`native`, `qin`) | `.qin` |" ] }, { "cell_type": "code", - "execution_count": 3, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The claisen_calculation object:\n", - "\n", - "=== PyQuiver Analysis ===\n", - "Isotopologue uncorrected Wigner inverted parabola\n", - " KIE KIE KIE\n", - "Isotopologue C1 1.0108 1.0125 1.0128 \n", - "Isotopologue C2 1.0000 1.0000 1.0000 \n", - "Isotopologue O3 1.0169 1.0184 1.0187 \n", - "Isotopologue C4 1.0278 1.0305 1.0310 \n", - "Isotopologue C6 1.0129 1.0146 1.0149 \n", - "Isotopologue H/D 0.9530 0.9545 0.9547 \n", - "\n", - "KIEs referenced to isotopologue C5, whose absolute KIEs are:\n", - "Isotopologue C5 1.0019 1.0019 1.0019 \n" - ] - } - ], - "source": [ - "print(\"The claisen_calculation object:\")\n", - "print(claisen_calculation)" - ] - }, - { - "cell_type": "markdown", "metadata": {}, "source": [ - "We can also access the KIEs directly via the `KIE` dictionary belonging to our `KIE_Calculation`. This can be useful for automating KIE analyses over a large number of files. This functionality is further developed in the `autoquiver` routine (see below)." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Iterating through the KIEs dictionary:\n", - "Isotopologue C1 1.0108 1.0125 1.0128 \n", - "\n", - "C1\n", - "[1.01083044 1.0124856 1.01277832]\n" - ] - } + "parsers.supported_styles()" ], - "source": [ - "print(\"Iterating through the KIEs dictionary:\")\n", - "for name,kie_object in claisen_calculation.KIES.items():\n", - " # pretty print the underlying KIE object:\n", - " print(kie_object)\n", - " # or pull the name and value directly:\n", - " print(type(kie_object))\n", - " print(kie_object.name)\n", - " print(kie_object.value)\n", - " break" - ] + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## `System` objects\n", + "## A simple calculation\n", "\n", - "We didn't have to specify file paths as the targets of our `KIE_Calculation` constructor. Instead, we can work directly with `System` objects, which contain the geometry and Hessian as fields. Below, we load the Claisen ground state and transition state and print the position of the first atom in the ground state and print the first row of the transition state Hessian." + "Build the configuration in Python with `Config.from_dict`, then run it on a ground-state and a transition-state file. Each substitution is `(ground_state_atom, transition_state_atom, replacement)` with 1-indexed atoms; `replacement` is a `weights.dat` label such as `\"13C\"`, `\"2D\"`, or `\"17O\"` (one isotopologue can make several). (You can also load a `.config` file with `Config(path)`.)" ] }, { "cell_type": "code", - "execution_count": 5, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Reading data from ../tutorial/gaussian/claisen_gs.out... with style g09\n", - "Molecule is not linear.\n", - "Reading data from ../tutorial/gaussian/claisen_ts.out... with style g09\n", - "Molecule is not linear.\n", - "Position of atom 0 in the ground state:\n", - "[ 4.77979210e+00 6.82814755e-01 -3.77945233e-05]\n", - "First row of the Carteisan transition state Hessian:\n", - "[ 2.5705678e-01 -2.3007491e-01 -1.6192179e-01 -7.6561780e-02\n", - " 8.4893130e-02 4.6126500e-03 2.1299140e-02 2.0270870e-02\n", - " -9.6391200e-03 -2.2122500e-02 -1.9531000e-02 2.2995300e-03\n", - " -2.5246920e-02 3.7391980e-02 3.6582000e-04 4.6899040e-02\n", - " -4.4453100e-03 2.2383900e-03 -1.5100128e-01 1.2099792e-01\n", - " 8.6293350e-02 -4.7478530e-02 3.2266700e-03 7.3894740e-02\n", - " 6.5812100e-03 -8.0738400e-03 5.6873800e-03 2.4898000e-04\n", - " -2.9655000e-04 2.0240000e-04 1.2257700e-03 3.9790000e-04\n", - " 4.2782000e-04 7.7369000e-04 2.6580000e-05 1.3262000e-04\n", - " -4.6166200e-03 -1.5256600e-03 -2.7586400e-03 -7.0569600e-03\n", - " -3.2577800e-03 -1.8351500e-03]\n" - ] - } - ], - "source": [ - "from quiver import System\n", - "\n", - "gs = System(\"../tutorial/gaussian/claisen_gs.out\")\n", - "ts = System(\"../tutorial/gaussian/claisen_ts.out\")\n", - "print(\"Position of atom 0 in the ground state:\")\n", - "print(gs.positions[0])\n", - "print(\"First row of the Carteisan transition state Hessian:\")\n", - "print(ts.hessian[0])" - ] - }, - { - "cell_type": "markdown", "metadata": {}, "source": [ - "### Running calculations with `System` objects\n", + "config = Config.from_dict(\n", + " isotopologues={\n", + " \"C1\": [(1, 1, \"13C\")],\n", + " \"C4\": [(4, 4, \"13C\")],\n", + " \"O3\": [(3, 3, \"17O\")],\n", + " \"H/D\": [(7, 7, \"2D\"), (8, 8, \"2D\")],\n", + " },\n", + " temperature=393, scaling=0.9614, imag_threshold=50,\n", + " reference_isotopologue=\"none\",\n", + ")\n", "\n", - "To run a KIE calculation with two `System` objects, we simply pass them into the relevant fields of a `KIE_Calculation` object:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Reading configuration from ../tutorial/gaussian/claisen_demo.config\n", - "Config file: ../tutorial/gaussian/claisen_demo.config\n", - "Temperature: 393.0 K\n", - "Scaling: 0.961\n", - "Reference Isotopologue: C5\n", - "Imag threshold (cm-1): 50\n", - " Isotopologue C5, replacement 1: replace gs atom 5 and ts atom 5 with 13C\n", - " Isotopologue C1, replacement 1: replace gs atom 1 and ts atom 1 with 13C\n", - " Isotopologue C2, replacement 1: replace gs atom 2 and ts atom 2 with 13C\n", - " Isotopologue C4, replacement 1: replace gs atom 4 and ts atom 4 with 13C\n", - " Isotopologue C6, replacement 1: replace gs atom 6 and ts atom 6 with 13C\n", - " Isotopologue H/D, replacement 1: replace gs atom 7 and ts atom 7 with 2D\n", - " Isotopologue H/D, replacement 2: replace gs atom 8 and ts atom 8 with 2D\n", - " Isotopologue O3, replacement 1: replace gs atom 3 and ts atom 3 with 17O\n", - "\n", - "=== PyQuiver Analysis ===\n", - "Isotopologue uncorrected Wigner inverted parabola\n", - " KIE KIE KIE\n", - "Isotopologue C1 1.0108 1.0125 1.0128 \n", - "Isotopologue C2 1.0000 1.0000 1.0000 \n", - "Isotopologue O3 1.0169 1.0184 1.0187 \n", - "Isotopologue C4 1.0278 1.0305 1.0310 \n", - "Isotopologue C6 1.0129 1.0146 1.0149 \n", - "Isotopologue H/D 0.9530 0.9545 0.9547 \n", - "\n", - "KIEs referenced to isotopologue C5, whose absolute KIEs are:\n", - "Isotopologue C5 1.0019 1.0019 1.0019 \n" - ] - } + "calc = KIE_Calculation(config, \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\",\n", + " style=\"gaussian\")\n", + "print(calc)" ], - "source": [ - "claisen_calculation2 = KIE_Calculation(\"../tutorial/gaussian/claisen_demo.config\", gs, ts)\n", - "print(claisen_calculation2)" - ] + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Building `Isotopologue`s\n", - "Once we have access to the underlying `System` objects, it is easy to make substituted `Isotopologue`s and perform frequency calculations ourselves. To make an `Isotopologue` we need to provide a name, a corresponding `System`, and a list of masses - one for each atom in the `System`.\n", + "## Results as data\n", "\n", - "Let's build the default light ground state `Isotopologue`:" + "Look up a single isotopologue by name, or get the whole set as a table." ] }, { "cell_type": "code", - "execution_count": 7, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Isotopologue: the light ground state\n", - " masses: [12.0, 12.0, 15.9949146, 12.0, 12.0, 12.0, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825]\n" - ] - } - ], + "metadata": {}, "source": [ - "from quiver import Isotopologue\n", - "from constants import DEFAULT_MASSES\n", - "\n", - "# we build the default masses by using the DEFAULT_MASSES dictionary which maps from atomic numbers to masses\n", - "# default masses in DEFAULT_MASSES are the average atomic weight of each element\n", - "gs_masses = [DEFAULT_MASSES[z] for z in gs.atomic_numbers]\n", + "c4 = calc.results[\"C4\"]\n", + "print(f\"C4 -- uncorrected: {c4.uncorrected:.4f}, Wigner: {c4.wigner:.4f}, inverted parabola: {c4.inverted_parabola:.4f}\")\n", "\n", - "gs_light = Isotopologue(\"the light ground state\", gs, gs_masses)\n", - "print(gs_light)" - ] + "display(calc.to_dataframe())" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### `Isotopologue`s with substitutions\n", - "Now that we know how to make `Isotopologue`s it's very easy to specify isotopic substitutions. Let's put the mysterious isotope carbon-5000 at atom 4:" + "## Reusing parsed files\n", + "\n", + "A `System` holds a parsed geometry and Hessian. Build the ground and transition states once and reuse them -- for example to scan the temperature -- so each file is read only once." ] }, { "cell_type": "code", - "execution_count": 8, - "metadata": { - "collapsed": false - }, - "outputs": [], + "metadata": {}, "source": [ - "# make a copy of gs_masses\n", - "sub_masses = list(gs_masses)\n", - "# index 3 corresponds to atom number 4\n", - "sub_masses[3] = 5000.0\n", + "gs = System(\"../tutorial/gaussian/claisen_gs.out\")\n", + "ts = System(\"../tutorial/gaussian/claisen_ts.out\")\n", "\n", - "gs_heavy = Isotopologue(\"the super heavy ground state\", gs, sub_masses)" - ] + "for T in (298, 350, 393):\n", + " cfg = Config.from_dict(isotopologues={\"C4\": [(4, 4, \"13C\")]},\n", + " temperature=T, scaling=0.9614, imag_threshold=50)\n", + " print(f\"{T} K: C4 KIE = {KIE_Calculation(cfg, gs, ts).results['C4'].uncorrected:.4f}\")" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Calculating Reduced Isotopic Partition Function Ratios\n", + "## Unusual isotopes\n", "\n", - "KIEs are essentially ratios of reduced isotopic partition functions (RPFRs). To calculate these, we use the function `calculate_rpfr` from the `kie` module. This function takes a tuple of the form `(light, heavy)`, a frequency threshold, a scaling factor, and a temperature. (All of these are discussed in detail in the README.)\n", - "\n", - "`calculate_rpfr` returns four values in a tuple: the first value is the RPFR, the second value is a ratio of large imaginary frequencies (if present), the third value is the frequencies in the light isotopomer, and the fourth value is the frequencies in the heavy isotopomer.\n", - "\n", - "To print the individual contributions to the RPFR, set `quiver.DEBUG = True` in `quiver.py` and restart the kernel. " + "A `replacement` is normally a standard isotope label, but a bare number is used directly as a mass (amu) -- so non-standard isotopes need no `weights.dat` entry. Here we place a (fictitious) mass-5000 nucleus at atom 4:" ] }, { "cell_type": "code", - "execution_count": 9, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "11.208123665562292\n", - "[ 61.631434 131.33940975 149.25478244 235.05384691 253.61000919\n", - " 388.68321807 538.23713827 573.1098416 689.54514791 693.15891096\n", - " 713.75792653 824.87106694 863.15186782 950.01325243 1000.01764911\n", - " 1000.40305289 1034.40340406 1075.63192671 1229.08835092 1266.54977859\n", - " 1298.58236833 1332.35577789 1376.57176191 1456.12070386 1466.42176654\n", - " 1501.64974966 1712.50915119 1738.46586076 2871.66940343 2922.94354739\n", - " 3159.73242676 3181.30165023 3195.17469476 3209.76123403 3263.87810693\n", - " 3278.47225297]\n" - ] - } - ], + "metadata": {}, "source": [ - "from kie import calculate_rpfr\n", - "gs_rpfr, gs_imag_ratio, gs_light_freqs, gs_heavy_freqs = calculate_rpfr((gs_light, gs_heavy), 50.0, 1.0, 273)\n", - "print(gs_rpfr)\n", - "print(gs_light_freqs)" - ] + "cfg = Config.from_dict(isotopologues={\"heavyC4\": [(4, 4, 5000.0)]},\n", + " temperature=393, scaling=0.9614, imag_threshold=50)\n", + "print(KIE_Calculation(cfg, gs, ts).results[\"heavyC4\"])" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Calculating KIEs from Isotopologues\n", - "It's nice to be able to calculate RPFRs by hand, but there is also an object available to calculate KIEs automatically: the `KIE` class.\n", + "## ORCA input\n", "\n", - "We need to create a `KIE` object by passing it a pair of ground states (light and heavy) and a pair of transition states (light and heavy) as well as some temperature information.\n", - "\n", - "We will make the desired substitution at Carbon 4 in the transition state and use a `KIE` object to calculate the KIE." + "ORCA `.hess` files are read with `style=\"orca\"`; nothing else changes." ] }, { "cell_type": "code", - "execution_count": 10, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Isotopologue: the light transition state\n", - " masses: [12.0, 12.0, 15.9949146, 12.0, 12.0, 12.0, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825]\n", - "\n", - "Isotopologue: the heavy transition state\n", - " masses: [12.0, 12.0, 15.9949146, 5000.0, 12.0, 12.0, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825, 1.007825]\n" - ] - } - ], + "metadata": {}, "source": [ - "ts_masses = [DEFAULT_MASSES[z] for z in gs.atomic_numbers]\n", - "ts_light = Isotopologue(\"the light transition state\", ts, ts_masses)\n", - "print(ts_light)\n", - "print()\n", - "ts_sub_masses = list(ts_masses)\n", - "ts_sub_masses[3] = 5000.0\n", - "ts_heavy = Isotopologue(\"the heavy transition state\", ts, ts_sub_masses)\n", - "print(ts_heavy)\n", - "\n", - "gs_tuple = (gs_light, gs_heavy)\n", - "ts_tuple = (ts_light, ts_heavy)\n", - "\n", - "from kie import KIE\n", - "\n", - "# we make a KIE object using the gs and ts tuples from above for a calculation at 273 degrees K, with a scaling factor of 1.0, and a frequency threshold of 50 cm^-1\n", - "carbon5000_kie = KIE(\"Carbon 5000 at C4 KIE\", gs_tuple, ts_tuple, 273, 1.0, 50.0)" - ] + "orca = KIE_Calculation(config, \"../tutorial/orca/claisen_gs_freq.hess\",\n", + " \"../tutorial/orca/claisen_ts_freq.hess\", style=\"orca\")\n", + "display(orca.to_dataframe())" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Displaying KIEs\n", - "Now, just like before, we have access to a KIE object (earlier we pulled them from the KIES dictionary). These can be printed prettily or you can take the value directly. As expected, we have large normal isotope effects because we used a carbon-5000 nucleus! (The three numbers correspond to the uncorrected, Wigner, and Bell KIEs.)" + "## Many files at once: `batch`\n", + "\n", + "`batch` runs one configuration over many ground-state/transition-state pairs and collects the results into one table. Build the pairs from your own file globbing -- no naming convention is assumed. Pairs may be a `{label: (gs, ts)}` mapping, `(label, gs, ts)` triples, or bare `(gs, ts)` tuples (label inferred from the filename)." ] }, { "cell_type": "code", - "execution_count": 11, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Isotopologue Carbon 5000 at C4 KIE 2.1324 2.3449 2.4192 \n", - "[2.13240475 2.34485535 2.41923705]\n" - ] - } - ], - "source": [ - "print(carbon5000_kie)\n", - "print(carbon5000_kie.value)" - ] - }, - { - "cell_type": "markdown", "metadata": {}, "source": [ - "## *PyQuiver* with multiple files: `autoquiver.py`\n", - "A common problem when computing KIEs is the need to screen a large number of levels of theory with numerous ground and transition state files. So far, we have only seen how to make a `KIE_Calculation` object that corresponds to a single configuration, ground state, and transition state.\n", - "\n", - "The `autoquiver` module implements functionality to perform screening over a large number of files. Its command line use is described in depth in the README. Here we will see how to leverage Python to have more sophisticated uses for `autoquiver`. Suppose we have the following files that we want to use as inputs to `PyQuiver`:\n", - "\n", - "```\n", - "config.config\n", + "results = batch(config, {\n", + " \"run1\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + " \"run2\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + "})\n", "\n", - "ground_state1.out ts-1.out\n", - "gs1rotomer.out tsrotomer1.out\n", - "GROUNDSTATE2.out transition_state2.out\n", - "gs3.out ts3.out\n", - "```\n", - "\n", - "We want to run *PyQuiver* on pair of files above in the same row using the configuration file `config.config`. The command line functionality of `autoquiver` is great when the file names have a consistent pattern, but here it's not obvious how to generate the proper pairings or detect which files are ground or transition states." - ] + "display(results.to_dataframe())\n", + "results[\"run1\"] # the KIE_Calculation for that pair, for drilling down" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### The Arguments\n", - "The function `autoquiver` (which is the only object available in the `autoquiver` module) requires the following arguments:\n", + "## Tunnelling corrections\n", "\n", - "* `filepath`: this is the target directory.\n", - "* `config_path`: the location of the configuration file to use for all *PyQuiver* calculations\n", - "* `input_extension`: the file extension for ground and transition state files (default=`.out`)\n", - "* `style`: the style of the ground and transition state files (default=`g09`)\n", + "Every KIE is reported three ways -- **uncorrected**, **Wigner**, and **Bell** (inverted parabola). Wigner and Bell need only the transition state's imaginary frequency; the **Skodje-Truhlar** correction also needs the barrier *height* (the reactant, product, and TS energies) and can be more appropriate when the imaginary frequency is very large.\n", "\n", - "In the README, we discussed the command:\n", + "For an imaginary mode of magnitude $\\tilde\\nu^\\ddagger$, write $u = \\dfrac{h c\\,\\tilde\\nu^\\ddagger}{k_B T}$. The single-mode transmission coefficients are\n", "\n", - "```\n", - "python src/autoquiver.py -e .output auto/ auto/substitutions.config gs ts -\n", - "```\n", + "$$\\kappa_{\\text{Wigner}} = 1 + \\frac{u^2}{24}, \\qquad \\kappa_{\\text{Bell}} = \\frac{u/2}{\\sin(u/2)}.$$\n", "\n", - "The strings \"gs\" and \"ts\" were used to find ground and transition files. A ground state file needed to have \"gs\" as a substring and likewise for \"gs\" and transition state files. The character \"-\" was used as a field delimiter and \"gs\" and \"ts\" matched if all fields after the first were identical.\n", + "Skodje-Truhlar additionally uses $\\alpha = \\dfrac{2\\pi}{h\\,\\nu^\\ddagger}$, $\\beta = \\dfrac{1}{k_B T}$, and the barrier $V = V_{\\text{TS}} - \\max(V_{\\text{reactant}}, V_{\\text{product}})$:\n", "\n", - "That is some sensible default behaviour, but for more complex cases, we can provide the following functions:\n", + "$$\\kappa = \\frac{\\beta\\pi/\\alpha}{\\sin(\\beta\\pi/\\alpha)} - \\frac{\\beta}{\\alpha-\\beta}\\,e^{(\\beta-\\alpha)V} \\quad (\\alpha > \\beta),$$\n", "\n", - "* `gs_p`: a function that takes a filename and returns a boolean value. `True` if the filename corresponds to a ground state and `False` if it does not.\n", - "* `ts_p`: a function that takes a filename and returns a boolean value. `True` if the filename corresponds to a transition state and `False` if it does not.\n", - "* `gs_ts_match_p`: a function that takes a ground state filename and a transition state filename and returns a boolean value. `True` if the ground state and transition state match and `Fase` if they do not.\n", + "$$\\kappa = \\frac{\\beta}{\\beta-\\alpha}\\left(e^{(\\beta-\\alpha)V} - 1\\right) \\quad (\\alpha < \\beta).$$\n", "\n", - "#### `gs_p` and `ts_p`\n", - "For the previous example, we need to detect that all of the following are ground state files:\n", - "\n", - "```\n", - "ground_state1.out\n", - "gs1_rotomer.out\n", - "GROUNDSTATE2.out\n", - "gs3.out\n", - "```\n", - "\n", - "We can accomplish this with the following Python function:" + "In every case the KIE is multiplied by $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$. The uncorrected, Wigner, and Bell values are in `to_dataframe()`; Skodje-Truhlar is computed on demand because it needs electronic energies, read here with [`cctk`](https://github.com/ekwan/cctk) (`pip install cctk`)." ] }, { "cell_type": "code", - "execution_count": 12, - "metadata": { - "collapsed": false - }, - "outputs": [], - "source": [ - "def gs_p(filename):\n", - " # convert to lowercase\n", - " filename = filename.lower()\n", - " # remove the _ character\n", - " filename = filename.replace(\"_\", \"\")\n", - " # replace groundstate with gs\n", - " filename = filename.replace(\"groundstate\", \"gs\")\n", - " # check if the modified filename begins with the string \"gs\"\n", - " if filename[:2] == \"gs\":\n", - " return True\n", - " else:\n", - " return False" - ] - }, - { - "cell_type": "markdown", "metadata": {}, "source": [ - "Let's test this on all the filenames in the example:" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Checking ground state matches:\n", - "True\n", - "True\n", - "True\n", - "True\n", - "Checking transition state matches:\n", - "False\n", - "False\n", - "False\n", - "False\n" - ] - } - ], - "source": [ - "gs_filenames = [\"ground_state1.out\", \"gs1_rotomer.out\", \"GROUNDSTATE2.out\", \"gs3.out\"]\n", - "ts_filenames = [\"ts-1.out\", \"tsrotomer1.out\", \"transition_state2.out\", \"ts3.out\"]\n", + "import cctk\n", "\n", - "print(\"Checking ground state matches:\")\n", - "for n in gs_filenames:\n", - " print(gs_p(n))\n", - "print(\"Checking transition state matches:\")\n", - "for n in ts_filenames:\n", - " print(gs_p(n))" - ] + "def energy(path):\n", + " ensemble = cctk.GaussianFile.read_file(path).ensemble\n", + " return ensemble.get_properties_dict(ensemble.molecules[-1])[\"energy\"]\n", + "\n", + "E_reactant = energy(\"../tutorial/gaussian/claisen_gs.out\")\n", + "E_ts = energy(\"../tutorial/gaussian/claisen_ts.out\")\n", + "\n", + "# energies in hartree; product taken equal to the reactant here\n", + "calc.skodje_truhlar(reactant_energy=E_reactant, product_energy=E_reactant, ts_energy=E_ts)" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Lo and behold! The function successfully separated the ground state and transition state files. We can similarly implement `ts_p`:" + "## Under the hood: reduced isotopic partition function ratios\n", + "\n", + "A KIE is a ratio of reduced isotopic partition function ratios (RPFRs). You can compute these directly: build a light and a heavy `Isotopologue` and call `calculate_rpfr`." ] }, { "cell_type": "code", - "execution_count": 14, - "metadata": { - "collapsed": true - }, - "outputs": [], + "metadata": {}, "source": [ - "def ts_p(filename):\n", - " # convert to lowercase\n", - " filename = filename.lower()\n", - " # remove the _ character\n", - " filename = filename.replace(\"_\", \"\")\n", - " # replace groundstate with gs\n", - " filename = filename.replace(\"transitionstate\", \"ts\")\n", - " # check if the modified filename begins with the string \"gs\"\n", - " if filename[:2] == \"ts\":\n", - " return True\n", - " else:\n", - " return False" - ] + "gs_masses = [DEFAULT_MASSES[z] for z in gs.atomic_numbers]\n", + "heavy_masses = list(gs_masses)\n", + "heavy_masses[0] = REPLACEMENTS[\"13C\"] # 13C at atom 1\n", + "\n", + "light = Isotopologue(\"light\", gs, gs_masses)\n", + "heavy = Isotopologue(\"heavy\", gs, heavy_masses)\n", + "\n", + "rpfr, imag_ratio, light_freqs, heavy_freqs = calculate_rpfr((light, heavy), 50.0, 0.9614, 393)\n", + "print(f\"ground-state RPFR = {rpfr:.4f}\")" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "#### `gs_ts_match_p`\n", - "Now all we have to do is write a function `gs_ts_match_p` that can take two filenames (one ground state and one transition state) and detect if they are matches. There are numerous ways to accomplish this. We will use a basic regular expression to highlight a common method for writing these functions." + "The per-mode contributions are returned data, not a debug log. `partition_components` gives the product, excitation, and zero-point factors for every vibrational mode:" ] }, { "cell_type": "code", - "execution_count": 15, - "metadata": { - "collapsed": false - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Match: ground_state1.out ts-1.out\n", - "Match: ground_state1.out tsrotomer1.out\n", - "Match: gs1_rotomer.out ts-1.out\n", - "Match: gs1_rotomer.out tsrotomer1.out\n", - "Match: GROUNDSTATE2.out transition_state2.out\n", - "Match: gs3.out ts3.out\n" - ] - } - ], + "metadata": {}, "source": [ - "gs_filenames = [\"ground_state1.out\", \"gs1_rotomer.out\", \"GROUNDSTATE2.out\", \"gs3.out\"]\n", - "ts_filenames = [\"ts-1.out\", \"tsrotomer1.out\", \"transition_state2.out\", \"ts3.out\"]\n", + "import pandas as pd\n", "\n", - "import re\n", - "def gs_ts_p(gs_name, ts_name):\n", - " # a regular expression that finds and extracts the first integer substring in a filename\n", - " gs_match = re.search(\".*([0-9]+).*\", gs_name)\n", - " gs_number = gs_match.group(1)\n", - " \n", - " ts_match = re.search(\".*([0-9]+).*\", ts_name)\n", - " ts_number = ts_match.group(1)\n", - " \n", - " if ts_number == gs_number:\n", - " return True\n", - " else:\n", - " return False\n", - " \n", - "import itertools\n", - "for gs_n, ts_n in itertools.product(gs_filenames, ts_filenames):\n", - " if gs_ts_p(gs_n, ts_n):\n", - " print(\"Match: {0} {1}\".format(gs_n, ts_n))" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": { - "collapsed": true - }, + "components = partition_components(heavy_freqs, light_freqs, 393)\n", + "modes = pd.DataFrame(components, columns=[\"product\", \"excitation\", \"ZPE\"])\n", + "modes[\"contribution\"] = modes.prod(axis=1)\n", + "display(modes.head())" + ], "outputs": [], - "source": [ - "# missing example of how to actually use autoquiver" - ] + "execution_count": null } ], "metadata": { "kernelspec": { - "display_name": "python_3_7", + "display_name": "Python 3", "language": "python", - "name": "python_3_7" + "name": "python3" }, "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.7.1" + "name": "python" } }, "nbformat": 4, - "nbformat_minor": 2 -} + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/scripts/skodgetruhlar.ipynb b/scripts/skodgetruhlar.ipynb deleted file mode 100644 index f4069de..0000000 --- a/scripts/skodgetruhlar.ipynb +++ /dev/null @@ -1,406 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# *Skodge Truhlar KIE correction*\n", - "This is an IPython Notebook interface to calculate tunnelling corrections via the Skodge Truhlar equations (*J. Phys. Chem.* **1981**, 85, 624-626.) using the *PyQuiver* and *cctk* packages. The code below will guide you through using *PyQuiver* and *cctk* through a native Python interface. The same steps could be reproduced in the Python interpreter or in a `.py` file.\n", - "\n", - "The initial KIE calculation requires hessians for the transition state and the starting material. The Skodge Truhlar equation additionally requires the energies of each SM/PR/TS (single point electronic energies). For Gaussian calculations this will require only the output files for the transition state and pre-transition state calculations. For Orca calculations the .hess and .out files are necessary" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Setup\n", - "Import necessary package elements.\n", - "sys.path.append should point to the src directory of PyQuiver. Note: Paths for all operating systems (including Windows) should use '/'\n", - "\n", - "cctk can be installed via \"pip install cctk\" or from github (ekwan/cctk)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "import sys\n", - "sys.path.append(\"../src\")\n", - "from kie import KIE_Calculation\n", - "import cctk\n", - "\n", - "\"\"\"Constants are imported from PHYSICAL_CONSTANTS (PyQuiver/src)\n", - "\n", - "Constants:\n", - " h: Planck's constant in J * s\n", - " c: speed of light in cm / s\n", - " kB: Boltzmann's constant in J / K\n", - " Eh: energy of a hartree in J\n", - "\"\"\"\n", - "from constants import PHYSICAL_CONSTANTS\n", - "h = PHYSICAL_CONSTANTS[\"h\"]\n", - "c = PHYSICAL_CONSTANTS[\"c\"]\n", - "kB = PHYSICAL_CONSTANTS[\"kB\"]\n", - "Eh = PHYSICAL_CONSTANTS[\"Eh\"]" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Extracting SPE energy from electronic structure calculations\n", - "\n", - "Electronic structure output files are read by cctk depending on the value of the *software* variable. The final molecule is chosen, and its energy is returned." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def get_energy(software,path):\n", - " \"\"\"Function to get energy from electronic structure calculation\n", - " Args:\n", - " software (string): the electronic structure package used. supported values: `orca` or `gauss`\n", - " path (string): relative or exact path to output file \n", - " \n", - " Returns:\n", - " float: energy in Eh\n", - "\n", - " Raises:\n", - " Exception: if software value passed is not as expected\n", - " \"\"\"\n", - " if software == 'orca':\n", - " ensemble = cctk.OrcaFile.read_file(path).ensemble\n", - " elif software == 'gauss':\n", - " ensemble = cctk.GaussianFile.read_file(path).ensemble\n", - " else:\n", - " raise Exception(\"Check software variable passed\")\n", - " molecule = ensemble.molecules[-1]\n", - " properties_dict = ensemble.get_properties_dict(molecule)\n", - " return properties_dict[\"energy\"]" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### The Skodge Truhlar equation\n", - "\n", - "The Skodge Truhlar equation is an alternative approximation for calculating transmission coefficients. (*J. Phys. Chem.* **1981**, 85, 624-626.) This strategy might be more applicable in cases of very large frequencies.\n", - "\n", - "The the natural vibrational frequency is calculated from the reduced mass ($\\mu$) and the force constant (k) of the imaginary mode. This is handled automatically in PyQuiver.\n", - "$$ \\nu^\\ddag = {1 \\over{2 \\pi}} * \\sqrt{k^\\ddag \\over \\mu^\\ddag} $$\n", - "\n", - "The ST equation is definited by two key terms $\\alpha$ and $\\beta$, where $ \\alpha = {{2\\pi}\\over{h\\nu^\\ddag}} $ and $ \\beta = {1\\over{k_BT}}$ as well as the barrier height (electronic energy) in the exothermic direction ($V = V_{TS} - max(V_{SM},V_{PR})$):\n", - "\n", - "\n", - "\n", - "The ST equation has two forms depending on the vibrational frequency and temperature.\n", - "\n", - "In cases of a relatively small $\\nu^\\ddag$ and high T ($\\alpha > \\beta$):\n", - "$$ \\kappa = {{\\beta\\pi/\\alpha}\\over{sin(\\beta\\pi/\\alpha)}} - {\\beta\\over{\\alpha - \\beta}}e^{(\\beta-\\alpha)V} $$\n", - "\n", - "Conversely, in cases of a relatively large $\\nu^\\ddag$ and low T ($\\alpha < \\beta$):\n", - "\n", - "$$ \\kappa = {\\beta\\over{\\beta-\\alpha}}(e^{(\\beta-\\alpha)V}-1) $$\n", - "\n", - "In states where $\\alpha = \\beta$ :\n", - "$$ \\kappa = {{\\beta\\pi/\\alpha}\\over{sin(\\beta\\pi/\\alpha)}}$$\n", - "\n", - "The tunnelling correction is defined as $\\kappa_{light}/\\kappa_{heavy}$\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def st_factor(imag_cm,temp_K,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh):\n", - " \"\"\"This function calculates the Skodge Truhlar transmission coefficient\n", - " Args:\n", - " imag_cm (float): natural vibrational frequency of the transition state mode in 1 / cm\n", - " temp_K (int): temperature of reaction in K \n", - " sm_energy_Eh (float): energy of starting structure in hartree\n", - " pr_energy_Eh (float): energy of product structure in hartree\n", - " ts_energy_Eh (float): energy of transition state in hartree\n", - "\n", - " Returns:\n", - " float: Skodge Truhlar transmission coefficient\n", - " \"\"\"\n", - "\n", - " #: natural vibrational frequency is converted to hertz with the speed of light and made positive\n", - " imag_hz = np.abs(imag_cm * c)\n", - "\n", - " #: sm/pr/ts energies are converted to J\n", - " pr_energy_J = pr_energy_Eh * Eh\n", - " ts_energy_J = ts_energy_Eh * Eh\n", - " sm_energy_J = sm_energy_Eh * Eh\n", - "\n", - " #: barrier is calculated in the exothermic direction in J\n", - " barr_J = ts_energy_J - max(pr_energy_J,sm_energy_J)\n", - "\n", - " #: ST parameters alpha and beta are calculated both in J**-1\n", - " alpha = 2 * np.pi / (h * imag_hz)\n", - " beta = 1 / (kB * temp_K)\n", - "\n", - " #: Equation used is dependent on relation of alpha and beta, both are unitless\n", - " if alpha == beta:\n", - " return (beta * np.pi / alpha)/(np.sin(beta * np.pi / alpha))\n", - " elif alpha > beta:\n", - " return (beta * np.pi / alpha)/(np.sin(beta * np.pi / alpha)) - beta/(alpha-beta)*np.exp((beta-alpha)*barr_J)\n", - " else:\n", - " return beta/(beta-alpha)*(np.exp((beta-alpha)*barr_J)-1)\n", - "\n", - "def st_corrrection(imag_light_cm,imag_heavy_cm,temp_K,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh):\n", - " \"\"\"This function uses st_factor to calculate the ratio of tunnelling coefficient for the light and heavy isotopes\n", - " Args:\n", - " imag_light_cm (float): light isotope natural vibrational frequency of the transition state mode in 1 / cm\n", - " imag_heavy_cm (float): heavy isotope natural vibrational frequency of the transition state mode in 1 / cm\n", - " temp_K (int): temperature of reaction in K \n", - " sm_energy_Eh (float): energy of starting structure in hartree\n", - " pr_energy_Eh (float): energy of product structure in hartree\n", - " ts_energy_Eh (float): energy of transition state in hartree\n", - "\n", - " Returns:\n", - " float: Skodge Truhlar tunnelling correction factor\n", - " \"\"\"\n", - " light_factor = st_factor(imag_light_cm,temp_K,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh)\n", - " heavy_factor = st_factor(imag_heavy_cm,temp_K,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh)\n", - " return light_factor/heavy_factor" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### PyQuiver implementation\n", - "The following function uses the above function for the ST tunnelling correction to a PyQuiver generated list of KIEs." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def calc_frequency(kie,kiecalc):\n", - " \"\"\"This function uses PyQuiver to calculate the natural vibrational frequencies\n", - " Args:\n", - " kie (KIE): isotopologue to calculate frequency of\n", - " kiecalc (KIE_CALCULATION): kie calculation to extract configuration parameters from\n", - "\n", - " Returns:\n", - " light_imag_freq (float), heavy_imag_freq (float): light and heavy imaginary frequencies in 1 / cm\n", - " \"\"\"\n", - "\n", - " light_imag_freqs = kie.ts_tuple[0].calculate_frequencies(kiecalc.config.imag_threshold, scaling=kiecalc.config.scaling)[1]\n", - " heavy_imag_freqs = kie.ts_tuple[1].calculate_frequencies(kiecalc.config.imag_threshold, scaling=kiecalc.config.scaling)[1]\n", - " return min(light_imag_freqs), min(heavy_imag_freqs)\n", - "\n", - "def st_KIE(kiecalc,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh):\n", - " \"\"\"This function calculates Skodge Truhlar corrected KIEs from a KIE_CALCULATION\n", - " Args:\n", - " kiecalc (KIE_Calculation): KIE Calculation\n", - " sm_energy_Eh (float): energy of starting structure in hartree\n", - " pr_energy_Eh (float): energy of product structure in hartree\n", - " ts_energy_Eh (float): energy of transition state in hartree\n", - "\n", - " Returns:\n", - " dictionary{isotopologue}: dictionary of isotopologues\n", - "\n", - " Raise:\n", - " Exception: if KIE calculation is determined to be an EIE calculation\n", - " \"\"\"\n", - "\n", - " sk_kie_dict = {}\n", - "\n", - " if kiecalc.eie_flag != 0:\n", - " raise Exception(\"ST Correction not appropriate for EIE calculation\")\n", - "\n", - " #: Check whether to use relative or absolute KIEs, calculate reference tunnelling correction if relative\n", - " if kiecalc.config.reference_isotopologue != \"default\" and kiecalc.config.reference_isotopologue != \"none\":\n", - " ref = kiecalc.KIES[kiecalc.config.reference_isotopologue]\n", - " imag_light_cm, imag_heavy_cm = calc_frequency(ref,kiecalc)\n", - " ref_tunnel_corr = st_corrrection(imag_light_cm,imag_heavy_cm,kiecalc.config.temperature,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh)\n", - " sk_kie_dict[kiecalc.config.reference_isotopologue] = ref.value[0] * ref_tunnel_corr\n", - " else:\n", - " ref_tunnel_corr = 1\n", - "\n", - " for key, value in kiecalc.KIES.items():\n", - " if key != kiecalc.config.reference_isotopologue:\n", - " #: extract light and heavy imaginary frequencies from pyquiver\n", - " imag_light_cm, imag_heavy_cm = calc_frequency(value,kiecalc)\n", - " \n", - " #: calculate tunnelling correction and print result\n", - " tuncorrection = st_corrrection(imag_light_cm,imag_heavy_cm,kiecalc.config.temperature,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh) / ref_tunnel_corr\n", - " sk_kie_dict[key] = value.value[0] * tuncorrection\n", - "\n", - " return sk_kie_dict\n", - "\n", - "def print_st(kiecalc,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh):\n", - " \"\"\"This function prints Skodge Truhlar corrected KIEs from a KIE_CALCULATION\n", - " Args:\n", - " kiecalc (KIE_Calculation): KIE Calculation\n", - " sm_energy_Eh (float): energy of starting structure in hartree\n", - " pr_energy_Eh (float): energy of product structure in hartree\n", - " ts_energy_Eh (float): energy of transition state in hartree\n", - " \"\"\"\n", - " \n", - " print(\"Skodge Truhlar Corrected Absolute KIE:\")\n", - " print(\"Isotopologue uncorrected Skodge Truhlar\")\n", - " print(\" KIE KIE\")\n", - "\n", - " sk_kie_dict = st_KIE(kiecalc,sm_energy_Eh,pr_energy_Eh,ts_energy_Eh)\n", - "\n", - " #: Iterate through Isotopologues to print uncorrected and ST corrected KIEs\n", - " for key, value in kiecalc.KIES.items():\n", - " if key != kiecalc.config.reference_isotopologue:\n", - " #: extract light and heavy imaginary frequencies from pyquiver\n", - " \n", - " spaces = \" \" * max((10 - len(key)),1)\n", - " print(\"isotopologue:\" + spaces + key, end =\" \")\n", - " print(\"%0.4f\" % value.value[0], end =\" \")\n", - " print(\"%0.4f\" % sk_kie_dict[key])\n", - " \n", - " #: display reference information\n", - " if kiecalc.config.reference_isotopologue != \"default\" and kiecalc.config.reference_isotopologue != \"none\":\n", - " print()\n", - " print(\"KIEs referenced to isotopologue \"+ kiecalc.config.reference_isotopologue +\", whose absolute KIEs are:\")\n", - " spaces = \" \" * max((10 - len(kiecalc.config.reference_isotopologue)),1)\n", - " print(\"isotopologue:\" + spaces + kiecalc.config.reference_isotopologue, end =\" \")\n", - " print(\"%0.4f\" % kiecalc.KIES[kiecalc.config.reference_isotopologue].value[0], end =\" \")\n", - " print(\"%0.4f\" % sk_kie_dict[kiecalc.config.reference_isotopologue])\n" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Running calculation\n", - "With the necessary functions defined, the correction can be run on electronic structure data. Here the paths reference the tutorial data for the Claisen reaction. Note that for this reaction the ST corrected KIEs nearly exactly match the inverted parabola values.\n", - "\n", - "First the software is set which allows cctk to appropriately extract the energies. Then, the path to the *PyQuiver* configuration file is defined (see tutorial for details). Finally, relevent electronic structure calculations are set. For generality the extension is ommitted and added as needed. Alternatively, these paths can of course be manually set below when calling the appropriate function.\n", - "\n", - "The first example is for Orca and the second for Gaussian:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# set computational software\n", - "# gauss for Gaussian\n", - "# orca for Orca\n", - "software = \"orca\"\n", - "\n", - "#location of pyquiver configuration file, see documentation/tutorial for details\n", - "pyquivconf = \"../tutorial/orca/claisen_demo.config\"\n", - "\n", - "#include basename not extension\n", - "smpath = \"../tutorial/orca/claisen_gs_freq\"\n", - "prpath = \"../tutorial/orca/claisen_gs_freq\"\n", - "tspath = \"../tutorial/orca/claisen_ts_freq\"\n", - "\n", - "kiecalc = KIE_Calculation(pyquivconf, smpath + \".hess\", tspath + \".hess\", style=\"orca\")\n", - "print(kiecalc)\n", - "print()\n", - "print_st(kiecalc,get_energy(software,smpath+\".out\"),get_energy(software,prpath+\".out\"),get_energy(software,tspath+\".out\"))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "It is also possible to directly extract the KIEs (in addition to printing them) as a dictionary of isotopologues (relative to reference isotopologue, which is recorded in the dictionary as an absolute KIE)\n", - "\n", - "Here we extract and print the ST corrected C4 KIE:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "st = st_KIE(kiecalc,get_energy(software,smpath+\".out\"),get_energy(software,prpath+\".out\"),get_energy(software,tspath+\".out\"))\n", - "print(\"%0.4f\" % st[\"C4\"])" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Here is the same calculation as above, except using Gaussian output files" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# set computational software\n", - "# gauss for Gaussian\n", - "# orca for Orca\n", - "software = \"gauss\"\n", - "\n", - "#location of pyquiver configuration file, see documentation/tutorial for details\n", - "pyquivconf = \"../tutorial/gaussian/claisen_demo.config\"\n", - "\n", - "#In this case the paths can include the extension since pyquiver and gaussian both use the gaussian output files\n", - "smpath = \"../tutorial/gaussian/claisen_gs.out\"\n", - "prpath = \"../tutorial/gaussian/claisen_gs.out\"\n", - "tspath = \"../tutorial/gaussian/claisen_ts.out\"\n", - "\n", - "kiecalc = KIE_Calculation(pyquivconf, smpath, tspath)\n", - "print(kiecalc)\n", - "print()\n", - "print_st(kiecalc,get_energy(software,smpath),get_energy(software,prpath),get_energy(software,tspath))" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "cctk", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.16" - }, - "orig_nbformat": 4, - "vscode": { - "interpreter": { - "hash": "50c68f4a39ea646ed7d3e4e0d35a44bd89267cbb0b9077d25aee25f802178e8f" - } - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/src/AUTOQUIVER.md b/src/AUTOQUIVER.md index 9e64f08..b0d0e4e 100644 --- a/src/AUTOQUIVER.md +++ b/src/AUTOQUIVER.md @@ -1,61 +1,57 @@ -### `autoquiver.py` +# Batch processing -`autoquiver` allows KIEs to be calculated over many ground state and transition state files that share a common set of desired isotopic substitutions. (The `autoquiver` module has additional functionality when used through a Python interface (read the `scripts/pyquiver.ipynb` if interested) but the command line interface should suffice for most purposes.) +To run one configuration over many ground-state/transition-state pairs, use +`pyquiver.batch`. File discovery and pairing are left to ordinary Python, so no +filename convention is assumed; `batch` just maps the configuration over the +pairs you give it and collects the results into a table. -Suppose we have a directory, `auto/`, with the following files: +```python +from pyquiver import batch -``` -substitutions.config -gs-type1.output ts-type1.output -gs-type2.output ts-type2.output -gs-type3.output ts-type3.output -gs-type4.output ts-type4.output +results = batch("demo.config", { + "b3lyp": ("b3lyp_gs.out", "b3lyp_ts.out"), + "m06": ("m06_gs.out", "m06_ts.out"), +}, style="gaussian") + +results.to_dataframe() # label, name, uncorrected, wigner, inverted_parabola +results.to_csv("kies.csv") +results["b3lyp"] # the KIE_Calculation for that pair ``` -We might want to run *PyQuiver* using the `substitutions.config` file on all pairs of ground states and transition states. For example, these pairs may be the same calculation run at many levels of theory. Note that it is crucial that all files have a consistent atom numbering scheme. +## Specifying pairs -To accomplish this we run `autoquiver.py` as follows: +`pairs` may be given three ways: -``` -python src/autoquiver.py -e .output auto/ auto/substitutions.config gs ts - -``` +* a mapping `{label: (gs, ts)}` +* an iterable of `(label, gs, ts)` triples +* an iterable of `(gs, ts)` pairs — the label is inferred from the ground-state + filename (without its extension) -The arguments are: +So you can discover and pair files however you like, then hand the result to +`batch`. For example, zipping two globs: -* `-e .output`: a flag to look for files with the extension `.output` as the frequency jobs for the ground and transitions states. -* `auto/`: look for files in the `auto/` directory. -* `auto/substitutions.config`: use `auto/substitutions.config` as the configuration file. -* `gs`: use the string "gs" to find ground state files. All files with the appropriate extension that contain the substring "gs" will be treated as ground state files. -* `ts`: use the string "ts" to find transition state files. -* `-`: use the field delimiter "-" to test if a ground state and transition states match. All fields after the first "-" must be identical. This means that `gs-type1.output` and `ts-type1.output` will match but `gs-type1.output` and `ts-type2.output` won't. +```python +import glob +from pyquiver import batch -The output of autoquiver is directed to a number of csv files corresponding to each configuration file given. These filenames are printed when autoquiver exits. +gs = sorted(glob.glob("*_reactant.out")) +ts = sorted(glob.glob("*_ts.out")) +results = batch("demo.config", zip(gs, ts)) +``` -For more information, the output of `python autoquiver.py -h` has been reproduced below: +## Skodje-Truhlar in batch +Pass `energies` mapping each label to its three single-point electronic +energies (hartree), ordered along the reaction coordinate +`(reactant, ts, product)`, to add a Skodje-Truhlar column: + +```python +results = batch( + "demo.config", + {"b3lyp": ("b3lyp_gs.out", "b3lyp_ts.out")}, + energies={"b3lyp": (E_reactant, E_ts, E_product)}, +) ``` -usage: autoquiver.py [-h] [-v] [-s STYLE] [-e EXT] - config target gs_p ts_p delimiter - -A program to automatically run PyQuiver on a config file and all ground state -and transition states matching certain constraints. - -positional arguments: - config configuration file path - target target directory file path (where the ground and - transition state files live - gs_p substring in ground state files - ts_p substring in transition state files - delimiter delimiter used to match ground and transition state - files (all fields separated by the delimiter after the - first must match) - -optional arguments: - -h, --help show this help message and exit - -v, --verbose when the verbose flag is set debug information is - printed - -s STYLE, --style STYLE - style of input files - -e EXT, --extension EXT - extension of input files -``` + +(For distinct products use the real product energy; for a single-well reaction +pass the reactant energy for the product too.) diff --git a/src/autoquiver.py b/src/autoquiver.py deleted file mode 100644 index e52bebe..0000000 --- a/src/autoquiver.py +++ /dev/null @@ -1,97 +0,0 @@ -import sys -import os -import re -import subprocess -import argparse - -import settings -import quiver - -from kie import KIE_Calculation - -import glob - -def autoquiver(filepath, config_path, gs_p, ts_p, gs_ts_match_p, input_extension='.out', style='g09', report_tunnelling=False): - if type(gs_p) is str: - gs_str = gs_p - gs_p = lambda x: (gs_str in x) - elif hasattr(gs_p, '__call__'): - pass - else: - raise TypeError("gs_p must either be a string or a function that returns a boolean value.") - if type(ts_p) is str: - ts_str = ts_p - ts_p = lambda x: (ts_str in x) - elif hasattr(ts_p, '__call__'): - pass - else: - raise TypeError("ts_p must either be a string or a function that returns a boolean value.") - if type(gs_ts_match_p) is str: - delimiter = gs_ts_match_p - gs_ts_match_p = lambda x,y: (x.split(delimiter)[1:] == y.split(delimiter)[1:]) - elif hasattr(ts_p, '__call__'): - pass - else: - raise TypeError("gs_ts_p must either be a string or a function that returns a boolean value.") - - os.chdir(filepath) - print("Starting AutoQuiver analysis...\n") - for config in glob.glob("*.config"): - if os.path.samefile(config_path, config): - eie_flag = -1 - title_start = "ground_state,transition_state," - title = title_start - table = "" - for gs in glob.glob("*"+input_extension): - if gs_p(gs): - for ts in glob.glob("*"+input_extension): - if ts_p(ts) and gs_ts_match_p(gs,ts): - print("%-50s - %-50s : " % (gs[-50:], ts[-50:]), end=' ') - try: - kie = KIE_Calculation(config, gs, ts, style=style) - except: - print("error") - continue - title_row, row, eie_p = kie.get_row(report_tunnelling=report_tunnelling) - - # print the KIEs (minus the comma at the end) - print(row[:-1]) - - if eie_flag == -1: - eie_flag = eie_p - else: - if eie_flag != eie_p: - raise ValueError("some calculations represented EIEs and others represented KIEs.") - if title is title_start: - title = title + title_row - table = title + "\n" + table - else: - if title_start + title_row != title: - raise ValueError("the alignment of the table columns is incorrect.") - - table += gs + "," + ts + "," + row + "\n" - - with open(os.path.splitext(config)[0]+"-kies.csv", 'w') as f: - f.write(table) - print("\nAutoQuiver has completed execution.\nResult written to {0}.".format(os.path.splitext(config)[0]+"-kies.csv")) - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="A program to automatically run PyQuiver on a config file and all ground state and transition states matching certain constraints.") - parser.add_argument('-v', '--verbose', dest="debug", help='when the verbose flag is set debug information is printed', action='count') - parser.add_argument('-s', '--style', dest="style", default='g09', help='style of input files') - parser.add_argument('-e', '--extension', dest="ext", default='.out', help='extension of input files') - parser.add_argument('-t', '--report_tunnelling', dest="report_tunnelling", action='store_true', help='report the tunnelling correction') - parser.add_argument('config', help='configuration file path') - parser.add_argument('target', help='target directory file path (where the ground and transition state files live') - parser.add_argument('gs_p', help='substring in ground state files') - parser.add_argument('ts_p', help='substring in transition state files') - parser.add_argument('delimiter', help='delimiter used to match ground and transition state files (all fields separated by the delimiter after the first must match)') - parser.set_defaults(report_tunnelling=False) - - args = parser.parse_args() - settings.DEBUG = 0 - if args.debug: - settings.DEBUG += args.debug - print("Debug level is %d" % settings.DEBUG) - - autoquiver(args.target, args.config, args.gs_p, args.ts_p, args.delimiter, style=args.style, input_extension=args.ext, report_tunnelling=args.report_tunnelling) diff --git a/src/config.py b/src/config.py index 0859698..353e654 100644 --- a/src/config.py +++ b/src/config.py @@ -1,177 +1,13 @@ -# This file reads PyQuiver configuration files. -import sys -import re -import settings -from constants import REPLACEMENTS, REPLACEMENTS_Z -from collections import OrderedDict +"""Deprecated shim. Import `pyquiver.config` instead of `config`. -# Reads PyQuiver .config files. -class Config(object): - def __init__(self,filename): - expected_fields = "scaling temperature mass_override_isotopologue reference_isotopologue imag_threshold frequency_threshold".split(" ") - config = { i : None for i in expected_fields } - config["filename"] = filename +Kept so that pre-packaging scripts (`from config import ...`) keep working. +""" +import os as _os, sys as _sys, warnings as _warnings - print_message = True - try: - f=list(sys._current_frames().values())[0] - if "autoquiver" in f.f_back.f_back.f_globals['__file__']: - print_message = False - except: - pass - if print_message and settings.DEBUG >= 2: - print("\nReading configuration from {0}".format(filename)) +_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) +_warnings.warn( + "'config' is deprecated; import 'pyquiver.config' instead.", + DeprecationWarning, stacklevel=2) - # a list of isotopologues - # each entry is a list of tuples - # each tuple is (from_atom_number, to_atom_number, replacement_isotope) - # this format allows for multiple replacements in one isotopologue - isotopologues = OrderedDict() - - # read file - for line in open(filename, "r"): - # ignore comments and blank lines - line = line.strip() - if len(line) == 0 or line[0] == "#": - continue - line = line.split("#", 1)[0] - - # read space-delimited data - fields = [_f for _f in line.split(" ") if _f] - - # for backwards compatibility with quiver - if fields[0] == "isotopomer": - fields[0] = "isotopologue" - elif fields[0] == "reference_isotopomer": - fields[0] = "reference_isotopologue" - elif fields[0] == "mass_override_isotopomer": - fields[0] = "mass_override_isotopologue" - - - # parse - if fields[0] == "isotopologue": - # parse isotopologues, checking for sanity - if len(fields) != 5: - raise ValueError("unexpected number of fields for isotopologue in config file:\n%s" % line) - isotopologue_id, from_atom_number, to_atom_number, replacement = str(fields[1]), int(fields[2]), int(fields[3]), str(fields[4]) - if isotopologue_id == "default": - raise ValueError("name default is reserved.") - if from_atom_number < 1 or to_atom_number < 1: - raise ValueError("check atom numbers:\n%s" % line) - if replacement not in list(REPLACEMENTS.keys()): - raise ValueError("invalid isotopic replacement:\n%s" % line) - - # allows for the fact that isotopologues can make multiple replacements - try: - isotopologues[isotopologue_id].append((from_atom_number, to_atom_number, replacement)) - except KeyError: - isotopologues[isotopologue_id] = [(from_atom_number, to_atom_number, replacement)] - - elif len(fields) == 2: - # read regular configuration fields that have only one entry - fields = [ str(i) for i in fields ] - if fields[0] not in expected_fields: - raise ValueError("unexpected config field:\n%s" % line) - config[fields[0]] = fields[1] - else: - raise ValueError("unexpected number of fields in config file:\n%s" % line) - - # ensure we have all the fields we are supposed to - for k,v in config.items(): - if k == "frequency_threshold" and v is not None: - print("*** Warning: frequency_threshold is now deprecated and will be ignored. ***") - if v is None: - if k == "frequency_threshold": - config["frequency_threshold"] = 0.0 - else: - raise ValueError("missing config file field: %s" % k) - if len(isotopologues) == 0: - raise ValueError("must specify at least one isotopologue") - - # check some of the other fields - config["temperature"] = float(config["temperature"]) - if config["temperature"] < 0.0: - raise ValueError("check temperature") - config["scaling"] = float(config["scaling"]) - if config["scaling"] < 0.5 or config["scaling"] > 1.5: - raise ValueError("check scaling factor") - - try: - config["reference_isotopologue"] - except KeyError: - raise ValueError("check reference isotopologue is valid") - config["reference_isotopologue"] = str(config["reference_isotopologue"]) - - try: - config["mass_override_isotopologue"] - except KeyError: - raise ValueError("check reference isotopologue is valid") - config["mass_override_isotopologue"] = str(config["mass_override_isotopologue"]) - - config["frequency_threshold"] = float(config["frequency_threshold"]) - - config["imag_threshold"] = float(config["imag_threshold"]) - if config["imag_threshold"] > 300.0: - raise ValueError("imag threshold is too high") - - # store all the information in the object dictionary - config["isotopologues"] = isotopologues - self.__dict__ = config - - # checks if this config file is compatible with a pair of ground and transition state systems - def check(self, gs, ts, verbose=False): - n_gs_atoms = len(gs.atomic_numbers) - n_ts_atoms = len(ts.atomic_numbers) - # check that the isotopic replacements make sense - isotopologues = self.isotopologues - for i,isotopologue in isotopologues.items(): - for r in range(len(isotopologue)): - # this replacement changes gs atom number from_atom - # and ts atom number to_atom - # to replacement (like "2D") - from_atom = isotopologue[r][0] - to_atom = isotopologue[r][1] - replacement = isotopologue[r][2] - replacement_string = "%s %d %d %s" % (i, from_atom, to_atom, replacement) - # get the atomic numbers of from_atom and to_atom - # must subtract one to convert from atom numbers to indices - from_atomZ = gs.atomic_numbers[from_atom-1] - to_atomZ = ts.atomic_numbers[to_atom-1] - replacementZ = REPLACEMENTS_Z[replacement] - if from_atomZ != replacementZ: - raise ValueError("gs atomic number and replacement atomic number do not match for {0}\n".format(replacement_string)) - if to_atomZ != replacementZ: - raise ValueError("ts atomic number and replacement atomic number do not match for {0}\n".format(replacement_string)) - if (from_atomZ != to_atomZ): - raise ValueError("gs and ts atomic number do not match for\n" % replacement_string) - - if verbose: - print("Config file %s makes sense with gs file %s and ts file %s.\n" % (self.filename, gs.filename, ts.filename)) - - # convert to human-readable format - def __str__(self): - to_string = "Config file: %s\nTemperature: %.1f K\nScaling: %.3f\nReference Isotopologue: %s\nImag threshold (cm-1): %d\n" % \ - (self.filename, self.temperature, self.scaling, self.reference_isotopologue, self.imag_threshold) - if self.frequency_threshold != 0: - to_string += "Frequency threshold (cm-1): %d\n" % self.frequency_threshold - - keys = list(self.isotopologues.keys()) - if self.reference_isotopologue != "default" and self.reference_isotopologue != "none": - try: - keys.remove(self.reference_isotopologue) - except: - print("\nCould not find the following reference isotopologue: %s" % self.reference_isotopologue) - sys.exit(1) - keys.sort() - - if self.reference_isotopologue == "default" or self.reference_isotopologue == "none": - to_string += " No reference isotopologue.\n" - else: - keys = [self.reference_isotopologue] + keys - for i in keys: - isotopologue = self.isotopologues[i] - - for j in range(len(isotopologue)): - to_string += " Isotopologue {0: >10s}, replacement {1: >2d}: replace gs atom {2: ^3d} and ts atom {3: ^3d} with {4: >3s}\n".format(i, j+1, isotopologue[j][0], isotopologue[j][1], isotopologue[j][2]) - - return to_string[:-1] +import pyquiver.config as _mod +_sys.modules[__name__] = _mod diff --git a/src/constants.py b/src/constants.py index 5e48df6..1e03bb3 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,148 +1,13 @@ -# This file holds physical constants and reads atomic weights. -import sys -import re -import os -import inspect -############### +"""Deprecated shim. Import `pyquiver.constants` instead of `constants`. -# Physical Constants +Kept so that pre-packaging scripts (`from constants import ...`) keep working. +""" +import os as _os, sys as _sys, warnings as _warnings -PHYSICAL_CONSTANTS = { - 'h' : 6.626070E-34, # Planck's constants in J * s - 'c' : 2.997925E+10, # speed of light in units of cm/s - 'Eh' : 4.359745E-18, # energy of a hartree in units of J = kg m^2/s^2 - 'a0' : 5.291772E-11, # bohr radius in m - 'atb': 5.291772E-01, # angstroms per bohr - 'amu': 1.660468E-27, # atomic mass unit in units kg - 'kB' : 1.380649E-23 # Boltzmann's constant in J/K -} - #CM/2.998E10/,EM/1.440E13/,HBC/1.4387/ +_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) +_warnings.warn( + "'constants' is deprecated; import 'pyquiver.constants' instead.", + DeprecationWarning, stacklevel=2) -############### - -# Atomic Weight Information - -class Element(object): - def __init__(self, full_name, atomic_number, symbol, default_mass): - # the name of this element, like "hydrogen" - full_name = str(full_name) - self.full_name = full_name - if re.match("[^a-z]", full_name): - print("Unexpected non-lowercase character in element name: %s" % full_name) - print("Quitting.") - sys.exit(1) - - # the symbol of this element, like "H" - symbol = str(symbol) - self.symbol = symbol - if re.match("[^a-zA-Z]", symbol): - print("Unexpected non-letter character in element symbol: %s" % symbol) - print("Quitting.") - sys.exit(1) - if len(symbol) < 1 or len(symbol) > 2: - print("Unexpected length of element symbol (must be 1 or 2): %s" % symbol) - print("Quitting.") - sys.exit(1) - - # the atomic number of this element, like 1 - atomic_number = int(atomic_number) - self.atomic_number = atomic_number - if atomic_number < 1 or atomic_number > 200: - print("Unexpected atomic number: %d" % atomic_number) - print("Quitting.") - sys.exit(1) - - # the average weight for this element, like 1.00783 - default_mass = float(default_mass) - self.default_mass = default_mass - if default_mass < 0.0 or default_mass > 500.0: - print("Unexpected default mass: %d" % default_mass) - print("Quitting.") - sys.exit(1) - - # pairs of tuples strings (like "2H") to masses (like 2.0141) - self.replacements = [] - - def __str__(self): - string = "%s (%s, Z=%d, default mass = %.4f" % (self.full_name.capitalize(), self.symbol, self.atomic_number, self.default_mass) - if len(self.replacements) == 0: - string += ", no isotopic replacements possible)\n" - else: - string += ")\n" - for s,m in self.replacements: - string += " %2s : %.4f\n" % (s,m) - return string[:-1] - - def add_replacement(self, symbol, mass): - symbol = str(symbol) - if re.match("[^a-zA-Z0-9]", symbol): - print("Unexpected non-letter character in isotopic replacement symbol: %s" % symbol) - print("Quitting.") - sys.exit(1) - if len(symbol) < 1 or len(symbol) > 4: - print("Unexpected length of element symbol in replacement (must be 1-4 inclusive, found %d): %s" % (len(symbol), symbol)) - print("Quitting.") - sys.exit(1) - for s,m in self.replacements: - if s == symbol: - print("Must use a unique symbol for every isotopic replacement: %s" % s) - sys.exit(1) - mass = float(mass) - if mass < 0.0 or mass > 500.0: - print("Unexpected isotopic replacement mass: %f" % mass) - sys.exit(1) - self.replacements.append((symbol,mass)) - -# read in atomic weight data -elements = [] - -root = os.path.split(os.path.abspath(__file__))[0] - -for line in open(root + "/weights.dat", "r"): - # ignore comments and blank lines - line = line.strip() - if len(line) == 0 or line[0] == "#": - continue - line = line.split("#",1)[0] - - # parse - fields = line.split(",") #line.encode("ascii","ignore").split(",") - if len(fields) < 4: - print("Error: not enough data on this line of weights.dat:") - print(line) - print("\nQuitting.") - sys.exit(1) - element = Element(*fields[0:4]) - if (len(fields)-4) % 2 != 0: - print("Unexpected number of isotopic replacement fields on this line of weights.dat.") - print("The number of fields after the first four must be a multiple of 2 (found %d)." % (len(fields)-4)) - print(line) - print("\nQuitting.") - sys.exit(1) - if (len(fields) > 4): - for i in range(4, len(fields), 2): - element.add_replacement(fields[i], fields[i+1]) - elements.append(element) - #print element -print("Read atomic weight data for %d elements." % len(elements)) - -# map from atomic number to default masses -DEFAULT_MASSES = { e.atomic_number : e.default_mass for e in elements } - -# map from valid isotopic replacements to masses -REPLACEMENTS = {} -for e in elements: - for replacement,mass in e.replacements: - REPLACEMENTS[replacement] = mass - -# map from isotopic replacements to atomic numbers -REPLACEMENTS_Z = {} -for e in elements: - for replacement,mass in e.replacements: - REPLACEMENTS_Z[replacement]=e.atomic_number - - -# threshold to separate linear molecules from non-linear molecules -LINEARITY_THRESHOLD = 1e-06 -DROP_NUM_LINEAR = 5 -# DROP_NUM_NONLINEAR = 6 +import pyquiver.constants as _mod +_sys.modules[__name__] = _mod diff --git a/src/kie.py b/src/kie.py index e919a5c..b966f55 100644 --- a/src/kie.py +++ b/src/kie.py @@ -1,380 +1,13 @@ -# This calculates KIEs based on the Bigeleisen-Mayer equation. -import numpy as np -#from quiver import System, Isotopologue, DEBUG -import quiver -import settings -from config import Config -from constants import DEFAULT_MASSES -from collections import OrderedDict +"""Deprecated shim. Import `pyquiver.kie` instead of `kie`. -# load constants -from constants import PHYSICAL_CONSTANTS, REPLACEMENTS -h = PHYSICAL_CONSTANTS["h"] # in J . s -c = PHYSICAL_CONSTANTS["c"] # in cm . s -kB = PHYSICAL_CONSTANTS["kB"] # in J/K +Kept so that pre-packaging scripts (`from kie import ...`) keep working. +""" +import os as _os, sys as _sys, warnings as _warnings -class KIE_Calculation(object): - def __init__(self, config, gs, ts, style="g09"): - # check the types of config, gs, and ts parsing files if necessary and copying fields if not - if type(config) is str: - self.config = Config(config) - elif type(config) is Config: - self.config = Config - else: - raise TypeError("config argument must be either a filepath or Config object.") +_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) +_warnings.warn( + "'kie' is deprecated; import 'pyquiver.kie' instead.", + DeprecationWarning, stacklevel=2) - if type(gs) is str: - self.gs_system = quiver.System(gs, style=style) - elif type(gs) is quiver.System: - self.gs_system = gs - else: - raise TypeError("gs argument must be either a filepath or quiver.System object.") - - if type(ts) is str: - self.ts_system = quiver.System(ts, style=style) - elif type(ts) is quiver.System: - self.ts_system = ts - else: - raise TypeError("ts argument must be either a filepath or quiver.System object.") - - # set the eie_flag to the recognized uninitialized value (used for checking if there are inconsistent calculation types) - self.eie_flag = -1 - - if settings.DEBUG != 0: - print(self.config) - - KIES = OrderedDict() - - if self.config.frequency_threshold: - print("WARNING: config file uses the frequency_threshold parameter. This has been deprecated and low frequencies are dropped by linearity detection.") - - for p in self.make_isotopologues(): - gs_tuple, ts_tuple = p - name = gs_tuple[1].name - kie = KIE(name, gs_tuple, ts_tuple, self.config.temperature, self.config.scaling, self.config.imag_threshold) - KIES[name] = kie - - for name,k in KIES.items(): - if name != self.config.reference_isotopologue: - if self.eie_flag == -1: - eie_flag_iso = name - self.eie_flag = k.eie_flag - else: - if self.eie_flag != k.eie_flag: - if eie_flag == 1: - raise ValueError("quiver attempted to run a KIE calculation (isotopomer {0}) after an EIE calculation (isotopomer {1}). Check the frequency threshold.".format(name, eie_flag_iso)) - else: - raise ValueError("quiver attempted to run an EIE calculation (isotopomer {0}) after a KIE calculation (isotopomer {1}). Check the frequency threshold.".format(name, eie_flag_iso)) - - if self.config.reference_isotopologue != "default" and self.config.reference_isotopologue != "none": - k.apply_reference(KIES[self.config.reference_isotopologue]) - - self.KIES = KIES - - # retrieves KIEs for autoquiver output - # if report_tunnelling = True, the first number will be the inverted parabola KIE - # and the second number will be the tunnelling correction - def get_row(self, report_tunnelling=False): - title_row = "" - row = "" - keys = list(self.KIES.keys()) - - # don't report the reference isotoplogue - if self.config.reference_isotopologue != "default" and self.config.reference_isotopologue != "none": - keys.remove(self.config.reference_isotopologue) - - if self.config.mass_override_isotopologue != "default" and self.config.reference_isotopologue != "none": - keys.remove(self.config.mass_override_isotopologue) - - # for each isotopologue, add the KIEs - for name in keys: - if report_tunnelling: - title_row += "%s,%s," % (name + "_uncorr", name + "_inf_para") - else: - title_row += "{0},".format(name) - - if self.eie_flag == 0: - # this is a KIE calculation - if report_tunnelling: - row += "%.4f,%.4f," % ( self.KIES[name].value[-1], self.KIES[name].value[-1]-self.KIES[name].value[-3] ) - else: - row += "{0:.4f},".format(self.KIES[name].value[-1]) - else: - # this is an EIE calculation - row += "{0:.4f},".format(self.KIES[name].value) - #if settings.DEBUG >= 2: - # if self.eie_flag == 0: - # print "KIE calculation detected using {0}th tunneling correction".format(len(self.KIES[name].value)) - # row += "{0:.3f},".format(self.KIES[name].value[-1]) - # else: - # row += "{0:.3f},".format(self.KIES[name].value) - - return (title_row, row, self.eie_flag) - - def build_mass_override_masses(self): - config = self.config - gs_system = self.gs_system - ts_system = self.ts_system - - gs_masses = self.build_default_masses(gs_system) - ts_masses = self.build_default_masses(ts_system) - - if config.mass_override_isotopologue != "default": - iso = self.config.isotopologues[config.mass_override_isotopologue] - gs_rules, ts_rules = self.compile_mass_rules(iso) - - gs_masses = self.apply_mass_rules(gs_masses, gs_rules) - ts_masses = self.apply_mass_rules(ts_masses, ts_rules) - - return gs_masses, ts_masses - - def build_default_masses(self, system): - masses = [] - #print "---start---" - #for k in DEFAULT_MASSES: - # print k, DEFAULT_MASSES[k] - #print "---" - for i in range(system.number_of_atoms): - #print i, system.atomic_numbers[i], DEFAULT_MASSES[system.atomic_numbers[i]] - if not system.atomic_numbers[i] in DEFAULT_MASSES: - raise ValueError("Default mass not available for atomic number %d at atom number %d in %s!" % (system.atomic_numbers[i], i+1, system.filename)) - masses.append(DEFAULT_MASSES[system.atomic_numbers[i]]) - #print "--end--" - return np.array(masses) - - def apply_mass_rules(self, prev_masses, rules): - masses = list(prev_masses) - for k,v in rules.items(): - masses[k] = v - return masses - - def compile_mass_rules(self, iso_rules): - gs_rules = {} - ts_rules = {} - for replacement in iso_rules: - gs_atom_number, ts_atom_number, replacement_label = replacement - replacement_mass = REPLACEMENTS[replacement_label] - gs_rules[gs_atom_number-1] = replacement_mass - ts_rules[ts_atom_number-1] = replacement_mass - return gs_rules, ts_rules - - # make the requested isotopic substitutions - # yields tuples of tuples of the form ((gs_sub, gs_ref), (ts_sub, ts_ref)) - def make_isotopologues(self): - config = self.config - gs_system = self.gs_system - ts_system = self.ts_system - config.check(gs_system, ts_system) - - mass_override_gs_masses, mass_override_ts_masses = self.build_mass_override_masses() - - default_gs = quiver.Isotopologue("default", gs_system, mass_override_gs_masses) - default_ts = quiver.Isotopologue("default", ts_system, mass_override_ts_masses) - - for id_,iso in config.isotopologues.items(): - if id_ != config.mass_override_isotopologue: - gs_rules, ts_rules = self.compile_mass_rules(iso) - gs_masses = self.apply_mass_rules(mass_override_gs_masses, gs_rules) - ts_masses = self.apply_mass_rules(mass_override_ts_masses, ts_rules) - sub_gs = quiver.Isotopologue(id_, gs_system, gs_masses) - sub_ts = quiver.Isotopologue(id_, ts_system, ts_masses) - yield ((default_gs, sub_gs), (default_ts, sub_ts)) - - def __str__(self): - string = "\n=== PyQuiver Analysis ===\n" - if self.eie_flag == 0: - string += "Isotopologue uncorrected Wigner inverted parabola\n" - string += " KIE KIE KIE" - else: - string += "Isotopologue EIE" - keys = list(self.KIES.keys()) - if self.config.reference_isotopologue != "default" and self.config.reference_isotopologue != "none": - keys.remove(self.config.reference_isotopologue) - if self.config.mass_override_isotopologue != "default": - keys.remove(self.config.mass_override_isotopologue) - #keys.sort() - for name in keys: - string += "\n" + str(self.KIES[name]) - - if self.config.reference_isotopologue != "default" and self.config.reference_isotopologue != "none": - string += "\n\nKIEs referenced to isotopologue {0}, whose absolute KIEs are:".format(self.config.reference_isotopologue) - string += "\n" + str(self.KIES[self.config.reference_isotopologue]) - else: - string += "\n\nAbsolute KIEs are given." - - return string - -class KIE(object): - # the constructor expects a tuple of the form yielded by make_isotopologue - def __init__(self, name, gs_tuple, ts_tuple, temperature, scaling, imag_threshold): - # copy fields - # the associated calculation object useful for pulling config fields etc. - self.eie_flag = -1 - self.name = name - self.imag_threshold = imag_threshold - self.gs_tuple, self.ts_tuple = gs_tuple, ts_tuple - self.temperature = temperature - self.scaling = scaling - - if settings.DEBUG >= 2: - print("Calculating KIE for isotopologue {0}.".format(name)) - self.value = self.calculate_kie() - - def calculate_kie(self): - if settings.DEBUG >= 2: - print(" Calculating Reduced Partition Function Ratio for Ground State.") - rpfr_gs, gs_imag_ratios, gs_heavy_freqs, gs_light_freqs = calculate_rpfr(self.gs_tuple, self.imag_threshold, self.scaling, self.temperature) - if settings.DEBUG >= 2: - print(" rpfr_gs:", np.prod(rpfr_gs)) - if settings.DEBUG >= 2: - print(" Calculating Reduced Partition Function Ratio for Transition State.") - - rpfr_ts, ts_imag_ratios, ts_heavy_freqs, ts_light_freqs = calculate_rpfr(self.ts_tuple, self.imag_threshold, self.scaling, self.temperature) - if settings.DEBUG >= 2: - print(" rpfr_ts:", np.prod(rpfr_ts)) - - if ts_imag_ratios is not None: - if self.eie_flag == -1: - self.eie_flag = 0 - else: - raise ValueError("quiver attempted to run a KIE calculation after an EIE calculation. Check the frequency threshold.") - - kies = ts_imag_ratios * rpfr_gs/rpfr_ts - return kies - else: - if self.eie_flag == -1: - self.eie_flag = 1 - else: - raise ValueError("quiver attempted to run a KIE calculation after an EIE calculation. Check the frequency threshold.") - - eie = rpfr_gs/rpfr_ts - return eie - - def apply_reference(self, reference_kie): - self.value /= reference_kie.value - return self.value - - def __str__(self): - if self.value is not None: - if self.eie_flag == 1: - return "Isotopologue {1: >10s} {0: >33s} {2: ^12.4f} ".format("", self.name, self.value) - else: - return "Isotopologue {1: >10s} {0: >33s} {2: ^12.4f} {3: ^14.4f} {4: ^17.4f}".format("", self.name, self.value[0], self.value[1], self.value[2]) - else: - "KIE Object for isotopomer {0}. No value has been calculated yet.".format(self.name) - -# utility function to calculate u terms -# u = hv/kT where h = Planck's constant, v = frequency, kB = Boltzmann's constant, T = temperature -# if using v in wavenumber, w -# w = v/c, with c in cm/s -# v = cw -# u = hcw/kT, with c in cm/s -def u(wavenumber, temperature): - return h*c*wavenumber/(kB*temperature) - -# calculates the reduced isotopic function ratio for a species (Wolfsberg eqn 4.79) -# assuming the symmetry ratio is 1/1 -# uses the Teller-Redlich product rule -# returns 1 x n array of the partition function ratios, where n is the number of frequencies -# frequencies below frequency_threshold will be ignored and will not be included in the array -def partition_components(freqs_heavy, freqs_light, temperature): - components = [] - i = 0 - for wavenumber_light, wavenumber_heavy in zip(freqs_light, freqs_heavy): - i += 1 - product_factor = wavenumber_heavy/wavenumber_light - u_light = u(wavenumber_light, temperature) - u_heavy = u(wavenumber_heavy, temperature) - excitation_factor = (1.0-np.exp(-u_light))/(1.0-np.exp(-u_heavy)) - ZPE_factor = np.exp(0.5*(u_light-u_heavy)) - components.append([product_factor,excitation_factor,ZPE_factor]) - if settings.DEBUG >= 3: - overall_factor = product_factor * excitation_factor * ZPE_factor - print("MODE %3d LIGHT: %9.3f cm-1 HEAVY: %9.3f cm-1 FRQ RATIO: %9.5f ZPE FACTOR: %9.5f CONTRB TO RIPF: %9.5f" % (i, wavenumber_light, wavenumber_heavy, product_factor, ZPE_factor, overall_factor)) - #if overall_factor < 0.99 or overall_factor > 1.01: - # print " *" - #else: - # print - return np.array(components) - -# tup is a tuple of a form (light_isotopologue, heavy_isotopologue) -def calculate_rpfr(tup, imag_threshold, scaling, temperature): - # calculate_frequencies gives tuples of the form (small_freqs, imaginary_freqs, freqs) - light_small_freqs, light_imag_freqs, light_freqs, light_num_small = tup[0].calculate_frequencies(imag_threshold, scaling=scaling) - heavy_small_freqs, heavy_imag_freqs, heavy_freqs, heavy_num_small = tup[1].calculate_frequencies(imag_threshold, scaling=scaling) - - if len(heavy_freqs) != len(light_freqs): - raise ValueError("mismatch in the number of frequencies between isotopomers!") - if len(light_imag_freqs) != len(heavy_imag_freqs): - print("WARNING: mismatch in the number of imaginary frequencies between isotopomers, ignoring imaginary mode") - light_imag_freqs = [] - heavy_imag_freqs = [] - - if settings.DEBUG > 2: - print("light imaginary frequencies: ", end=' ') - if len(light_imag_freqs) == 0: - print("none", end=' ') - for i in light_imag_freqs: - print("%.3f " % i, end=' ') - print() - print("light small frequencies: ", end=' ') - for i in light_small_freqs: - print("%.1f " % i, end=' ') - print() - print("heavy imaginary frequencies: ", end=' ') - if len(heavy_imag_freqs) == 0: - print("none", end=' ') - for i in heavy_imag_freqs: - print("%.3f " % i, end=' ') - print() - print("heavy small frequencies: ", end=' ') - for i in heavy_small_freqs: - print("%.1f " % i, end=' ') - print() - - raw_imag_ratio = None - imag_ratios = None - try: - raw_imag_ratio = light_imag_freqs[0]/heavy_imag_freqs[0] - except IndexError: - pass - - if raw_imag_ratio: - wigner_imag_ratio = raw_imag_ratio * wigner(heavy_imag_freqs[0], light_imag_freqs[0], temperature) - bell_imag_ratio = raw_imag_ratio * bell(heavy_imag_freqs[0], light_imag_freqs[0], temperature) - imag_ratios = np.array([raw_imag_ratio, wigner_imag_ratio, bell_imag_ratio]) - - partition_factors = partition_components(heavy_freqs, light_freqs, temperature) - - if settings.DEBUG >= 2: - factors = np.prod(partition_factors, axis=0) - print("{3: ^8}Product Factor: {0}\n{3: ^8}Excitation Factor: {1}\n{3: ^8}ZPE Factor: {2}".format(factors[0], factors[1], factors[2], "")) - - return (np.prod(partition_factors), imag_ratios, np.array(heavy_freqs), np.array(light_freqs)) - -# calculates the Wigner tunnelling correction -# multiplies the KIE by a factor of (1+u_H^2/24)/(1+u_D^2/24) -# assumes the frequencies are sorted in ascending order -def wigner(ts_imag_heavy, ts_imag_light, temperature): - # calculate correction factor - if ts_imag_heavy < 0.0 and ts_imag_light < 0.0: - u_H = u(ts_imag_light, temperature) - u_D = u(ts_imag_heavy, temperature) - correction_factor = (1.0 + u_H**2 / 24.0) / (1.0 + u_D**2 / 24.0) - else: - raise ValueError("imaginary frequency passed to Wigner correction was real") - - return correction_factor - -# calculates the Bell inverted parabola tunneling correction -# multiplies the KIE by a factor of (u_H/u_D)*(sin(u_D/2)/sin(u_H/2)) -# assumes the frequencies are sorted in ascending order -def bell(ts_imag_heavy, ts_imag_light, temperature): - # calculate correction factor - if ts_imag_heavy < 0.0 and ts_imag_light < 0.0: - u_H = u(ts_imag_light, temperature) - u_D = u(ts_imag_heavy, temperature) - correction_factor = (u_H/u_D)*(np.sin(u_D/2.0)/np.sin(u_H/2.0)) - else: - raise ValueError("imaginary frequency passed to Bell correction was real") - return correction_factor +import pyquiver.kie as _mod +_sys.modules[__name__] = _mod diff --git a/src/orca.py b/src/orca.py index ced6b83..a8e35d9 100644 --- a/src/orca.py +++ b/src/orca.py @@ -1,100 +1,13 @@ -import re -import numpy as np +"""Deprecated shim. Import `pyquiver.orca` instead of `orca`. -from io import StringIO +Kept so that pre-packaging scripts (`from orca import ...`) keep working. +""" +import os as _os, sys as _sys, warnings as _warnings -DEBUG = False +_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) +_warnings.warn( + "'orca' is deprecated; import 'pyquiver.orca' instead.", + DeprecationWarning, stacklevel=2) -elemToNum = { - "H": 1, "He": 2, - "Li": 3, "Be": 4, "B": 5, "C": 6, "N": 7, "O": 8, "F": 9, "Ne": 10, - "Na": 11, "Mg": 12, "Al": 13, "Si": 14, "P": 15, "S": 16, "Cl": 17, "Ar": 18, - "K": 19, "Ca": 20, - "Sc": 21, "Ti": 22, "V": 23, "Cr": 24, "Mn": 25, "Fe": 26, "Co": 27, "Ni": 28, "Cu": 29, "Zn": 30, - "Ga": 31, "Ge": 32, "As": 33, "Se": 34, "Br": 35, "Kr": 36, - "Rb": 37, "Sr": 38, - "Y": 39, "Zr": 40, "Nb": 41, "Mo": 42, "Tc": 43, "Ru": 44, "Rh": 45, "Pd": 46, "Ag": 47, "Cd": 48, - "In": 49, "Sn": 50, "Sb": 51, "Te": 52, "I": 53, "Xe": 54, - "Cs": 55, "Ba": 56, - "La": 57, "Ce": 58, "Pr": 59, "Nd": 60, "Pm": 61, "Sm": 62, "Eu": 63, "Gd": 64, "Tb": 65, "Dy": 66, "Ho": 67, "Er": 68, "Tm": 69, "Yb": 70, "Lu": 71, - "Hf": 72, "Ta": 73, "W": 74, "Re": 75, "Os": 76, "Ir": 77, "Pt": 78, "Au": 79, "Hg": 80, - "Tl": 81, "Pb": 82, "Bi": 83, "Po": 84, "At": 85, "Rn": 86, - "Fr": 87, "Ra": 88, - "Ac": 89, "Th": 90, "Pa": 91, "U": 92, "Np": 93, "Pu": 94, "Am": 95, "Cm": 96, "Bk": 97, "Cf": 98, "Es": 99, "Fm": 100, "Md": 101, "No": 102, "Lr": 103, - "Rf": 104, "Db": 105, "Sg": 106, "Bh": 107, "Hs": 108, "Mt": 109, "Ds": 110, "Rg": 111, "Cn": 112 -} - - -def debug(s): - if DEBUG: - print("DEBUG: {}".format(s)) - -def bohr2angstrom(length): - return length*0.529177249 - -def parse_orca_output(out_data): - lines = out_data.split('\n') - - # Molecule position data - try: - atoms_line = next( - (n for n, line in enumerate(lines) - if line.startswith('$atoms'))) - except StopIteration: - raise ValueError("Could not find '$atoms' in output data.") - - number_of_atoms = int(lines[atoms_line+1]) - debug("number_of_atoms={}".format(number_of_atoms)) - - atomic_numbers = [] - positions = np.zeros(shape=(number_of_atoms, 3)) - for i, line in enumerate( - lines[atoms_line+2:atoms_line+number_of_atoms+2]): - _, atom, _, x, y, z = re.split(r'\s+', line) - - atomic_numbers.append(elemToNum[atom]) - positions[i][0] = bohr2angstrom(float(x)) - positions[i][1] = bohr2angstrom(float(y)) - positions[i][2] = bohr2angstrom(float(z)) - - debug("atomic_numbers={}".format(atomic_numbers)) - debug("positions={}".format(positions)) - - try: - hessian_line = next( - (n for n, line in enumerate(lines) - if line.startswith('$hessian'))) - except StopIteration: - raise ValueError("Could not find '$hessian' in output data.") - - hessian_size = int(lines[hessian_line+1]) - debug("Size of hessian: {}".format(hessian_size)) - - hessian = None - - for i, batch in enumerate(range(0, hessian_size, 5)): - batch_size = min(5, hessian_size-batch) - - s = StringIO(u"\n".join( - lines[hessian_line+(i*(hessian_size+1)+2): - hessian_line+((i+1)*(hessian_size+1))+2])) - - h = np.loadtxt(s, skiprows=1, - converters=dict((i+1, float) for i in range(batch_size)), - usecols=tuple(i+1 for i in range(batch_size)), - ndmin=2) - - hessian = h if hessian is None else np.hstack((hessian, h)) - - hessian = (hessian + hessian.T)/2 - debug("Hessian: {}".format(hessian)) - - return atomic_numbers, positions, hessian - - -if __name__ == '__main__': - DEBUG = True - import sys - with open(sys.argv[1], 'r') as f: - out_data = f.read() - parse_orca_output(out_data) +import pyquiver.orca as _mod +_sys.modules[__name__] = _mod diff --git a/src/quiver.py b/src/quiver.py index f43d679..ed9a474 100644 --- a/src/quiver.py +++ b/src/quiver.py @@ -1,406 +1,22 @@ -import argparse -import sys -import re -import os -import pickle -import math +"""Deprecated shim. Import ``pyquiver.quiver`` (or use the ``pyquiver`` +command / ``python -m pyquiver``) instead of running ``src/quiver.py``. -import numpy as np +Kept so that pre-packaging usage keeps working: + python src/quiver.py CONFIG GS TS + from quiver import System, Isotopologue +""" +import os as _os, sys as _sys, warnings as _warnings -import settings -from utility import proj, normalize, test_orthogonality, schmidt -from constants import DEFAULT_MASSES, PHYSICAL_CONSTANTS, LINEARITY_THRESHOLD, DROP_NUM_LINEAR -from config import Config - -# represents a geometric arrangement of atoms with specific masses -class Isotopologue(object): - def __init__(self, id_, system, masses): - self.name = id_ - self.system = system - self.masses = masses - self.frequencies = None - - self.number_of_atoms = system.number_of_atoms - self.mw_hessian = self.calculate_mw_hessian() - - def __str__(self): - returnString = "Isotopologue: %s\n" % self.name - returnString += " masses: %s" % self.masses.__str__() - return returnString - - def dump_debug(self, name, obj): - pass - #f = open(name+'_'+(self.system.filename)+'.pickle', 'w') - #ret = pickle.dump(obj, f) - #f.close() - - def calculate_mw_hessian(self): - #### old code - this is very inefficient because we calculate sqrt about a zillion times - #### better to call sqrt on the whole matrix and let numpy vectorize it - - #mw_hessian2 = np.zeros_like(hessian) - #mass_weights2=[] - #for i in range(0, 3*self.number_of_atoms): - # for j in range(0, 3*self.number_of_atoms): - # mass_weights2.append(1/(np.sqrt(masses3[i]*masses3[j]))) - # mw_hessian2[i,j] = hessian[i,j] / np.sqrt(masses3[i] * masses3[j] ) - - #### new code, a bit more efficient - - # scatter masses into 2D array of combinations - masses_ij = np.outer(np.array(self.masses), np.array(self.masses)) - - # call sqrt once! - # if we wanted we could take advantage of the symmetry of the matrix here... - # but numpy is fast enough that it really doesn't matter - inv_sqrt_masses = 1 / np.sqrt(masses_ij) - - # now we triple each dimension, to match the dimension of the hessian - inv_sqrt_masses = np.repeat(inv_sqrt_masses, 3, 0) - inv_sqrt_masses = np.repeat(inv_sqrt_masses, 3, 1) - - # and use matrix multiplication to generate mw_hessian - mass_weights = np.ravel(inv_sqrt_masses) - mw_hessian = self.system.hessian * inv_sqrt_masses - - #assert np.allclose(mw_hessian, mw_hessian2) - #assert all([a == b for a, b in zip(mass_weights, mass_weights2)]) - - if settings.DEBUG >= 3: - self.dump_debug("mw", mass_weights) - self.dump_debug("mw_hessian", mw_hessian) - - return mw_hessian - - def calculate_frequencies(self, imag_threshold, scaling=1.0, method="mass weighted hessian"): - # short circuit if frequencies have already been calculated - if self.frequencies is not None: - return self.frequencies - - if method == "mass weighted hessian": - imaginary_freqs = [] - small_freqs = [] - conv_factor = PHYSICAL_CONSTANTS['Eh']/(PHYSICAL_CONSTANTS['a0']**2 * PHYSICAL_CONSTANTS['amu']) - - v = np.linalg.eigvalsh(self.mw_hessian*conv_factor) - - constant = scaling / (2*np.pi*PHYSICAL_CONSTANTS['c']) - freqs = np.sqrt(np.abs(v)) * np.sign(v) * constant - - #freqs2 = [ np.copysign(np.sqrt(np.abs(freq)),freq) * constant for freq in v ] - #assert np.allclose(np.array(freqs), freqs2) - - freqs.sort() - - imaginary_freqs = [] - small_freqs = [] - regular_freqs = [] - - # detect imaginary frequencies - for f in freqs: - if f < -imag_threshold: - imaginary_freqs.append(f) - - if len(imaginary_freqs) > 1 and settings.DEBUG >= 2: - print("WARNING: multiple imaginaries") - - # strip the imaginary frequencies - freqs = freqs[len(imaginary_freqs):] - - if self.system.is_linear: - small_freqs = freqs[:DROP_NUM_LINEAR] - regular_freqs = freqs[DROP_NUM_LINEAR:] - else: - small_freqs = freqs[:1+DROP_NUM_LINEAR] - regular_freqs = freqs[1+DROP_NUM_LINEAR:] - - # bugfix 2/6/20: third argument is regular_freqs, not freqs! - self.frequencies = (small_freqs, imaginary_freqs, np.array(regular_freqs), len(small_freqs)) - if settings.DEBUG >= 3: - self.dump_debug("freqs", self.frequencies) - return self.frequencies - else: - raise ValueError("unknown frequency calculation type") - - -class System(object): - def __init__(self, outfile, style="g09"): - self.positions_angstrom = False - self.positions = False - self.atomic_numbers = False - self.number_of_atoms = False - self.hessian = False - self.is_linear = True - self.filename = outfile - - valid_styles = ["g09", "pyquiver", "orca"] - if style not in valid_styles: - raise ValueError("specified style, {0}, not supported".format(style)) - - if settings.DEBUG >= 1: - print("Reading data from {0}... with style {1}".format(outfile, style)) - - # assumes snip files worked correctly - if style == "g09" and not outfile.endswith(".snip") and not "Normal termination" in tail(outfile): - raise ValueError("Gaussian job %s terminated in an error" % outfile) - - self.filename = outfile - with open(outfile, 'r') as f: - out_data = f.read() - if style == "pyquiver": - lines = out_data.split("\n") - try: - number_of_atoms = int(lines[0]) - except ValueError: - raise ValueError("first line must contain integer number of atoms.") - self.number_of_atoms = number_of_atoms - - atomic_numbers = [0 for i in range(number_of_atoms)] - positions = np.zeros(shape=(number_of_atoms,3)) - - for l in lines[1:number_of_atoms+1]: - fields = l.split(',') - try: - center_number, atomic_number, x, y, z = fields - except ValueError: - raise ValueError("the following line in the geometry did not have the appropriate number of fields: {0}".format(l)) - center_number = int(center_number) - atomic_number = int(atomic_number) - - atomic_numbers[center_number] = atomic_number - positions[center_number][0] = x - positions[center_number][1] = y - positions[center_number][2] = z - - fcm_fields = lines[number_of_atoms+1].split(',') - hessian = self._parse_serial_lower_hessian(fcm_fields) - - if style == "g09": - # check that the verbose output option has been set - verbose_flag_present = re.search(r" *#[pP] ", out_data) - if verbose_flag_present == None and not outfile.lower().endswith(".snip"): - print() - print("Error: Gaussian output file %s" % outfile) - print("was not run with the verbose flag, so it does not contain enough information for") - print("PyQuiver to run. Please re-run this calculation with a route card that starts with #p") - print() - sys.exit(1) - - # read in the number of atoms - m = re.search("NAtoms\= +([0-9]+)", out_data) - if m: - number_of_atoms = int(m.group(1)) - else: - raise AttributeError("Number of atoms not detected.") - m = None - self.number_of_atoms = number_of_atoms - - # read in the last geometry (assumed cartesian coordinates) - atomic_numbers = [0 for i in range(number_of_atoms)] - positions = np.zeros(shape=(number_of_atoms,3)) - - # use standard orientation if possible - for m in re.finditer("Standard orientation(.+?)Rotational constants \(GHZ\)", out_data, re.DOTALL): - pass - - # for input files with nosymm keyword, use input orientation - if m is None: - for m in re.finditer("Input orientation(.+?)Distance matrix", out_data, re.DOTALL): - pass - if not m is None: - print("Couldn't find standard orientation so used input orientation instead.") - - if m is None: - for m in re.finditer("Input orientation(.+?)Rotational constants \(GHZ\)", out_data, re.DOTALL): - pass - if not m is None: - print("Couldn't find standard orientation so used input orientation instead.") - - # still couldn't find any geometries - if m is None: - raise AttributeError("Geometry table not detected.") - - def valid_geom_line_p(split_line): - if len(split_line) == 6: - try: - int(split_line[0]) - int(split_line[1]) - int(split_line[2]) - return True - except ValueError: - return False - return False - - for l in m.group(1).split('\n'): - raw_geom_line = l.split() - raw_geom_line = [_f for _f in l.split(' ') if _f] - - if valid_geom_line_p(raw_geom_line): - center_number = int(raw_geom_line[0]) - 1 - atomic_numbers[center_number] = int(raw_geom_line[1]) - for e in range(0,3): - positions[center_number][e] = raw_geom_line[3+e] - - # units = hartrees/bohr^2 - hessian = self._parse_g09_hessian(out_data) - - elif style == "orca": - from orca import parse_orca_output - atomic_numbers, positions, hessian = parse_orca_output(out_data) - self.number_of_atoms = len(atomic_numbers) - - - # copy fields - self.hessian = hessian - - self.positions_angstrom = positions - # convert position in angstroms to needed bohr - self.positions = positions/PHYSICAL_CONSTANTS['atb'] - - # detect if molecule is linear - # method: take every difference of two centers (that share center 0) - # calculate the dot product with other differences - - if self.number_of_atoms > 2: - detected_indep = 0 - for i in range(1,len(positions)-1): - for j in range(i+1, len(positions)): - # compares how parallel the unit vectors are - diff0i = positions[0] - positions[i] - diff0j = positions[0] - positions[j] - u = diff0i/np.linalg.norm(diff0i) - v = diff0j/np.linalg.norm(diff0j) - # if the vectors are (probably) linearly indep, we break - if 1 - np.abs(np.inner(u,v)) > LINEARITY_THRESHOLD: - self.is_linear = False - detected_indep = 1 - break - - if detected_indep: - break - linear_string = "linear" if self.is_linear else "not linear" - if settings.DEBUG >= 2: - print("Molecule is %s." % linear_string) - self.atomic_numbers = atomic_numbers - - def dump_debug(self, obj): - f = open('freqs_'+self.name+'.json', 'w') - out = json.dumps(self.frequencies) - f.write(out) - f.close() - - def _lower_triangle_serial_triangle(self,row,col): - if col > row: - return self._lower_triangle_serial_triangle(col,row) - triangle = lambda n: n*(n+1)/2 - return int(triangle(row) + col) - - # search for the archive at the end of the file - # then extract the force constant matrix - def _parse_g09_hessian(self, data): - #print("\n\nparsing\n\n") - # regex for finding text between 1\1\GINC and \@ - # DOTALL means that . will match newlines - # there are two capture groups here, which is why we have to use archive[0] later - raw_archive = re.findall(r"1\\1\\GINC(.+?)\\(\s*)@", data, re.DOTALL) - found_frequencies = False - for archive in raw_archive: - archive = re.sub('[\s+]', '', archive[0]) - #print(archive[:1000]) - #print("...") - #print(archive[-1000:]) - #print("---") - archive = re.search("NImag\=(.+?)$", archive, re.DOTALL) - #print(archive) - #print("*") - #print() - if archive: - found_frequencies = True - break - if not found_frequencies: - raise ValueError(f"No frequency job detected in {self.filename}.") - - raw_fcm = archive.group(0).split('\\')[2].split(',') - #print(raw_fcm) - self.raw_fcm = raw_fcm - fcm = self._parse_serial_lower_hessian(raw_fcm) - #print("\n\nsuccess\n\n") - return fcm - - def _parse_serial_lower_hessian(self, fields): - #fcm = np.zeros(shape=(3*self.number_of_atoms, 3*self.number_of_atoms)) - #for i in range(3*self.number_of_atoms): - # for j in range(3*self.number_of_atoms): - # fcm[i,j] = fields[self._lower_triangle_serial_triangle(i,j)] - - # compute index over entire grid at once - range1d = np.array(range(3*self.number_of_atoms), dtype=int) - xgrid, ygrid = np.meshgrid(range1d, range1d) - agrid = np.minimum(xgrid, ygrid) - bgrid = np.maximum(xgrid, ygrid) - idxs2 = (bgrid*(bgrid+1)/2 + agrid).astype(int) - - fcm2 = np.array(fields, dtype=float)[idxs2] - #assert np.allclose(fcm, fcm2) - - return fcm2 - - def _make_serial_hessian(self): - serial = "" - for i in range(self.number_of_atoms*3): - for j in range(0,i+1): - serial += str(self.hessian[i,j]) + "," - return serial - - def _make_serial_geometry(self): - serial = "" - for i in range(self.number_of_atoms): - serial += "{0},{1},{2},{3},{4}\n".format(i, self.atomic_numbers[i], self.positions_angstrom[i,0], self.positions_angstrom[i,1], self.positions_angstrom[i,2]) - return serial - - - def dump_pyquiver_input_file(self, extension=".qin"): - path = os.path.splitext(self.filename)[0] + extension - serial = str(self.number_of_atoms) + "\n" - serial += self._make_serial_geometry() - serial += self._make_serial_hessian() - - with open(path, 'w') as f: - f.write(serial) - - return serial +_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) if __name__ == "__main__": - parser = argparse.ArgumentParser(description="PyQuiver calculates KIEs and EIEs based on a ground and transition state file.") - parser.add_argument('-v', '--verbose', dest="debug", help='when the verbose flag is set debug information is printed', action='count') - parser.add_argument('-s', '--style', dest="style", default='g09', help='style of input files (g09, orca, or pyquiver)') - parser.add_argument('config', help='configuration file path') - parser.add_argument('gs', help='ground state file path') - parser.add_argument('ts', help='transition state file path') - - args = parser.parse_args() - if args.debug: - settings.DEBUG = args.debug + 1 - - from kie import KIE_Calculation - calc = KIE_Calculation(args.config, args.gs, args.ts, style=args.style) - print(calc) - - -def slugify(value): - return "".join(x for x in value if x.isalnum()) - -def tail(filename): -# with open(filename) as f: -# content = f.readlines() -# return content[-1].strip() - - # https://stackoverflow.com/questions/46258499/how-to-read-the-last-line-of-a-file-in-python - with open(filename, 'rb') as f: - try: # catch OSError in case of a one line file - f.seek(-2, os.SEEK_END) - while f.read(1) != b'\n': - f.seek(-2, os.SEEK_CUR) - except OSError: - f.seek(0) - return f.readline().decode() + _warnings.warn("Use the 'pyquiver' command or 'python -m pyquiver' " + "instead of 'python src/quiver.py'.", + DeprecationWarning, stacklevel=2) + from pyquiver.cli import main + main() +else: + _warnings.warn("'quiver' is deprecated; import 'pyquiver.quiver' instead.", + DeprecationWarning, stacklevel=2) + import pyquiver.quiver as _mod + _sys.modules[__name__] = _mod diff --git a/src/settings.py b/src/settings.py index a9f626b..d8fed19 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,6 +1,13 @@ -# DEBUG global variable -# 3 = dump files into cwd -# 2 = extra verbose - corresponds to -v flag on quiver or -vv flag on auto -# 1 = reasonable level of printing -# 0 = minimal printing -DEBUG = 0 +"""Deprecated shim. Import `pyquiver.settings` instead of `settings`. + +Kept so that pre-packaging scripts (`from settings import ...`) keep working. +""" +import os as _os, sys as _sys, warnings as _warnings + +_sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) +_warnings.warn( + "'settings' is deprecated; import 'pyquiver.settings' instead.", + DeprecationWarning, stacklevel=2) + +import pyquiver.settings as _mod +_sys.modules[__name__] = _mod diff --git a/src/utility.py b/src/utility.py deleted file mode 100644 index 58d03a8..0000000 --- a/src/utility.py +++ /dev/null @@ -1,44 +0,0 @@ -### This file contains utility functions used in quiver calculations. - -# Linear Algebra utility function - -def proj(u,v): - # project u onto v - return np.inner(v,u)/np.inner(u,u) * u - -def normalize(v): - norm=np.linalg.norm(v) - if norm < 1.0E-5: - raise ValueError - return v/norm - -def test_orthogonality(vectors): - mat = np.zeros(shape=(len(vectors),len(vectors))) - for i,u in enumerate(vectors): - for j,v in enumerate(vectors): - inner = np.inner(u,v) - if inner != 0.0 or inner != 1.0: - mat[i][j] = inner - print("Orthogonality:") - for l in mat: - print(l) - -def schmidt(seed_vectors, rest_vectors, dimension): - vectors = list(seed_vectors) - test_vectors = list(rest_vectors) - while len(vectors) < dimension: - try: - test_vector = test_vectors.pop() - for v in vectors: - test_vector -= proj(v, test_vector) - try: - vectors.append(normalize(test_vector)) - except ValueError: - pass - - except IndexError: - raise ValueError("Could not fill the appropriate dimensional space") - if len(vectors) < dimension: - raise ValueError("Could not fill the appropriate dimensional space") - else: - return vectors diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..d52a2ce --- /dev/null +++ b/test/README.md @@ -0,0 +1,27 @@ +# PyQuiver test suite + +Standard-library `unittest` tests (no extra dependencies beyond NumPy, which +PyQuiver already requires). Run from the repository root: + +```bash +python3 -m unittest discover -s test +``` + +or run a single module / verbose: + +```bash +python3 -m unittest test.test_regression -v +``` + +`pytest` also works if installed (`pytest test`). + +## What is covered + +| File | Scope | +|------|-------| +| `test_regression.py` | End-to-end KIE values for the Claisen tutorial (g09 + ORCA inputs) locked to known-good numbers. The safety net for refactoring. | +| `test_physics.py` | Pure functions: `u`, `wigner`, `bell`, `partition_components`. | +| `test_isotopologue.py` | Mass-weighting, frequency caching, and the reference-isotopologue caching invariant (the reference is diagonalized once, not once per substitution). | +| `test_parsers.py` | `quiver.System` parsing for g09, ORCA, and the native `.qin` format (including a round-trip check against the g09 Hessian). | + +`context.py` adds `src/` to `sys.path` and provides path/quiet helpers. diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..ea8eb3b --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,38 @@ +"""Shared pytest configuration and fixtures for the PyQuiver test suite. + +The ``pyquiver`` package lives at the repository root, so adding the root to +``sys.path`` makes ``import pyquiver`` resolve directly from the source tree +without requiring an install. pytest captures stdout by default, so the +package's chatty prints stay hidden unless a test fails. +""" + +import os +import sys + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, os.pardir)) +TUTORIAL = os.path.join(ROOT, "tutorial") + +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +@pytest.fixture +def tutorial(): + """Return a helper that builds paths into the tutorial/ directory.""" + def _path(*parts): + return os.path.join(TUTORIAL, *parts) + return _path + + +@pytest.fixture +def claisen_systems(): + """Parsed g09 ground- and transition-state Systems for the Claisen demo.""" + from pyquiver import quiver + gs = quiver.System(os.path.join(TUTORIAL, "gaussian", "claisen_gs.out"), + style="g09") + ts = quiver.System(os.path.join(TUTORIAL, "gaussian", "claisen_ts.out"), + style="g09") + return gs, ts diff --git a/test/test_batch.py b/test/test_batch.py new file mode 100644 index 0000000..ae804d1 --- /dev/null +++ b/test/test_batch.py @@ -0,0 +1,92 @@ +"""Tests for the batch runner (pyquiver.batch).""" + +import pytest + +from pyquiver import batch +from pyquiver.batch import BatchResults + +CONFIG = ("gaussian", "claisen_demo.config") +GS = ("gaussian", "claisen_gs.out") +TS = ("gaussian", "claisen_ts.out") + + +@pytest.fixture +def files(tutorial): + return tutorial(*CONFIG), tutorial(*GS), tutorial(*TS) + + +def test_pairs_as_mapping(files): + cfg, gs, ts = files + results = batch(cfg, {"a": (gs, ts), "b": (gs, ts)}) + assert set(results) == {"a", "b"} + assert len(results) == 2 + assert "C1" in results["a"].KIES # __getitem__ -> KIE_Calculation + + +def test_pairs_as_triples(files): + cfg, gs, ts = files + results = batch(cfg, [("low", gs, ts), ("high", gs, ts)]) + assert [label for label, _ in results.items()] == ["low", "high"] + + +def test_pairs_as_two_tuples_infer_label(files): + cfg, gs, ts = files + results = batch(cfg, [(gs, ts)]) + # label inferred from the gs filename without its extension + assert "claisen_gs" in results + + +def test_bad_pair_shape_rejected(files): + cfg, gs, ts = files + with pytest.raises(ValueError): + batch(cfg, [(gs,)]) + + +def test_to_records_and_dataframe(files): + cfg, gs, ts = files + results = batch(cfg, {"run": (gs, ts)}) + records = results.to_records() + assert {r["label"] for r in records} == {"run"} + assert "uncorrected" in records[0] + pd = pytest.importorskip("pandas") + df = results.to_dataframe() + assert "label" in df.columns and len(df) == len(records) + + +def test_to_csv(files, tmp_path): + cfg, gs, ts = files + results = batch(cfg, {"run": (gs, ts)}) + path = tmp_path / "out.csv" + text = results.to_csv(str(path)) + assert path.read_text() == text + assert text.splitlines()[0].startswith("label,") + + +def test_empty_batch_csv(): + assert BatchResults({}).to_csv() == "" + + +def test_to_dataframe_without_pandas(files, monkeypatch): + import builtins + real_import = builtins.__import__ + + def fake_import(name, *a, **k): + if name == "pandas": + raise ImportError("no pandas") + return real_import(name, *a, **k) + + cfg, gs, ts = files + results = batch(cfg, {"run": (gs, ts)}) + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(ImportError): + results.to_dataframe() + + +def test_skodje_truhlar_column(files): + cfg, gs, ts = files + # energies per label: (reactant, ts, product) in hartree + results = batch(cfg, {"run": (gs, ts)}, + energies={"run": (0.0, 0.02, 0.0)}) + by_iso = {r["name"]: r for r in results.to_records()} + assert "skodje_truhlar" in by_iso["C1"] + assert by_iso["C1"]["skodje_truhlar"] is not None diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..b25f3bd --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,34 @@ +"""Tests for the command-line entry point (pyquiver.cli).""" + +import pytest + +from pyquiver import cli + +CFG = ("gaussian", "claisen_demo.config") +GS = ("gaussian", "claisen_gs.out") +TS = ("gaussian", "claisen_ts.out") + + +def test_main_runs_and_prints(tutorial, capsys): + calc = cli.main([tutorial(*CFG), tutorial(*GS), tutorial(*TS), + "-s", "gaussian"]) + out = capsys.readouterr().out + assert "Isotopologue" in out + assert "C1" in calc.KIES + + +@pytest.mark.parametrize("extra", [["-v"], ["-vv"], ["-j", "2"]]) +def test_main_flag_variants(tutorial, extra): + calc = cli.main([tutorial(*CFG), tutorial(*GS), tutorial(*TS)] + extra) + assert calc.eie_flag == 0 + + +def test_main_bad_style_raises(tutorial): + with pytest.raises(ValueError): + cli.main([tutorial(*CFG), tutorial(*GS), tutorial(*TS), "-s", "nope"]) + + +def test_main_is_deprecated(tutorial, capsys): + with pytest.warns(DeprecationWarning): + cli.main([tutorial(*CFG), tutorial(*GS), tutorial(*TS)]) + assert "deprecated" in capsys.readouterr().err diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000..007ef52 --- /dev/null +++ b/test/test_config.py @@ -0,0 +1,238 @@ +"""Tests for the configuration-file parser (``config.Config``).""" + +import pytest + +from pyquiver.config import Config +from pyquiver import quiver + +MINIMAL = """\ +scaling 0.96 +temperature 300 +imag_threshold 50 +mass_override_isotopologue default +reference_isotopologue none +isotopologue C1 1 1 13C +""" + + +@pytest.fixture +def make_config(tmp_path): + """Return a helper that writes config text to a temp file and parses it.""" + counter = {"n": 0} + + def _make(text): + counter["n"] += 1 + path = tmp_path / ("c%d.config" % counter["n"]) + path.write_text(text) + return Config(str(path)) + return _make + + +def test_scalar_fields(make_config): + c = make_config(MINIMAL) + assert c.temperature == 300.0 + assert c.scaling == 0.96 + assert c.imag_threshold == 50.0 + assert c.reference_isotopologue == "none" + + +def test_single_isotopologue(make_config): + c = make_config(MINIMAL) + assert list(c.isotopologues) == ["C1"] + assert c.isotopologues["C1"] == [(1, 1, "13C")] + + +def test_deprecated_isotopomer_alias(make_config): + text = MINIMAL.replace("isotopologue C1", "isotopomer C1") \ + .replace("reference_isotopologue none", + "reference_isotopomer none") + assert "C1" in make_config(text).isotopologues + + +def test_multiple_replacements_one_isotopologue(make_config): + c = make_config(MINIMAL + "isotopologue C1 2 2 13C\n") + assert c.isotopologues["C1"] == [(1, 1, "13C"), (2, 2, "13C")] + + +def test_comments_and_blank_lines_ignored(make_config): + c = make_config("# a comment\n\n" + MINIMAL + " # trailing\n") + assert c.scaling == 0.96 + + +@pytest.mark.parametrize("mutation", [ + "isotopologue default 2 2 13C\n", # reserved name + "isotopologue C2 2 2 99Z\n", # invalid replacement +]) +def test_bad_isotopologue_rejected(make_config, mutation): + with pytest.raises(ValueError): + make_config(MINIMAL + mutation) + + +@pytest.mark.parametrize("old,new", [ + ("scaling 0.96", "scaling 2.0"), # out of range + ("temperature 300", "temperature -5"), # negative + ("imag_threshold 50", "imag_threshold 500"), # too high +]) +def test_out_of_range_fields_rejected(make_config, old, new): + with pytest.raises(ValueError): + make_config(MINIMAL.replace(old, new)) + + +@pytest.mark.parametrize("prefix", ["temperature", "isotopologue"]) +def test_missing_required_lines_rejected(make_config, prefix): + text = "\n".join(l for l in MINIMAL.splitlines() + if not l.startswith(prefix)) + "\n" + with pytest.raises(ValueError): + make_config(text) + + +def test_str_runs(make_config): + assert "Config file" in str(make_config(MINIMAL)) + + +def test_mass_override_isotopomer_alias(make_config): + text = MINIMAL + "mass_override_isotopomer C1\n" + assert make_config(text).mass_override_isotopologue == "C1" + + +@pytest.mark.parametrize("bad_line", [ + "isotopologue C1 1 1\n", # too few fields for an isotopologue + "isotopologue C2 0 1 13C\n", # atom number < 1 + "bogus_field value\n", # unknown 2-field config key + "one two three\n", # wrong number of fields entirely +]) +def test_malformed_lines_rejected(make_config, bad_line): + with pytest.raises(ValueError): + make_config(MINIMAL + bad_line) + + +def test_frequency_threshold_warned_and_shown(make_config, caplog): + import logging + with caplog.at_level(logging.WARNING, logger="pyquiver"): + c = make_config(MINIMAL + "frequency_threshold 50\n") + assert any("frequency_threshold" in m for m in caplog.messages) + assert "Frequency threshold" in str(c) + + +def test_str_reference_not_found_raises(make_config): + c = make_config(MINIMAL.replace("reference_isotopologue none", + "reference_isotopologue ZZ")) + with pytest.raises(ValueError): + str(c) + + +def test_from_dict_numeric_mass(tutorial): + # a bare number is used directly as an unusual mass (no weights.dat label) + from pyquiver.kie import KIE_Calculation + from pyquiver import System + cfg = Config.from_dict(isotopologues={"heavyC4": [(4, 4, 5000.0)]}, + temperature=393, scaling=0.9614, imag_threshold=50) + assert cfg.isotopologues["heavyC4"] == [(4, 4, 5000.0)] # numeric type preserved + gs = System(tutorial("gaussian", "claisen_gs.out"), style="gaussian") + ts = System(tutorial("gaussian", "claisen_ts.out"), style="gaussian") + calc = KIE_Calculation(cfg, gs, ts) + assert calc.results["heavyC4"].uncorrected > 1.5 # very heavy -> large KIE + + +def test_from_dict_negative_mass_rejected(): + with pytest.raises(ValueError): + Config.from_dict(isotopologues={"x": [(1, 1, -3.0)]}, + temperature=300, scaling=0.96, imag_threshold=50) + + +def test_config_file_numeric_mass(make_config): + c = make_config(MINIMAL + "isotopologue heavyC4 4 4 5000.0\n") + assert c.isotopologues["heavyC4"] == [(4, 4, 5000.0)] + + +def test_check_skips_element_match_for_numeric_mass(make_config, claisen_systems): + # a custom numeric mass carries no element identity, so check() must not + # try to validate the element and must not raise + gs, ts = claisen_systems + c = make_config(MINIMAL + "isotopologue heavyC4 4 4 5000.0\n") + c.check(gs, ts) # must not raise + + +def test_from_dict_atom_index_validated(): + with pytest.raises(ValueError): + Config.from_dict(isotopologues={"C1": [(0, 1, "13C")]}, + temperature=300, scaling=0.96, imag_threshold=50) + + +def test_check_verbose_logs(make_config, claisen_systems, caplog): + import logging + gs, ts = claisen_systems + with caplog.at_level(logging.INFO, logger="pyquiver"): + make_config(MINIMAL).check(gs, ts, verbose=True) + assert any("makes sense" in m for m in caplog.messages) + + +def test_check_ts_mismatch_rejected(make_config): + # gs atom matches the replacement element but the ts atom does not + class Stub: + def __init__(self, znums): + self.atomic_numbers = znums + self.filename = "stub" + c = make_config(MINIMAL) # isotopologue C1: gs atom 1 -> 13C (Z=6) + gs = Stub([6]) # carbon, matches + ts = Stub([7]) # nitrogen, mismatch + with pytest.raises(ValueError): + c.check(gs, ts) + + +def test_check_valid_config_passes(make_config, claisen_systems): + gs, ts = claisen_systems + make_config(MINIMAL).check(gs, ts) # must not raise + + +def test_from_dict_builds_without_file(): + c = Config.from_dict( + isotopologues={"C1": [(1, 1, "13C")]}, + temperature=300, scaling=0.96, imag_threshold=50) + assert c.temperature == 300.0 + assert c.scaling == 0.96 + assert c.reference_isotopologue == "none" + assert c.isotopologues["C1"] == [(1, 1, "13C")] + + +def test_from_dict_matches_file(make_config): + from_file = make_config(MINIMAL) + from_dict = Config.from_dict( + isotopologues={"C1": [(1, 1, "13C")]}, + temperature=300, scaling=0.96, imag_threshold=50) + assert from_dict.isotopologues == from_file.isotopologues + assert from_dict.temperature == from_file.temperature + assert from_dict.scaling == from_file.scaling + + +@pytest.mark.parametrize("kwargs", [ + dict(isotopologues={"C1": [(1, 1, "99Z")]}, temperature=300, scaling=0.96, imag_threshold=50), + dict(isotopologues={"default": [(1, 1, "13C")]}, temperature=300, scaling=0.96, imag_threshold=50), + dict(isotopologues={"C1": [(1, 1, "13C")]}, temperature=300, scaling=2.0, imag_threshold=50), + dict(isotopologues={}, temperature=300, scaling=0.96, imag_threshold=50), +]) +def test_from_dict_validates(kwargs): + with pytest.raises(ValueError): + Config.from_dict(**kwargs) + + +def test_from_dict_usable_in_calculation(tutorial): + # a programmatic Config should drive a real calculation + from pyquiver.kie import KIE_Calculation + cfg = Config.from_dict(isotopologues={"C1": [(1, 1, "13C")]}, + temperature=393, scaling=0.9614, imag_threshold=50) + gs = quiver.System(tutorial("gaussian", "claisen_gs.out"), style="gaussian") + ts = quiver.System(tutorial("gaussian", "claisen_ts.out"), style="gaussian") + calc = KIE_Calculation(cfg, gs, ts) + # reference defaults to "none", so this is the absolute (un-referenced) KIE + assert calc.results["C1"].uncorrected == pytest.approx(1.0127464, abs=1e-5) + + +def test_check_element_mismatch_rejected(make_config, claisen_systems): + # atom 1 of the Claisen system is carbon; labelling it 2D (hydrogen) is + # an element mismatch that check() must reject + gs, ts = claisen_systems + c = make_config(MINIMAL.replace("isotopologue C1 1 1 13C", + "isotopologue X 1 1 2D")) + with pytest.raises(ValueError): + c.check(gs, ts) diff --git a/test/test_constants.py b/test/test_constants.py new file mode 100644 index 0000000..41f47e1 --- /dev/null +++ b/test/test_constants.py @@ -0,0 +1,112 @@ +"""Tests for the physical-constant and atomic-weight tables in ``constants.py`` +(loaded from ``weights.dat``).""" + +import pytest + +from pyquiver.constants import (DEFAULT_MASSES, REPLACEMENTS, REPLACEMENTS_Z, + PHYSICAL_CONSTANTS, LINEARITY_THRESHOLD, DROP_NUM_LINEAR, + Element, replacement_mass) + + +@pytest.mark.parametrize("z,mass", [(1, 1.0078), (6, 12.0), (8, 15.9949)]) +def test_default_masses(z, mass): + assert DEFAULT_MASSES[z] == pytest.approx(mass, abs=1e-3) + + +@pytest.mark.parametrize("label", ["13C", "2D", "17O"]) +def test_known_replacements_present(label): + assert label in REPLACEMENTS + + +@pytest.mark.parametrize("label,mass", [("13C", 13.0033), ("2D", 2.0141)]) +def test_replacement_masses(label, mass): + assert REPLACEMENTS[label] == pytest.approx(mass, abs=1e-3) + + +def test_replacement_mass_resolver(): + assert replacement_mass("13C") == pytest.approx(13.00335, abs=1e-4) # label + assert replacement_mass(5000.0) == 5000.0 # numeric + for bad in (True, -1.0, "nope"): + with pytest.raises(ValueError): + replacement_mass(bad) + + +def test_replacement_heavier_than_default(): + assert REPLACEMENTS["13C"] > DEFAULT_MASSES[6] + + +@pytest.mark.parametrize("label,z", [("13C", 6), ("2D", 1), ("17O", 8)]) +def test_replacement_atomic_numbers(label, z): + assert REPLACEMENTS_Z[label] == z + + +@pytest.mark.parametrize("key", ["h", "c", "kB", "Eh", "a0", "amu", "atb"]) +def test_physical_constant_present(key): + assert key in PHYSICAL_CONSTANTS + + +@pytest.mark.parametrize("key", ["h", "c", "kB"]) +def test_physical_constants_positive(key): + assert PHYSICAL_CONSTANTS[key] > 0.0 + + +def test_linearity_parameters(): + assert LINEARITY_THRESHOLD > 0.0 + # five modes (3 translation + 2 rotation) are dropped for linear cases + assert DROP_NUM_LINEAR == 5 + + +# --- Element class ----------------------------------------------------------- + +def test_element_construction(): + e = Element("carbon", 6, "C", 12.0) + assert e.full_name == "carbon" + assert e.symbol == "C" + assert e.atomic_number == 6 + assert e.default_mass == pytest.approx(12.0) + assert e.replacements == [] + + +def test_element_add_replacement(): + e = Element("carbon", 6, "C", 12.0) + e.add_replacement("13C", 13.00335) + assert ("13C", pytest.approx(13.00335)) in e.replacements + + +def test_element_str_runs(): + e = Element("carbon", 6, "C", 12.0) + assert "Carbon" in str(e) + e.add_replacement("13C", 13.00335) + assert "13C" in str(e) + + +def test_element_duplicate_replacement_raises(): + e = Element("carbon", 6, "C", 12.0) + e.add_replacement("13C", 13.00335) + with pytest.raises(ValueError): + e.add_replacement("13C", 13.00335) + + +@pytest.mark.parametrize("bad", [ + dict(full_name="Carbon", atomic_number=6, symbol="C", default_mass=12.0), # non-lowercase name + dict(full_name="carbon", atomic_number=6, symbol="1", default_mass=12.0), # non-letter symbol + dict(full_name="carbon", atomic_number=0, symbol="C", default_mass=12.0), # bad atomic number + dict(full_name="carbon", atomic_number=6, symbol="CCC", default_mass=12.0), # symbol too long + dict(full_name="carbon", atomic_number=6, symbol="C", default_mass=999.0), # mass out of range +]) +def test_element_invalid_values_raise(bad): + with pytest.raises(ValueError): + Element(**bad) + + +@pytest.mark.parametrize("symbol", ["&bad", "ABCDE"]) +def test_element_add_replacement_validation(symbol): + e = Element("carbon", 6, "C", 12.0) + with pytest.raises(ValueError): + e.add_replacement(symbol, 13.0) + + +def test_element_add_replacement_bad_mass(): + e = Element("carbon", 6, "C", 12.0) + with pytest.raises(ValueError): + e.add_replacement("13C", 999.0) diff --git a/test/test_edge_cases.py b/test/test_edge_cases.py new file mode 100644 index 0000000..f7d5eec --- /dev/null +++ b/test/test_edge_cases.py @@ -0,0 +1,232 @@ +"""Error-path and less-common-branch coverage.""" + +import logging + +import numpy as np +import pytest + +from pyquiver import quiver +from pyquiver.config import Config +from pyquiver.kie import KIE_Calculation +from pyquiver.kie import calculate_rpfr +from pyquiver.parsers.orca import parse_orca_output + +CFG = ("gaussian", "claisen_demo.config") +GS = ("gaussian", "claisen_gs.out") +TS = ("gaussian", "claisen_ts.out") + + +# --- Results container ------------------------------------------------------- + +def test_results_iter_len_and_missing(tutorial): + calc = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian") + res = calc.results + assert len(res) == len(list(res)) + assert {r.name for r in res} == set(calc.KIES) + with pytest.raises(KeyError): + res["does-not-exist"] + + +def test_to_dataframe_without_pandas(monkeypatch, tutorial): + # simulate pandas being unavailable + import builtins + real_import = builtins.__import__ + + def fake_import(name, *a, **k): + if name == "pandas": + raise ImportError("no pandas") + return real_import(name, *a, **k) + + monkeypatch.setattr(builtins, "__import__", fake_import) + calc = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian") + with pytest.raises(ImportError): + calc.to_dataframe() + + +# --- KIE_Calculation argument validation ------------------------------------- + +def test_bad_config_type(tutorial): + with pytest.raises(TypeError): + KIE_Calculation(123, tutorial(*GS), tutorial(*TS)) + + +def test_bad_gs_type(tutorial): + with pytest.raises(TypeError): + KIE_Calculation(tutorial(*CFG), 123, tutorial(*TS), style="gaussian") + + +def test_bad_ts_type(tutorial): + with pytest.raises(TypeError): + KIE_Calculation(tutorial(*CFG), tutorial(*GS), 123, style="gaussian") + + +def test_frequency_threshold_warning(tutorial, caplog): + # a config carrying the deprecated frequency_threshold warns when used + cfg = Config.from_dict(isotopologues={"C1": [(1, 1, "13C")]}, + temperature=393, scaling=0.9614, imag_threshold=50) + cfg.frequency_threshold = 50.0 + gs = quiver.System(tutorial("gaussian", "claisen_gs.out"), style="gaussian") + ts = quiver.System(tutorial("gaussian", "claisen_ts.out"), style="gaussian") + with caplog.at_level(logging.WARNING, logger="pyquiver"): + KIE_Calculation(cfg, gs, ts) + assert any("frequency_threshold" in m for m in caplog.messages) + + +# --- get_row with tunnelling + debug logging branches ------------------------ + +def test_get_row_report_tunnelling(tutorial): + calc = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian") + title, row, eie = calc.get_row(report_tunnelling=True) + assert "_uncorr" in title and "_inf_para" in title + assert eie == 0 + + +def test_debug_logging_paths(tutorial, caplog): + # exercise the logger.isEnabledFor(DEBUG) branches in calculate_rpfr / + # partition_components and the per-mode dump + with caplog.at_level(logging.DEBUG, logger="pyquiver"): + KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian") + assert any("MODE" in m or "Factor" in m for m in caplog.messages) + + +# --- mass override isotopologue path ----------------------------------------- + +def test_mass_override_isotopologue(tutorial): + cfg = Config.from_dict( + isotopologues={"C1": [(1, 1, "13C")], "C4": [(4, 4, "13C")]}, + temperature=393, scaling=0.9614, imag_threshold=50, + reference_isotopologue="C1", mass_override_isotopologue="C4") + gs = quiver.System(tutorial("gaussian", "claisen_gs.out"), style="gaussian") + ts = quiver.System(tutorial("gaussian", "claisen_ts.out"), style="gaussian") + calc = KIE_Calculation(cfg, gs, ts) + assert "C1" in calc.KIES + # get_row and __str__ both drop the reference and mass-override columns + title, row, eie = calc.get_row() + assert "C4" not in title and "C1" not in title + assert "Isotopologue" in str(calc) + + +def test_str_with_and_without_reference(tutorial): + # with a reference (C5) -> "referenced to" section + ref = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian") + assert "referenced to isotopologue C5" in str(ref) + + # reference "none" -> "Absolute KIEs" section + cfg = Config.from_dict(isotopologues={"C1": [(1, 1, "13C")]}, + temperature=393, scaling=0.9614, imag_threshold=50) + gs = quiver.System(tutorial("gaussian", "claisen_gs.out"), style="gaussian") + ts = quiver.System(tutorial("gaussian", "claisen_ts.out"), style="gaussian") + assert "Absolute KIEs" in str(KIE_Calculation(cfg, gs, ts)) + + +def test_get_row_eie(tutorial): + # EIE (gs as both endpoints) -> single value column in get_row + calc = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*GS), + style="gaussian") + title, row, eie = calc.get_row() + assert eie == 1 + assert row # EIE values rendered + + +def test_build_default_masses_missing_mass(tutorial): + calc = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian") + + class Stub: + number_of_atoms = 1 + atomic_numbers = [200] # not present in weights.dat + filename = "stub" + + with pytest.raises(ValueError): + calc.build_default_masses(Stub()) + + +# --- Isotopologue / frequency branches --------------------------------------- + +class _FakeSystem: + def __init__(self, hessian, is_linear=False): + self.hessian = np.asarray(hessian, dtype=float) + self.number_of_atoms = self.hessian.shape[0] // 3 + self.is_linear = is_linear + + +def test_isotopologue_str(): + iso = quiver.Isotopologue("x", _FakeSystem(np.eye(3)), [12.0]) + assert "Isotopologue: x" in str(iso) + + +def test_calculate_frequencies_unknown_method(): + iso = quiver.Isotopologue("x", _FakeSystem(np.eye(6)), [12.0, 12.0]) + with pytest.raises(ValueError): + iso.calculate_frequencies(imag_threshold=50, method="bogus") + + +def test_calculate_frequencies_linear_branch(): + iso = quiver.Isotopologue("x", _FakeSystem(np.eye(6), is_linear=True), + [12.0, 12.0]) + result = iso.calculate_frequencies(imag_threshold=50) + assert len(result) == 4 + + +def test_multiple_imaginary_warning(caplog): + # a Hessian with two strongly negative eigenvalues yields two imaginary + # frequencies, which logs a warning + hess = np.diag([-1.0, -1.0, 1.0, 1.0, 1.0, 1.0]) + iso = quiver.Isotopologue("x", _FakeSystem(hess), [12.0, 12.0]) + with caplog.at_level(logging.WARNING, logger="pyquiver"): + iso.calculate_frequencies(imag_threshold=50) + assert any("imaginary" in m for m in caplog.messages) + + +# --- native (.qin) parser errors --------------------------------------------- + +def test_native_bad_atom_count(tmp_path): + p = tmp_path / "bad.qin" + p.write_text("notanumber\n") + with pytest.raises(ValueError): + quiver.System(str(p), style="native") + + +def test_native_bad_geometry_line(tmp_path): + p = tmp_path / "bad.qin" + p.write_text("1\n0,6,0.0\n0.0,\n") # geometry line has too few fields + with pytest.raises(ValueError): + quiver.System(str(p), style="native") + + +# --- ORCA parser errors ------------------------------------------------------ + +def test_orca_missing_atoms(): + with pytest.raises(ValueError): + parse_orca_output("nothing useful here") + + +def test_orca_missing_hessian(): + data = "$atoms\n1\n C 1.0 0.0 0.0 0.0\n" + with pytest.raises(ValueError): + parse_orca_output(data) + + +# --- calculate_rpfr imaginary-frequency-count mismatch ----------------------- + +class _FreqStub: + def __init__(self, small, imag, freqs): + self._r = (small, imag, np.array(freqs, dtype=float), len(small)) + + def calculate_frequencies(self, imag_threshold, scaling=1.0): + return self._r + + +def test_calculate_rpfr_imaginary_mismatch_warns(caplog): + # light has one imaginary mode, heavy has none -> warn and drop it + light = _FreqStub([], [-500.0], [1000.0, 1100.0]) + heavy = _FreqStub([], [], [1000.0, 1100.0]) + with caplog.at_level(logging.WARNING, logger="pyquiver"): + rpfr, imag_ratios, _, _ = calculate_rpfr((light, heavy), 50, 1.0, 300.0) + assert any("imaginary" in m for m in caplog.messages) + assert imag_ratios is None # imaginary mode was dropped diff --git a/test/test_gaussian_errors.py b/test/test_gaussian_errors.py new file mode 100644 index 0000000..de2e5b1 --- /dev/null +++ b/test/test_gaussian_errors.py @@ -0,0 +1,88 @@ +"""Error and fallback paths in the Gaussian parser, using crafted minimal +output files.""" + +import pytest + +from pyquiver import quiver +from pyquiver.parsers import gaussian + + +def _write(tmp_path, text, name="job.out"): + p = tmp_path / name + p.write_text(text) + return str(p) + + +def test_tail_handles_tiny_file(tmp_path): + # a file too short to seek backwards exercises the OSError fallback in _tail + p = tmp_path / "tiny.out" + p.write_text("x") + assert gaussian._tail(str(p)) == "x" + + +def test_terminated_in_error(tmp_path): + path = _write(tmp_path, "some output\njob died\n") + with pytest.raises(ValueError): + quiver.System(path, style="gaussian") + + +def test_missing_verbose_flag(tmp_path): + path = _write(tmp_path, "output line\nNormal termination of Gaussian\n") + with pytest.raises(ValueError): + quiver.System(path, style="gaussian") + + +def test_missing_natoms(tmp_path): + path = _write(tmp_path, " #p \nNormal termination\n") + with pytest.raises(ValueError): + quiver.System(path, style="gaussian") + + +def test_missing_geometry(tmp_path): + path = _write(tmp_path, " #p \nNAtoms= 1 \nNormal termination\n") + with pytest.raises(ValueError): + quiver.System(path, style="gaussian") + + +# a standard-orientation geometry but no frequency archive -> ValueError, after +# exercising the geometry-table parsing +STD_ORIENT = ( + " #p \n" + "NAtoms= 1 \n" + "Standard orientation\n" + " 1 6 0 0.000000 0.000000 0.000000\n" + " Rotational constants (GHZ)\n" + "Normal termination\n" +) + + +def test_no_frequency_archive(tmp_path): + with pytest.raises(ValueError): + quiver.System(_write(tmp_path, STD_ORIENT), style="gaussian") + + +# input-orientation fallbacks (no standard orientation present) +INPUT_ORIENT_DISTANCE = ( + " #p \n" + "NAtoms= 1 \n" + "Input orientation\n" + " 1 6 0 0.000000 0.000000 0.000000\n" + " Distance matrix\n" + "Normal termination\n" +) + +INPUT_ORIENT_ROT = ( + " #p \n" + "NAtoms= 1 \n" + "Input orientation\n" + " 1 6 0 0.000000 0.000000 0.000000\n" + " Rotational constants (GHZ)\n" + "Normal termination\n" +) + + +@pytest.mark.parametrize("text", [INPUT_ORIENT_DISTANCE, INPUT_ORIENT_ROT]) +def test_input_orientation_fallbacks(tmp_path, text): + # geometry is found via input orientation, then the missing archive raises + with pytest.raises(ValueError): + quiver.System(_write(tmp_path, text), style="gaussian") diff --git a/test/test_isotopologue.py b/test/test_isotopologue.py new file mode 100644 index 0000000..35765ad --- /dev/null +++ b/test/test_isotopologue.py @@ -0,0 +1,97 @@ +"""Tests for mass-weighting, frequency calculation, and the caching +behaviour of the reference isotopologue. + +The caching test is the regression guard for the performance discussion: +when several isotopologues share one config, the reference ("default") +isotopologue must be diagonalized only once, not once per substitution. +""" + +from unittest import mock + +import numpy as np +import pytest + +from pyquiver import quiver +from pyquiver.kie import KIE_Calculation + + +class FakeSystem: + """Minimal stand-in for quiver.System for unit-testing Isotopologue.""" + def __init__(self, hessian, is_linear=False): + self.hessian = np.asarray(hessian, dtype=float) + self.number_of_atoms = self.hessian.shape[0] // 3 + self.is_linear = is_linear + + +# --- mass weighting ---------------------------------------------------------- + +def test_mass_weighted_hessian(): + # two atoms -> 6x6 hessian; masses 2 and 8 amu + n = 6 + hessian = np.arange(1, n * n + 1, dtype=float).reshape(n, n) + hessian = (hessian + hessian.T) / 2.0 + masses = [2.0, 8.0] + iso = quiver.Isotopologue("t", FakeSystem(hessian), masses) + + m3 = np.repeat(masses, 3) + expected = hessian / np.sqrt(np.outer(m3, m3)) + assert np.allclose(iso.mw_hessian, expected) + + +def test_mass_weighting_preserves_symmetry(): + n = 3 + h = np.arange(1, n * n + 1, dtype=float).reshape(n, n) + h = h + h.T + iso = quiver.Isotopologue("t", FakeSystem(h), [5.0]) + assert np.allclose(iso.mw_hessian, iso.mw_hessian.T) + + +# --- frequency calculation + per-object caching ------------------------------ + +def _make_iso(): + band = np.array([[6, 1, 0, 0, 0, 0], + [1, 6, 1, 0, 0, 0], + [0, 1, 6, 1, 0, 0], + [0, 0, 1, 6, 1, 0], + [0, 0, 0, 1, 6, 1], + [0, 0, 0, 0, 1, 6]], dtype=float) + return quiver.Isotopologue("t", FakeSystem(band), [12.0, 12.0]) + + +def test_frequencies_returns_four_tuple(): + small, imag, regular, n_small = _make_iso().calculate_frequencies( + imag_threshold=50) + assert isinstance(regular, np.ndarray) + assert n_small == len(small) + + +def test_frequencies_second_call_is_cached(): + iso = _make_iso() + first = iso.calculate_frequencies(imag_threshold=50) + second = iso.calculate_frequencies(imag_threshold=50) + # the short-circuit must hand back the very same cached object + assert first is second + + +# --- reference isotopologue is diagonalized once, not per substitution -------- + +def test_reference_diagonalized_once(tutorial): + cfg = tutorial("gaussian", "claisen_demo.config") + gs = tutorial("gaussian", "claisen_gs.out") + ts = tutorial("gaussian", "claisen_ts.out") + + real_eigvalsh = np.linalg.eigvalsh + calls = [] + + def counting_eigvalsh(*a, **k): + calls.append(1) + return real_eigvalsh(*a, **k) + + with mock.patch("numpy.linalg.eigvalsh", side_effect=counting_eigvalsh): + calc = KIE_Calculation(cfg, gs, ts, style="g09") + + n_iso = len(calc.config.isotopologues) + # cached: 2 reference diagonalizations (gs + ts) + 2 per substitution; + # the naive recompute-the-reference path would be 4 per substitution + assert len(calls) == 2 + 2 * n_iso + assert len(calls) < 4 * n_iso diff --git a/test/test_kie.py b/test/test_kie.py new file mode 100644 index 0000000..308e9c2 --- /dev/null +++ b/test/test_kie.py @@ -0,0 +1,92 @@ +"""Tests for the KIE/EIE orchestration in ``kie.py`` (the KIE class and the +KIE_Calculation driver), beyond the pure physics functions.""" + +import numpy as np +import pytest + +from pyquiver.kie import KIE_Calculation +from pyquiver.config import Config +from pyquiver import quiver + +CFG = ("gaussian", "claisen_demo.config") +GS = ("gaussian", "claisen_gs.out") +TS = ("gaussian", "claisen_ts.out") + + +@pytest.fixture +def kie(tutorial): + """Run a KIE_Calculation; pass which tutorial files to use as gs and ts.""" + def _run(gs=GS, ts=TS): + return KIE_Calculation(tutorial(*CFG), tutorial(*gs), tutorial(*ts), + style="g09") + return _run + + +def test_transition_state_is_kie(kie): + # a real transition state has an imaginary mode -> KIE (eie_flag 0), and + # each value is a 3-vector (raw, Wigner, inverted parabola) + calc = kie() + assert calc.eie_flag == 0 + assert len(calc.KIES["C1"].value) == 3 + + +def test_no_imaginary_mode_is_eie(kie): + # using the ground state as both endpoints -> no imaginary mode -> EIE + calc = kie(gs=GS, ts=GS) + assert calc.eie_flag == 1 + assert np.ndim(calc.KIES["C1"].value) == 0 + + +def test_eie_of_identical_endpoints_is_unity(kie): + calc = kie(gs=GS, ts=GS) + assert float(calc.KIES["C1"].value) == pytest.approx(1.0, abs=1e-6) + + +def test_reference_isotopologue_kept_absolute(kie): + # the config references everything to C5; the reference entry itself is + # left as its absolute value (~1.0019 for the raw column), not divided + calc = kie() + assert calc.config.reference_isotopologue == "C5" + assert float(calc.KIES["C5"].value[0]) == pytest.approx(1.001895, abs=1e-5) + + +@pytest.mark.parametrize("endpoints", [(GS, TS), (GS, GS)]) +def test_str_runs(kie, endpoints): + assert "Isotopologue" in str(kie(*endpoints)) + + +def test_get_row_shapes(kie): + title_row, row, eie_p = kie().get_row() + assert eie_p == 0 + # the reference (C5) is excluded from the output columns + assert "C5" not in title_row + # one comma-separated value per reported isotopologue + n_titles = len([t for t in title_row.split(",") if t]) + n_values = len([v for v in row.split(",") if v]) + assert n_titles == n_values + + +def test_accepts_prebuilt_objects(tutorial): + # KIE_Calculation accepts Config and System objects, not just file paths; + # the result must match the path-based calculation + cfg = Config(tutorial(*CFG)) + gs = quiver.System(tutorial(*GS), style="g09") + ts = quiver.System(tutorial(*TS), style="g09") + from_objects = KIE_Calculation(cfg, gs, ts) + from_paths = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="g09") + assert [float(v) for v in from_objects.KIES["C1"].value] == \ + pytest.approx([float(v) for v in from_paths.KIES["C1"].value]) + + +def test_rejects_bad_argument_types(tutorial): + with pytest.raises(TypeError): + KIE_Calculation(123, tutorial(*GS), tutorial(*TS), style="g09") + + +def test_apply_reference_divides(kie): + calc = kie() + # apply_reference divides this KIE's value in place by the reference's; + # dividing by itself yields ones in every column + result = calc.KIES["C1"].apply_reference(calc.KIES["C1"]) + assert np.allclose(result, 1.0) diff --git a/test/test_package.py b/test/test_package.py new file mode 100644 index 0000000..a3f5752 --- /dev/null +++ b/test/test_package.py @@ -0,0 +1,27 @@ +"""Package-level smoke tests: public API and back-compat re-exports.""" + +import pyquiver + + +def test_public_api_exports(): + for name in pyquiver.__all__: + assert hasattr(pyquiver, name), name + + +def test_version_string(): + v = pyquiver.__version__ + assert isinstance(v, str) + # either a real version (installed) or the documented source-checkout fallback + assert "." in v or v == "0+unknown" + + +def test_orca_backcompat_reexport(): + # pyquiver.orca.parse_orca_output must remain importable + from pyquiver.orca import parse_orca_output, parse + assert callable(parse_orca_output) + assert callable(parse) + + +def test_settings_backcompat(): + from pyquiver import settings + assert settings.DEBUG == 0 diff --git a/test/test_parallel.py b/test/test_parallel.py new file mode 100644 index 0000000..32ab0cc --- /dev/null +++ b/test/test_parallel.py @@ -0,0 +1,46 @@ +"""The optional n_jobs parallelism must not change results. + +eigvalsh releases the GIL, so threads parallelize the per-isotopologue work; +this checks that serial and threaded runs agree exactly, and that the +reference isotopologue is still diagonalized only once (pre-warmed before the +fan-out). +""" + +from unittest import mock + +import numpy as np +import pytest + +from pyquiver.kie import KIE_Calculation + +CFG = ("gaussian", "claisen_demo.config") +GS = ("gaussian", "claisen_gs.out") +TS = ("gaussian", "claisen_ts.out") + + +@pytest.mark.parametrize("n_jobs", [2, 4, -1]) +def test_parallel_matches_serial(tutorial, n_jobs): + serial = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian", n_jobs=1) + parallel = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian", n_jobs=n_jobs) + assert list(serial.KIES) == list(parallel.KIES) # order preserved + for name in serial.KIES: + assert np.allclose(serial.KIES[name].value, parallel.KIES[name].value) + + +def test_reference_still_diagonalized_once_in_parallel(tutorial): + real_eigvalsh = np.linalg.eigvalsh + calls = [] + + def counting(*a, **k): + calls.append(1) + return real_eigvalsh(*a, **k) + + with mock.patch("numpy.linalg.eigvalsh", side_effect=counting): + calc = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian", n_jobs=4) + + n_iso = len(calc.config.isotopologues) + # 2 reference diagonalizations (gs + ts, pre-warmed) + 2 per substitution + assert len(calls) == 2 + 2 * n_iso diff --git a/test/test_parsers.py b/test/test_parsers.py new file mode 100644 index 0000000..9063601 --- /dev/null +++ b/test/test_parsers.py @@ -0,0 +1,69 @@ +"""Tests for the input-file parsers (``quiver.System``). + +The Claisen tutorial system has 14 atoms, so every parser should produce a +42x42 symmetric Hessian. +""" + +import numpy as np +import pytest + +from pyquiver import quiver +from pyquiver import parsers + +N_ATOMS = 14 + + +@pytest.mark.parametrize("style", ["gaussian", "g16", "g09"]) +def test_gaussian_style_aliases(style, tutorial): + # gaussian is the preferred name; g16/g09 are accepted aliases + system = quiver.System(tutorial("gaussian", "claisen_gs.out"), style=style) + assert system.number_of_atoms == N_ATOMS + + +@pytest.mark.parametrize("style", ["pyquiver", "native", "qin"]) +def test_native_style_aliases(style, tutorial): + system = quiver.System(tutorial("pyquiver", "claisen_gs.qin"), style=style) + assert system.number_of_atoms == N_ATOMS + + +def test_supported_styles_listed(): + styles = parsers.supported_styles() + for expected in ("gaussian", "orca", "native"): + assert expected in styles + + +@pytest.mark.parametrize("style,filename", [ + ("g09", "gaussian/claisen_gs.out"), + ("g09", "gaussian/claisen_ts.out"), + ("orca", "orca/claisen_gs_freq.hess"), + ("orca", "orca/claisen_ts_freq.hess"), + ("pyquiver", "pyquiver/claisen_gs.qin"), + ("pyquiver", "pyquiver/claisen_ts.qin"), +]) +def test_parsers_produce_symmetric_hessian(style, filename, tutorial): + system = quiver.System(tutorial(*filename.split("/")), style=style) + assert system.number_of_atoms == N_ATOMS + assert system.hessian.shape == (3 * N_ATOMS, 3 * N_ATOMS) + assert np.allclose(system.hessian, system.hessian.T), \ + "parsed Hessian is not symmetric" + + +def test_invalid_style_rejected(tutorial): + with pytest.raises(ValueError): + quiver.System(tutorial("gaussian", "claisen_gs.out"), + style="not_a_real_style") + + +def test_g09_and_orca_agree_on_size(tutorial): + g = quiver.System(tutorial("gaussian", "claisen_gs.out"), style="g09") + o = quiver.System(tutorial("orca", "claisen_gs_freq.hess"), style="orca") + assert g.number_of_atoms == o.number_of_atoms + + +def test_pyquiver_round_trips_against_g09(tutorial): + # the .qin files were generated from the g09 output, so the parsed + # Hessians should match what the g09 parser produces + qin = quiver.System(tutorial("pyquiver", "claisen_gs.qin"), + style="pyquiver") + g09 = quiver.System(tutorial("gaussian", "claisen_gs.out"), style="g09") + assert np.allclose(qin.hessian, g09.hessian, atol=1e-6) diff --git a/test/test_physics.py b/test/test_physics.py new file mode 100644 index 0000000..bf36d8c --- /dev/null +++ b/test/test_physics.py @@ -0,0 +1,99 @@ +"""Unit tests for the pure physics functions in ``kie.py``: +the reduced vibrational term ``u``, the Wigner and Bell tunnelling +corrections, and the Bigeleisen-Mayer partition components. +""" + +import numpy as np +import pytest + +from pyquiver.kie import u, wigner, bell, partition_components, h, c, kB + + +# --- u = h c w / (kB T) ------------------------------------------------------ + +def test_u_matches_definition(): + assert u(1000.0, 300.0) == pytest.approx(h * c * 1000.0 / (kB * 300.0)) + + +def test_u_zero_frequency(): + assert u(0.0, 300.0) == 0.0 + + +def test_u_linear_in_wavenumber(): + assert u(2000.0, 300.0) == pytest.approx(2.0 * u(1000.0, 300.0)) + + +def test_u_inverse_in_temperature(): + assert u(1000.0, 600.0) == pytest.approx(0.5 * u(1000.0, 300.0)) + + +def test_u_representative_magnitude(): + # a ~1000 cm-1 mode at room temperature has u of order a few + assert u(1000.0, 300.0) == pytest.approx(4.796, abs=1e-2) + + +# --- Wigner tunnelling correction ------------------------------------------- + +def test_wigner_unity_for_equal_frequencies(): + assert wigner(-500.0, -500.0, 300.0) == pytest.approx(1.0) + + +def test_wigner_lighter_isotope_tunnels_more(): + # the light isotope has the more negative imaginary frequency, so the ratio + # (light correction / heavy correction) should exceed 1 + assert wigner(-400.0, -500.0, 300.0) > 1.0 + + +def test_wigner_value_against_formula(): + u_H = u(-500.0, 300.0) + u_D = u(-400.0, 300.0) + expected = (1.0 + u_H ** 2 / 24.0) / (1.0 + u_D ** 2 / 24.0) + assert wigner(-400.0, -500.0, 300.0) == pytest.approx(expected) + + +def test_wigner_rejects_real_frequencies(): + # tunnelling only applies to imaginary (negative) modes + with pytest.raises(ValueError): + wigner(500.0, 500.0, 300.0) + + +# --- Bell inverted-parabola correction -------------------------------------- + +def test_bell_unity_for_equal_frequencies(): + assert bell(-500.0, -500.0, 300.0) == pytest.approx(1.0) + + +def test_bell_value_against_formula(): + u_H = u(-500.0, 300.0) + u_D = u(-400.0, 300.0) + expected = (u_H / u_D) * (np.sin(u_D / 2.0) / np.sin(u_H / 2.0)) + assert bell(-400.0, -500.0, 300.0) == pytest.approx(expected) + + +def test_bell_rejects_real_frequencies(): + with pytest.raises(ValueError): + bell(500.0, 500.0, 300.0) + + +# --- Bigeleisen-Mayer partition components ---------------------------------- + +def test_partition_components_shape(): + comps = partition_components([1100.0, 1200.0], [1000.0, 1100.0], 300.0) + assert comps.shape == (2, 3) + + +def test_partition_components_identical_isotopologues_give_unity(): + freqs = [1000.0, 1500.0, 2000.0] + comps = partition_components(freqs, freqs, 300.0) + assert np.allclose(comps, 1.0) + assert float(np.prod(comps)) == pytest.approx(1.0) + + +def test_partition_components_known_single_mode(): + heavy, light, T = 1000.0, 1100.0, 300.0 + u_l, u_h = u(light, T), u(heavy, T) + expected = [heavy / light, + (1.0 - np.exp(-u_l)) / (1.0 - np.exp(-u_h)), + np.exp(0.5 * (u_l - u_h))] + comps = partition_components([heavy], [light], T) + assert comps[0] == pytest.approx(expected) diff --git a/test/test_regression.py b/test/test_regression.py new file mode 100644 index 0000000..21f8940 --- /dev/null +++ b/test/test_regression.py @@ -0,0 +1,79 @@ +"""End-to-end regression tests. + +These run a full ``KIE_Calculation`` on the tutorial Claisen rearrangement +inputs and lock in the reported KIE values. They are the safety net for any +refactoring (performance work, vectorization, etc.): if the numbers move, a +test breaks. + +Golden values were captured from the current implementation and rounded to +six decimals; the printed output is only reported to four decimals, so the +1e-5 tolerance is well within what anyone reads off a result. +""" + +import pytest + +from pyquiver.kie import KIE_Calculation + +# Each entry: style -> (config, gs, ts files) and the expected +# (raw, Wigner, inverted-parabola) KIE per isotopologue. Non-reference +# isotopologues are already divided by the C5 reference. +GOLDEN = { + "g09": { + "files": ("gaussian/claisen_demo.config", + "gaussian/claisen_gs.out", + "gaussian/claisen_ts.out"), + "values": { + "C1": (1.010830, 1.012486, 1.012778), + "C2": (1.000021, 1.000043, 1.000047), + "O3": (1.016914, 1.018397, 1.018659), + "C4": (1.027808, 1.030531, 1.031010), + "C5": (1.001895, 1.001933, 1.001940), + "C6": (1.012881, 1.014584, 1.014886), + "H/D": (0.953030, 0.954466, 0.954720), + }, + }, + "orca": { + "files": ("orca/claisen_demo.config", + "orca/claisen_gs_freq.hess", + "orca/claisen_ts_freq.hess"), + "values": { + "C1": (1.011186, 1.012815, 1.013100), + "C2": (1.000320, 1.000343, 1.000347), + "O3": (1.017148, 1.018604, 1.018860), + "C4": (1.028062, 1.030778, 1.031251), + "C5": (1.001825, 1.001862, 1.001868), + "C6": (1.012909, 1.014593, 1.014888), + "H/D": (0.947056, 0.948577, 0.948843), + }, + }, + # The native .qin inputs are generated from the g09 output, so the KIEs + # are identical to the g09 case (this also exercises the .qin parser). + "pyquiver": { + "files": ("pyquiver/claisen_demo.config", + "pyquiver/claisen_gs.qin", + "pyquiver/claisen_ts.qin"), + "values": { + "C1": (1.010830, 1.012486, 1.012778), + "C2": (1.000021, 1.000043, 1.000047), + "O3": (1.016914, 1.018397, 1.018659), + "C4": (1.027808, 1.030531, 1.031010), + "C5": (1.001895, 1.001933, 1.001940), + "C6": (1.012881, 1.014584, 1.014886), + "H/D": (0.953030, 0.954466, 0.954720), + }, + }, +} + + +@pytest.mark.parametrize("style", list(GOLDEN)) +def test_kies_match_golden(style, tutorial): + cfg, gs, ts = (tutorial(*p.split("/")) for p in GOLDEN[style]["files"]) + calc = KIE_Calculation(cfg, gs, ts, style=style) + + expected = GOLDEN[style]["values"] + assert set(calc.KIES) == set(expected), "set of isotopologues changed" + + for name, exp in expected.items(): + got = [float(v) for v in calc.KIES[name].value] + assert got == pytest.approx(exp, abs=1e-5), \ + "%s style, isotopologue %s: %s != %s" % (style, name, got, exp) diff --git a/test/test_results.py b/test/test_results.py new file mode 100644 index 0000000..a3ceb53 --- /dev/null +++ b/test/test_results.py @@ -0,0 +1,62 @@ +"""Tests for the structured result objects (pyquiver.results).""" + +import pytest + +from pyquiver.kie import KIE_Calculation +from pyquiver.results import KIEResult, EIEResult + +CFG = ("gaussian", "claisen_demo.config") +GS = ("gaussian", "claisen_gs.out") +TS = ("gaussian", "claisen_ts.out") + + +@pytest.fixture +def calc(tutorial): + return KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian") + + +def test_named_access(calc): + r = calc.results["C1"] + assert isinstance(r, KIEResult) + assert r.name == "C1" + # the three columns match the positional .value vector + assert (r.uncorrected, r.wigner, r.inverted_parabola) == \ + pytest.approx(tuple(float(v) for v in calc.KIES["C1"].value)) + + +def test_to_dict_kie(calc): + d = calc.to_dict() + assert set(d) == set(calc.KIES) + assert set(d["C1"]) == {"uncorrected", "wigner", "inverted_parabola"} + assert d["C1"]["uncorrected"] == pytest.approx(1.010830, abs=1e-5) + + +def test_to_csv_roundtrip(calc, tmp_path): + path = tmp_path / "out.csv" + text = calc.to_csv(str(path)) + assert path.read_text() == text + lines = [l for l in text.splitlines() if l.strip()] + assert lines[0] == "name,uncorrected,wigner,inverted_parabola" + # one data row per isotopologue + assert len(lines) == 1 + len(calc.KIES) + + +def test_to_dataframe(calc): + pandas = pytest.importorskip("pandas") + df = calc.to_dataframe() + assert list(df.columns) == ["name", "uncorrected", "wigner", "inverted_parabola"] + assert len(df) == len(calc.KIES) + row = df[df["name"] == "C1"].iloc[0] + assert row["wigner"] == pytest.approx(1.012486, abs=1e-5) + + +def test_eie_results_are_scalar(tutorial): + # gs as both endpoints -> EIE -> single value column + calc = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*GS), + style="gaussian") + assert calc.results.is_eie + r = calc.results["C1"] + assert isinstance(r, EIEResult) + assert calc.to_dict()["C1"] == pytest.approx(1.0, abs=1e-6) + assert calc.results.columns == ["name", "value"] diff --git a/test/test_serialization.py b/test/test_serialization.py new file mode 100644 index 0000000..528f516 --- /dev/null +++ b/test/test_serialization.py @@ -0,0 +1,29 @@ +"""Round-trip test for the native .qin serialization. + +Parsing a g09 file, dumping it to the native ``.qin`` format, and re-parsing +must reproduce the same geometry and Hessian. This exercises +``dump_pyquiver_input_file`` and the ``_make_serial_*`` helpers, and guards +the trailing-comma handling in the lower-triangle Hessian parser. +""" + +import shutil + +import numpy as np + +from pyquiver import quiver + + +def test_qin_round_trip(tmp_path, tutorial): + # parse from a copy inside tmp_path so the dumped .qin lands there too + src = tmp_path / "claisen_gs.out" + shutil.copy(tutorial("gaussian", "claisen_gs.out"), str(src)) + original = quiver.System(str(src), style="g09") + + original.dump_pyquiver_input_file() # writes claisen_gs.qin next to src + qin = tmp_path / "claisen_gs.qin" + assert qin.exists() + + reparsed = quiver.System(str(qin), style="pyquiver") + assert reparsed.number_of_atoms == original.number_of_atoms + assert reparsed.atomic_numbers == original.atomic_numbers + assert np.allclose(reparsed.hessian, original.hessian, atol=1e-6) diff --git a/test/test_tunneling.py b/test/test_tunneling.py new file mode 100644 index 0000000..363cf2f --- /dev/null +++ b/test/test_tunneling.py @@ -0,0 +1,93 @@ +"""Tests for the Skodje-Truhlar tunnelling correction (pyquiver.tunneling and +the KIE_Calculation.skodje_truhlar method).""" + +import numpy as np +import pytest + +from pyquiver.kie import KIE_Calculation +from pyquiver.tunneling import (skodje_truhlar, skodje_truhlar_kappa, wigner, + bell, Eh) + +CFG = ("gaussian", "claisen_demo.config") +GS = ("gaussian", "claisen_gs.out") +TS = ("gaussian", "claisen_ts.out") + + +# --- pure function ----------------------------------------------------------- + +def test_equal_frequencies_give_unity(): + assert skodje_truhlar(-500.0, -500.0, 300.0, 0.05 * Eh) == pytest.approx(1.0) + + +def test_rejects_real_frequencies(): + with pytest.raises(ValueError): + skodje_truhlar(500.0, 500.0, 300.0, 0.05 * Eh) + + +def test_ratio_matches_kappa_definition(): + barrier = 0.03 * Eh + expected = (skodje_truhlar_kappa(-400.0, 300.0, barrier) + / skodje_truhlar_kappa(-500.0, 300.0, barrier)) + assert skodje_truhlar(-500.0, -400.0, 300.0, barrier) == pytest.approx(expected) + + +def test_kappa_low_temperature_branch(): + # a large imaginary frequency at low temperature gives alpha < beta, + # exercising the deep-tunnelling form of the equation + kappa = skodje_truhlar_kappa(-3000.0, 100.0, 0.03 * Eh) + assert np.isfinite(kappa) and kappa > 0.0 + # the ratio should also evaluate in this regime + ratio = skodje_truhlar(-3000.0, -2800.0, 100.0, 0.03 * Eh) + assert np.isfinite(ratio) + + +def test_module_reexports_wigner_and_bell(): + # all three corrections are reachable from pyquiver.tunneling + assert wigner(-500.0, -500.0, 300.0) == pytest.approx(1.0) + assert bell(-500.0, -500.0, 300.0) == pytest.approx(1.0) + + +# --- high-level method ------------------------------------------------------- + +@pytest.fixture +def calc(tutorial): + return KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), + style="gaussian") + + +def test_method_returns_all_isotopologues(calc): + st = calc.skodje_truhlar(reactant_energy=0.0, product_energy=0.0, + ts_energy=0.02) + assert set(st) == set(calc.KIES) + assert all(np.isfinite(v) for v in st.values()) + + +def test_hartree_and_joule_units_agree(calc): + a = calc.skodje_truhlar(0.0, 0.0, 0.02, unit="hartree") + b = calc.skodje_truhlar(0.0, 0.0, 0.02 * Eh, unit="J") + assert a == pytest.approx(b) + + +def test_bad_unit_rejected(calc): + with pytest.raises(ValueError): + calc.skodje_truhlar(0.0, 0.0, 0.02, unit="kcal") + + +def test_method_without_reference(tutorial): + # reference 'none' -> absolute ST-corrected KIEs (exercises the no-ref path) + from pyquiver.config import Config + from pyquiver import System + cfg = Config.from_dict(isotopologues={"C1": [(1, 1, "13C")]}, + temperature=393, scaling=0.9614, imag_threshold=50) + gs = System(tutorial("gaussian", "claisen_gs.out"), style="gaussian") + ts = System(tutorial("gaussian", "claisen_ts.out"), style="gaussian") + calc = KIE_Calculation(cfg, gs, ts) + st = calc.skodje_truhlar(0.0, 0.0, 0.02) + assert np.isfinite(st["C1"]) + + +def test_eie_calculation_rejected(tutorial): + eie = KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*GS), + style="gaussian") + with pytest.raises(ValueError): + eie.skodje_truhlar(0.0, 0.0, 0.02) diff --git a/tutorial/TUTORIAL.md b/tutorial/TUTORIAL.md index 311ddf4..195759a 100644 --- a/tutorial/TUTORIAL.md +++ b/tutorial/TUTORIAL.md @@ -53,18 +53,16 @@ isotopomer H/D 8 8 2D Now we are ready to calculate the KIEs! Enter in the following: ``` -cd src/ -python quiver.py ../tutorial/gaussian/claisen_demo.config ../tutorial/gaussian/claisen_gs.out ../tutorial/gaussian/claisen_ts.out +pyquiver tutorial/gaussian/claisen_demo.config tutorial/gaussian/claisen_gs.out tutorial/gaussian/claisen_ts.out ``` -When run from the command line, *PyQuiver* expects the names (in order) of the configuration file, the ground state file, and the transition state file. The expected output is: +(The legacy `python src/quiver.py ...` invocation still works but is deprecated.) -``` -Read atomic weight data for 30 elements. +When run from the command line, *PyQuiver* expects the names (in order) of the configuration file, the ground state file, and the transition state file. Pass `-v` to see the progress messages shown below (by default only the result table is printed). The expected output is: -Reading configuration from claisen_demo.config -Reading data from claisen_gs.out... with style g09 -Reading data from claisen_ts.out... with style g09 +``` +Reading data from claisen_gs.out with style gaussian +Reading data from claisen_ts.out with style gaussian Config file: claisen_demo.config Temperature: 393.0 K Scaling: 0.961 @@ -110,6 +108,6 @@ The above captures the basic workflow of a *PyQuiver* calculation: * locate ground and transition states (if using Gaussian, turn on the verbose `#p` flag) * run a frequency calculations * specify the desired isotopic substitutions in a configuration file -* run `python quiver.py` on the configuration, ground state, and transition state files +* run the `pyquiver` command on the configuration, ground state, and transition state files If EIEs are desired, simply replace the transition state with the equilibrium state of interest. diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ccda6e3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,856 @@ +version = 1 +revision = 2 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, + { url = "https://files.pythonhosted.org/packages/56/b4/52eeb530a99e2a4c55ffcd352772b599ed4473a0f892d127f4147cf0f88e/pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2", size = 11567720, upload-time = "2025-09-29T23:33:06.209Z" }, + { url = "https://files.pythonhosted.org/packages/48/4a/2d8b67632a021bced649ba940455ed441ca854e57d6e7658a6024587b083/pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8", size = 10810302, upload-time = "2025-09-29T23:33:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/d2465010ee0569a245c975dc6967b801887068bc893e908239b1f4b6c1ac/pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff", size = 12154874, upload-time = "2025-09-29T23:33:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/1f/18/aae8c0aa69a386a3255940e9317f793808ea79d0a525a97a903366bb2569/pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29", size = 12790141, upload-time = "2025-09-29T23:34:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/f7/26/617f98de789de00c2a444fbe6301bb19e66556ac78cff933d2c98f62f2b4/pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73", size = 13208697, upload-time = "2025-09-29T23:34:21.835Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fb/25709afa4552042bd0e15717c75e9b4a2294c3dc4f7e6ea50f03c5136600/pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9", size = 13879233, upload-time = "2025-09-29T23:34:35.079Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/7be05277859a7bc399da8ba68b88c96b27b48740b6cf49688899c6eb4176/pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa", size = 11359119, upload-time = "2025-09-29T23:34:46.339Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyquiver-kie" +version = "1.1.0" +source = { editable = "." } +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.optional-dependencies] +dev = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +pandas = [ + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", marker = "extra == 'dev'" }, + { name = "numpy" }, + { name = "pandas", marker = "extra == 'pandas'" }, + { name = "pytest", marker = "extra == 'dev'" }, +] +provides-extras = ["pandas", "dev"] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] From 58fbc7c54897223c8f616db0f0af33a893210543 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 07:28:10 -0400 Subject: [PATCH 02/14] Fix CI install (uv sync) and rebuild the tutorial notebook CI: the test job used `uv pip install --system`, which uv refuses against its managed Python ("externally managed"). Install into a venv with `uv sync --extra dev --extra pandas` and run via `uv run --no-sync`; the build job now uses `uv build` + `uvx twine check`. Notebook (scripts/pyquiver.ipynb), per review: - all imports in one cell at the top (incl. pandas and cctk) - intro explains what a KIE calculation is - simpler "simple calculation"; double substitution (H/D) called out and the numeric-mass option noted inline; per-style parsing folded in (no separate "supported files"/"ORCA" sections) - the three batch pair forms each in their own beginner-friendly cell - separate Wigner, Bell, and Skodje-Truhlar equation cells (ST reproduced from the original notebook); Bell inverted parabola noted as the default and guidance on when to use Skodje-Truhlar - RPFR per-mode contributions shown as data, not a debug log - executed so outputs are embedded (no need to re-run to read it) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 15 +- scripts/pyquiver.ipynb | 823 ++++++++++++++++++++++++++++++++++----- 2 files changed, 727 insertions(+), 111 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 459aff9..bdb1f61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,12 +20,12 @@ jobs: with: python-version: ${{ matrix.python-version }} enable-cache: true - - name: Install package + dev/pandas extras - run: uv pip install --system -e ".[dev,pandas]" + - name: Sync environment (package + dev/pandas extras) + run: uv sync --extra dev --extra pandas - name: Run tests with coverage run: | - coverage run --source=pyquiver -m pytest -q - coverage report -m + uv run --no-sync coverage run --source=pyquiver -m pytest -q + uv run --no-sync coverage report -m build: name: build & check distribution @@ -34,10 +34,7 @@ jobs: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v5 - with: - python-version: "3.13" - name: Build sdist + wheel and validate run: | - uv pip install --system build twine - python -m build - python -m twine check dist/* + uv build + uvx twine check dist/* diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index afb07bf..4cd7192 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -1,76 +1,142 @@ { "cells": [ - { - "cell_type": "code", - "metadata": {}, - "source": [ - "import numpy as np\n", - "from IPython.display import display\n", - "\n", - "from pyquiver import KIE_Calculation, System, Config, Isotopologue, batch, parsers\n", - "from pyquiver.constants import DEFAULT_MASSES, REPLACEMENTS\n", - "from pyquiver.kie import calculate_rpfr, partition_components" - ], - "outputs": [], - "execution_count": null - }, { "cell_type": "markdown", + "id": "a78b0dd8", "metadata": {}, "source": [ - "## Supported input files\n", + "# Kinetic isotope effects with PyQuiver\n", "\n", - "PyQuiver reads Cartesian Hessians from several electronic-structure programs. Pass the matching `style` (the first name; the rest are aliases):\n", + "A **kinetic isotope effect (KIE)** is the ratio of reaction rates between two isotopologues -- molecules that differ only by isotopic substitution at one or more atoms (for example ¹²C vs ¹³C). A heavier isotope shifts vibrational frequencies, which slightly changes the activation barrier and therefore the rate; the KIE measures that change and is a sensitive probe of mechanism.\n", "\n", - "| Program | `style` | files |\n", - "|---------|---------|-------|\n", - "| Gaussian | `gaussian` (`g16`, `g09`) | `.out` |\n", - "| ORCA | `orca` | `.hess` |\n", - "| PyQuiver native | `pyquiver` (`native`, `qin`) | `.qin` |" + "PyQuiver computes KIEs from the vibrational frequencies (Hessians) of the reactant ground state and the transition state via the Bigeleisen-Mayer equation. You supply those two frequency calculations and a configuration describing the isotopic substitutions.\n", + "\n", + "(The Skodje-Truhlar section below uses [`cctk`](https://github.com/ekwan/cctk) to read energies: `pip install cctk`.)" ] }, { "cell_type": "code", - "metadata": {}, - "source": [ - "parsers.supported_styles()" - ], + "execution_count": 1, + "id": "2ed82d4d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:32.997966Z", + "iopub.status.busy": "2026-06-17T11:26:32.997684Z", + "iopub.status.idle": "2026-06-17T11:26:33.563576Z", + "shell.execute_reply": "2026-06-17T11:26:33.563098Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import cctk\n", + "from IPython.display import display\n", + "\n", + "from pyquiver import KIE_Calculation, System, Config, Isotopologue, batch\n", + "from pyquiver.constants import DEFAULT_MASSES, REPLACEMENTS\n", + "from pyquiver.kie import calculate_rpfr, partition_components" + ] }, { "cell_type": "markdown", + "id": "02617220", "metadata": {}, "source": [ "## A simple calculation\n", "\n", - "Build the configuration in Python with `Config.from_dict`, then run it on a ground-state and a transition-state file. Each substitution is `(ground_state_atom, transition_state_atom, replacement)` with 1-indexed atoms; `replacement` is a `weights.dat` label such as `\"13C\"`, `\"2D\"`, or `\"17O\"` (one isotopologue can make several). (You can also load a `.config` file with `Config(path)`.)" + "Build the configuration in Python with `Config.from_dict`, then run it on a ground-state and a transition-state file.\n", + "\n", + "`isotopologues` maps a name to a list of `(ground_state_atom, transition_state_atom, replacement)` substitutions. Atom numbers are 1-indexed and `replacement` is a `weights.dat` label such as `\"13C\"`, `\"2D\"`, or `\"17O\"`. List **several tuples to substitute several atoms at once** -- the `\"H/D\"` isotopologue below deuterates two hydrogens together. A bare number can be used instead of a label for an unusual mass (e.g. `(4, 4, 5000.0)`)." ] }, { "cell_type": "code", - "metadata": {}, + "execution_count": 2, + "id": "1eb8048d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.564994Z", + "iopub.status.busy": "2026-06-17T11:26:33.564887Z", + "iopub.status.idle": "2026-06-17T11:26:33.571571Z", + "shell.execute_reply": "2026-06-17T11:26:33.571247Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== PyQuiver Analysis ===\n", + "Isotopologue uncorrected Wigner inverted parabola\n", + " KIE KIE KIE\n", + "Isotopologue C1 1.0127 1.0144 1.0147 \n", + "Isotopologue O3 1.0188 1.0204 1.0206 \n", + "Isotopologue H/D 0.9548 0.9563 0.9566 \n", + "\n", + "Absolute KIEs are given.\n" + ] + } + ], "source": [ "config = Config.from_dict(\n", " isotopologues={\n", - " \"C1\": [(1, 1, \"13C\")],\n", - " \"C4\": [(4, 4, \"13C\")],\n", + " \"C1\": [(1, 1, \"13C\")], # single substitution\n", " \"O3\": [(3, 3, \"17O\")],\n", - " \"H/D\": [(7, 7, \"2D\"), (8, 8, \"2D\")],\n", + " \"H/D\": [(7, 7, \"2D\"), (8, 8, \"2D\")], # double substitution: two atoms at once\n", " },\n", " temperature=393, scaling=0.9614, imag_threshold=50,\n", - " reference_isotopologue=\"none\",\n", ")\n", "\n", "calc = KIE_Calculation(config, \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\",\n", " style=\"gaussian\")\n", "print(calc)" + ] + }, + { + "cell_type": "markdown", + "id": "0075c551", + "metadata": {}, + "source": [ + "PyQuiver reads Gaussian output, ORCA `.hess` files, and its own `.qin` format -- just set `style`. The same configuration runs on any of them:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0b6d9ccc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.572582Z", + "iopub.status.busy": "2026-06-17T11:26:33.572527Z", + "iopub.status.idle": "2026-06-17T11:26:33.577591Z", + "shell.execute_reply": "2026-06-17T11:26:33.577306Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gaussian C1: 1.0127\n", + "ORCA C1: 1.0130\n", + "native C1: 1.0127\n" + ] + } ], - "outputs": [], - "execution_count": null + "source": [ + "orca = KIE_Calculation(config, \"../tutorial/orca/claisen_gs_freq.hess\", \"../tutorial/orca/claisen_ts_freq.hess\", style=\"orca\")\n", + "native = KIE_Calculation(config, \"../tutorial/pyquiver/claisen_gs.qin\", \"../tutorial/pyquiver/claisen_ts.qin\", style=\"pyquiver\")\n", + "\n", + "print(f\"Gaussian C1: {calc.results['C1'].uncorrected:.4f}\")\n", + "print(f\"ORCA C1: {orca.results['C1'].uncorrected:.4f}\")\n", + "print(f\"native C1: {native.results['C1'].uncorrected:.4f}\")" + ] }, { "cell_type": "markdown", + "id": "f1367d70", "metadata": {}, "source": [ "## Results as data\n", @@ -80,131 +146,573 @@ }, { "cell_type": "code", - "metadata": {}, + "execution_count": 4, + "id": "48cf8ee5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.578572Z", + "iopub.status.busy": "2026-06-17T11:26:33.578509Z", + "iopub.status.idle": "2026-06-17T11:26:33.584820Z", + "shell.execute_reply": "2026-06-17T11:26:33.584501Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C1 -- uncorrected: 1.0127, Wigner: 1.0144, inverted parabola: 1.0147\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nameuncorrectedwignerinverted_parabola
0C11.0127461.0144431.014743
1O31.0188421.0203651.020635
2H/D0.9548370.9563110.956572
\n", + "
" + ], + "text/plain": [ + " name uncorrected wigner inverted_parabola\n", + "0 C1 1.012746 1.014443 1.014743\n", + "1 O3 1.018842 1.020365 1.020635\n", + "2 H/D 0.954837 0.956311 0.956572" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ - "c4 = calc.results[\"C4\"]\n", - "print(f\"C4 -- uncorrected: {c4.uncorrected:.4f}, Wigner: {c4.wigner:.4f}, inverted parabola: {c4.inverted_parabola:.4f}\")\n", + "c1 = calc.results[\"C1\"]\n", + "print(f\"C1 -- uncorrected: {c1.uncorrected:.4f}, Wigner: {c1.wigner:.4f}, inverted parabola: {c1.inverted_parabola:.4f}\")\n", "\n", "display(calc.to_dataframe())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", + "id": "6f5ab41f", "metadata": {}, "source": [ "## Reusing parsed files\n", "\n", - "A `System` holds a parsed geometry and Hessian. Build the ground and transition states once and reuse them -- for example to scan the temperature -- so each file is read only once." + "Passing file paths re-reads and re-parses each file on every call. A `System` parses a file once into a reusable object, so building the ground and transition states up front avoids repeating that work -- handy when you run many calculations on the same structures, such as a temperature scan." ] }, { "cell_type": "code", - "metadata": {}, + "execution_count": 5, + "id": "d33f0bfe", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.585701Z", + "iopub.status.busy": "2026-06-17T11:26:33.585650Z", + "iopub.status.idle": "2026-06-17T11:26:33.589403Z", + "shell.execute_reply": "2026-06-17T11:26:33.589152Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "298 K: C1 KIE = 1.0144\n", + "350 K: C1 KIE = 1.0134\n", + "393 K: C1 KIE = 1.0127\n" + ] + } + ], "source": [ "gs = System(\"../tutorial/gaussian/claisen_gs.out\")\n", "ts = System(\"../tutorial/gaussian/claisen_ts.out\")\n", "\n", "for T in (298, 350, 393):\n", - " cfg = Config.from_dict(isotopologues={\"C4\": [(4, 4, \"13C\")]},\n", + " cfg = Config.from_dict(isotopologues={\"C1\": [(1, 1, \"13C\")]},\n", " temperature=T, scaling=0.9614, imag_threshold=50)\n", - " print(f\"{T} K: C4 KIE = {KIE_Calculation(cfg, gs, ts).results['C4'].uncorrected:.4f}\")" - ], - "outputs": [], - "execution_count": null + " print(f\"{T} K: C1 KIE = {KIE_Calculation(cfg, gs, ts).results['C1'].uncorrected:.4f}\")" + ] }, { "cell_type": "markdown", + "id": "3ea4cb3f", "metadata": {}, "source": [ - "## Unusual isotopes\n", + "## Many files at once: `batch`\n", "\n", - "A `replacement` is normally a standard isotope label, but a bare number is used directly as a mass (amu) -- so non-standard isotopes need no `weights.dat` entry. Here we place a (fictitious) mass-5000 nucleus at atom 4:" + "`batch` runs one configuration over many ground-state/transition-state pairs and returns a single table. You can supply the pairs three ways." ] }, { - "cell_type": "code", + "cell_type": "markdown", + "id": "f34f2d59", "metadata": {}, "source": [ - "cfg = Config.from_dict(isotopologues={\"heavyC4\": [(4, 4, 5000.0)]},\n", - " temperature=393, scaling=0.9614, imag_threshold=50)\n", - "print(KIE_Calculation(cfg, gs, ts).results[\"heavyC4\"])" + "**1. A dictionary** mapping a label to each `(ground_state, transition_state)` pair:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a108601a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.590467Z", + "iopub.status.busy": "2026-06-17T11:26:33.590411Z", + "iopub.status.idle": "2026-06-17T11:26:33.596603Z", + "shell.execute_reply": "2026-06-17T11:26:33.596276Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
labelnameuncorrectedwignerinverted_parabola
0run1C11.0127461.0144431.014743
1run1O31.0188421.0203651.020635
2run1H/D0.9548370.9563110.956572
3run2C11.0127461.0144431.014743
4run2O31.0188421.0203651.020635
5run2H/D0.9548370.9563110.956572
\n", + "
" + ], + "text/plain": [ + " label name uncorrected wigner inverted_parabola\n", + "0 run1 C1 1.012746 1.014443 1.014743\n", + "1 run1 O3 1.018842 1.020365 1.020635\n", + "2 run1 H/D 0.954837 0.956311 0.956572\n", + "3 run2 C1 1.012746 1.014443 1.014743\n", + "4 run2 O3 1.018842 1.020365 1.020635\n", + "5 run2 H/D 0.954837 0.956311 0.956572" + ] + }, + "metadata": {}, + "output_type": "display_data" + } ], - "outputs": [], - "execution_count": null + "source": [ + "results = batch(config, {\n", + " \"run1\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + " \"run2\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + "})\n", + "display(results.to_dataframe())" + ] }, { "cell_type": "markdown", + "id": "f6a49746", "metadata": {}, "source": [ - "## ORCA input\n", - "\n", - "ORCA `.hess` files are read with `style=\"orca\"`; nothing else changes." + "**2. A list of `(label, ground_state, transition_state)` triples** -- the same thing without a dictionary:" ] }, { "cell_type": "code", + "execution_count": 7, + "id": "dd0119bc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.597534Z", + "iopub.status.busy": "2026-06-17T11:26:33.597482Z", + "iopub.status.idle": "2026-06-17T11:26:33.603379Z", + "shell.execute_reply": "2026-06-17T11:26:33.603099Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
labelnameuncorrectedwignerinverted_parabola
0run1C11.0127461.0144431.014743
1run1O31.0188421.0203651.020635
2run1H/D0.9548370.9563110.956572
3run2C11.0127461.0144431.014743
4run2O31.0188421.0203651.020635
5run2H/D0.9548370.9563110.956572
\n", + "
" + ], + "text/plain": [ + " label name uncorrected wigner inverted_parabola\n", + "0 run1 C1 1.012746 1.014443 1.014743\n", + "1 run1 O3 1.018842 1.020365 1.020635\n", + "2 run1 H/D 0.954837 0.956311 0.956572\n", + "3 run2 C1 1.012746 1.014443 1.014743\n", + "4 run2 O3 1.018842 1.020365 1.020635\n", + "5 run2 H/D 0.954837 0.956311 0.956572" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "results = batch(config, [\n", + " (\"run1\", \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + " (\"run2\", \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + "])\n", + "display(results.to_dataframe())" + ] + }, + { + "cell_type": "markdown", + "id": "bd4e0f36", "metadata": {}, "source": [ - "orca = KIE_Calculation(config, \"../tutorial/orca/claisen_gs_freq.hess\",\n", - " \"../tutorial/orca/claisen_ts_freq.hess\", style=\"orca\")\n", - "display(orca.to_dataframe())" + "**3. A list of `(ground_state, transition_state)` pairs** -- PyQuiver labels each one from the ground-state filename:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "29785bb3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.604272Z", + "iopub.status.busy": "2026-06-17T11:26:33.604225Z", + "iopub.status.idle": "2026-06-17T11:26:33.608571Z", + "shell.execute_reply": "2026-06-17T11:26:33.608225Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
labelnameuncorrectedwignerinverted_parabola
0claisen_gsC11.0127461.0144431.014743
1claisen_gsO31.0188421.0203651.020635
2claisen_gsH/D0.9548370.9563110.956572
\n", + "
" + ], + "text/plain": [ + " label name uncorrected wigner inverted_parabola\n", + "0 claisen_gs C1 1.012746 1.014443 1.014743\n", + "1 claisen_gs O3 1.018842 1.020365 1.020635\n", + "2 claisen_gs H/D 0.954837 0.956311 0.956572" + ] + }, + "metadata": {}, + "output_type": "display_data" + } ], - "outputs": [], - "execution_count": null + "source": [ + "results = batch(config, [\n", + " (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + "])\n", + "display(results.to_dataframe())" + ] }, { "cell_type": "markdown", + "id": "64de6147", "metadata": {}, "source": [ - "## Many files at once: `batch`\n", + "## Tunnelling corrections\n", + "\n", + "The imaginary mode at the transition state lets nuclei tunnel through the barrier, speeding up the reaction. PyQuiver reports every KIE three ways -- **uncorrected**, **Wigner**, and **Bell** (inverted parabola) -- and can also apply the **Skodje-Truhlar** correction.\n", "\n", - "`batch` runs one configuration over many ground-state/transition-state pairs and collects the results into one table. Build the pairs from your own file globbing -- no naming convention is assumed. Pairs may be a `{label: (gs, ts)}` mapping, `(label, gs, ts)` triples, or bare `(gs, ts)` tuples (label inferred from the filename)." + "The **Bell inverted parabola is the default** correction and is reported in the table above. Reach for **Skodje-Truhlar** when the imaginary frequency is very large, where the inverted-parabola approximation breaks down (it needs the barrier height, so it is computed on demand rather than reported automatically).\n", + "\n", + "Wigner and Bell need only the imaginary frequency; in each case the KIE is multiplied by $\\kappa_{light}/\\kappa_{heavy}$, where for an imaginary mode of magnitude $\\tilde\\nu^\\ddag$ we write $u = {h c\\, \\tilde\\nu^\\ddag \\over k_B T}$." ] }, { - "cell_type": "code", + "cell_type": "markdown", + "id": "fd7d3fe2", "metadata": {}, "source": [ - "results = batch(config, {\n", - " \"run1\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", - " \"run2\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", - "})\n", + "### Wigner\n", "\n", - "display(results.to_dataframe())\n", - "results[\"run1\"] # the KIE_Calculation for that pair, for drilling down" - ], - "outputs": [], - "execution_count": null + "$$ \\kappa = 1 + {u^2 \\over 24} $$" + ] }, { "cell_type": "markdown", + "id": "dea76ced", "metadata": {}, "source": [ - "## Tunnelling corrections\n", + "### Bell (inverted parabola)\n", "\n", - "Every KIE is reported three ways -- **uncorrected**, **Wigner**, and **Bell** (inverted parabola). Wigner and Bell need only the transition state's imaginary frequency; the **Skodje-Truhlar** correction also needs the barrier *height* (the reactant, product, and TS energies) and can be more appropriate when the imaginary frequency is very large.\n", - "\n", - "For an imaginary mode of magnitude $\\tilde\\nu^\\ddagger$, write $u = \\dfrac{h c\\,\\tilde\\nu^\\ddagger}{k_B T}$. The single-mode transmission coefficients are\n", + "$$ \\kappa = {u/2 \\over \\sin(u/2)} $$" + ] + }, + { + "cell_type": "markdown", + "id": "1506dcd8", + "metadata": {}, + "source": [ + "### Skodje-Truhlar\n", "\n", - "$$\\kappa_{\\text{Wigner}} = 1 + \\frac{u^2}{24}, \\qquad \\kappa_{\\text{Bell}} = \\frac{u/2}{\\sin(u/2)}.$$\n", + "An alternative (*J. Phys. Chem.* **1981**, 85, 624-626) that can be more applicable for very large imaginary frequencies. With $\\alpha = {2\\pi \\over h\\nu^\\ddag}$, $\\beta = {1 \\over k_B T}$, and the barrier height in the exothermic direction $V = V_{TS} - \\max(V_{SM}, V_{PR})$:\n", "\n", - "Skodje-Truhlar additionally uses $\\alpha = \\dfrac{2\\pi}{h\\,\\nu^\\ddagger}$, $\\beta = \\dfrac{1}{k_B T}$, and the barrier $V = V_{\\text{TS}} - \\max(V_{\\text{reactant}}, V_{\\text{product}})$:\n", + "small $\\nu^\\ddag$ / high $T$ ($\\alpha > \\beta$):\n", + "$$ \\kappa = {\\beta\\pi/\\alpha \\over \\sin(\\beta\\pi/\\alpha)} - {\\beta \\over \\alpha - \\beta}\\,e^{(\\beta-\\alpha)V} $$\n", "\n", - "$$\\kappa = \\frac{\\beta\\pi/\\alpha}{\\sin(\\beta\\pi/\\alpha)} - \\frac{\\beta}{\\alpha-\\beta}\\,e^{(\\beta-\\alpha)V} \\quad (\\alpha > \\beta),$$\n", + "large $\\nu^\\ddag$ / low $T$ ($\\alpha < \\beta$):\n", + "$$ \\kappa = {\\beta \\over \\beta-\\alpha}\\left(e^{(\\beta-\\alpha)V} - 1\\right) $$\n", "\n", - "$$\\kappa = \\frac{\\beta}{\\beta-\\alpha}\\left(e^{(\\beta-\\alpha)V} - 1\\right) \\quad (\\alpha < \\beta).$$\n", + "and $\\kappa = {\\beta\\pi/\\alpha \\over \\sin(\\beta\\pi/\\alpha)}$ when $\\alpha = \\beta$.\n", "\n", - "In every case the KIE is multiplied by $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$. The uncorrected, Wigner, and Bell values are in `to_dataframe()`; Skodje-Truhlar is computed on demand because it needs electronic energies, read here with [`cctk`](https://github.com/ekwan/cctk) (`pip install cctk`)." + "Skodje-Truhlar needs electronic energies, read here from the Gaussian outputs with `cctk`:" ] }, { "cell_type": "code", - "metadata": {}, + "execution_count": 9, + "id": "82a4aee6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.609585Z", + "iopub.status.busy": "2026-06-17T11:26:33.609537Z", + "iopub.status.idle": "2026-06-17T11:26:33.615288Z", + "shell.execute_reply": "2026-06-17T11:26:33.614971Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "OrderedDict([('C1', np.float64(1.0147431211866438)),\n", + " ('O3', np.float64(1.0206350979560215)),\n", + " ('H/D', np.float64(0.956572091397035))])" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "import cctk\n", - "\n", "def energy(path):\n", " ensemble = cctk.GaussianFile.read_file(path).ensemble\n", " return ensemble.get_properties_dict(ensemble.molecules[-1])[\"energy\"]\n", @@ -214,12 +722,11 @@ "\n", "# energies in hartree; product taken equal to the reactant here\n", "calc.skodje_truhlar(reactant_energy=E_reactant, product_energy=E_reactant, ts_energy=E_ts)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", + "id": "62ebddc9", "metadata": {}, "source": [ "## Under the hood: reduced isotopic partition function ratios\n", @@ -229,7 +736,25 @@ }, { "cell_type": "code", - "metadata": {}, + "execution_count": 10, + "id": "3df3abc6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.616108Z", + "iopub.status.busy": "2026-06-17T11:26:33.616059Z", + "iopub.status.idle": "2026-06-17T11:26:33.617900Z", + "shell.execute_reply": "2026-06-17T11:26:33.617606Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ground-state RPFR = 1.0872\n" + ] + } + ], "source": [ "gs_masses = [DEFAULT_MASSES[z] for z in gs.atomic_numbers]\n", "heavy_masses = list(gs_masses)\n", @@ -240,12 +765,11 @@ "\n", "rpfr, imag_ratio, light_freqs, heavy_freqs = calculate_rpfr((light, heavy), 50.0, 0.9614, 393)\n", "print(f\"ground-state RPFR = {rpfr:.4f}\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", + "id": "277715cb", "metadata": {}, "source": [ "The per-mode contributions are returned data, not a debug log. `partition_components` gives the product, excitation, and zero-point factors for every vibrational mode:" @@ -253,17 +777,103 @@ }, { "cell_type": "code", - "metadata": {}, + "execution_count": 11, + "id": "25d57409", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T11:26:33.618718Z", + "iopub.status.busy": "2026-06-17T11:26:33.618672Z", + "iopub.status.idle": "2026-06-17T11:26:33.622696Z", + "shell.execute_reply": "2026-06-17T11:26:33.622411Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
productexcitationZPEcontribution
01.0084010.9926530.9989670.999957
11.0000330.9999760.9999900.999999
21.0056130.9960450.9981680.999801
31.0018480.9988630.9991680.999877
41.0117600.9932430.9941350.999030
\n", + "
" + ], + "text/plain": [ + " product excitation ZPE contribution\n", + "0 1.008401 0.992653 0.998967 0.999957\n", + "1 1.000033 0.999976 0.999990 0.999999\n", + "2 1.005613 0.996045 0.998168 0.999801\n", + "3 1.001848 0.998863 0.999168 0.999877\n", + "4 1.011760 0.993243 0.994135 0.999030" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ - "import pandas as pd\n", - "\n", "components = partition_components(heavy_freqs, light_freqs, 393)\n", "modes = pd.DataFrame(components, columns=[\"product\", \"excitation\", \"ZPE\"])\n", "modes[\"contribution\"] = modes.prod(axis=1)\n", "display(modes.head())" - ], - "outputs": [], - "execution_count": null + ] } ], "metadata": { @@ -273,9 +883,18 @@ "name": "python3" }, "language_info": { - "name": "python" + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} From 57cb14a609fe9944d74e08e5e61504ab079fe167 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 07:32:41 -0400 Subject: [PATCH 03/14] Rewrite the notebook prose for newcomers Read the tutorial notebook from a new user's perspective (not assumed to be Python or KIE experts) and reworked the markdown: define ground/transition state, isotopologue, the config parameters (temperature/scaling/imag_threshold), tunnelling, and RPFRs in plain language; smoother transitions; no em dashes; mark the deep-dive sections optional. Code and embedded outputs unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/pyquiver.ipynb | 136 +++++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 67 deletions(-) diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index 4cd7192..d8b3d17 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -7,11 +7,11 @@ "source": [ "# Kinetic isotope effects with PyQuiver\n", "\n", - "A **kinetic isotope effect (KIE)** is the ratio of reaction rates between two isotopologues -- molecules that differ only by isotopic substitution at one or more atoms (for example ¹²C vs ¹³C). A heavier isotope shifts vibrational frequencies, which slightly changes the activation barrier and therefore the rate; the KIE measures that change and is a sensitive probe of mechanism.\n", + "A **kinetic isotope effect (KIE)** compares how fast a reaction goes for two versions of the same molecule that differ only in their isotopes, for example a normal molecule versus one with a single \u00b9\u00b2C replaced by \u00b9\u00b3C. The heavier isotope vibrates a little more slowly, which nudges the activation barrier and therefore the rate; that small ratio is a classic probe of reaction mechanism.\n", "\n", - "PyQuiver computes KIEs from the vibrational frequencies (Hessians) of the reactant ground state and the transition state via the Bigeleisen-Mayer equation. You supply those two frequency calculations and a configuration describing the isotopic substitutions.\n", + "PyQuiver computes KIEs from the vibrational frequencies of the **reactant** (the *ground state*) and the **transition state** (the high point along the reaction path), using the Bigeleisen-Mayer equation. For each of those two structures you supply a frequency calculation in the form of its Cartesian Hessian, plus a short configuration listing the isotopic substitutions you want.\n", "\n", - "(The Skodje-Truhlar section below uses [`cctk`](https://github.com/ekwan/cctk) to read energies: `pip install cctk`.)" + "The examples below use the Claisen rearrangement files shipped with PyQuiver." ] }, { @@ -20,10 +20,10 @@ "id": "2ed82d4d", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:32.997966Z", - "iopub.status.busy": "2026-06-17T11:26:32.997684Z", - "iopub.status.idle": "2026-06-17T11:26:33.563576Z", - "shell.execute_reply": "2026-06-17T11:26:33.563098Z" + "iopub.execute_input": "2026-06-17T11:30:29.835191Z", + "iopub.status.busy": "2026-06-17T11:30:29.834955Z", + "iopub.status.idle": "2026-06-17T11:30:30.394682Z", + "shell.execute_reply": "2026-06-17T11:30:30.394237Z" } }, "outputs": [], @@ -45,9 +45,11 @@ "source": [ "## A simple calculation\n", "\n", - "Build the configuration in Python with `Config.from_dict`, then run it on a ground-state and a transition-state file.\n", + "A calculation needs three things: a **configuration** (what to substitute, and the conditions), a **ground-state** file, and a **transition-state** file. The easiest way to make the configuration is `Config.from_dict`.\n", "\n", - "`isotopologues` maps a name to a list of `(ground_state_atom, transition_state_atom, replacement)` substitutions. Atom numbers are 1-indexed and `replacement` is a `weights.dat` label such as `\"13C\"`, `\"2D\"`, or `\"17O\"`. List **several tuples to substitute several atoms at once** -- the `\"H/D\"` isotopologue below deuterates two hydrogens together. A bare number can be used instead of a label for an unusual mass (e.g. `(4, 4, 5000.0)`)." + "Its `isotopologues` argument is a dictionary. Each key is a name you choose, and its value lists the substitutions to make as `(ground_state_atom, transition_state_atom, replacement)` tuples. Atom numbers start at 1 and follow the numbering in your output files; `replacement` is an isotope label from `weights.dat` such as `\"13C\"`, `\"2D\"`, or `\"17O\"`. Listing **more than one tuple substitutes several atoms at once**, as in `\"H/D\"` below, which deuterates two hydrogens together. (You can also give a plain number instead of a label to use an unusual mass, e.g. `(4, 4, 5000.0)`.)\n", + "\n", + "The remaining settings are the `temperature` in kelvin, an empirical `scaling` factor applied to the frequencies, and `imag_threshold` in cm\u207b\u00b9 (a small imaginary frequency below this cutoff is ignored rather than treated as the reaction mode)." ] }, { @@ -56,10 +58,10 @@ "id": "1eb8048d", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.564994Z", - "iopub.status.busy": "2026-06-17T11:26:33.564887Z", - "iopub.status.idle": "2026-06-17T11:26:33.571571Z", - "shell.execute_reply": "2026-06-17T11:26:33.571247Z" + "iopub.execute_input": "2026-06-17T11:30:30.395994Z", + "iopub.status.busy": "2026-06-17T11:30:30.395870Z", + "iopub.status.idle": "2026-06-17T11:30:30.401831Z", + "shell.execute_reply": "2026-06-17T11:30:30.401524Z" } }, "outputs": [ @@ -99,7 +101,7 @@ "id": "0075c551", "metadata": {}, "source": [ - "PyQuiver reads Gaussian output, ORCA `.hess` files, and its own `.qin` format -- just set `style`. The same configuration runs on any of them:" + "The same calculation works with output from other programs; you only change `style`. PyQuiver reads Gaussian output files, ORCA `.hess` files, and its own compact `.qin` format, and the configuration stays exactly the same:" ] }, { @@ -108,10 +110,10 @@ "id": "0b6d9ccc", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.572582Z", - "iopub.status.busy": "2026-06-17T11:26:33.572527Z", - "iopub.status.idle": "2026-06-17T11:26:33.577591Z", - "shell.execute_reply": "2026-06-17T11:26:33.577306Z" + "iopub.execute_input": "2026-06-17T11:30:30.402840Z", + "iopub.status.busy": "2026-06-17T11:30:30.402779Z", + "iopub.status.idle": "2026-06-17T11:30:30.408242Z", + "shell.execute_reply": "2026-06-17T11:30:30.407897Z" } }, "outputs": [ @@ -139,9 +141,9 @@ "id": "f1367d70", "metadata": {}, "source": [ - "## Results as data\n", + "## Getting the results as data\n", "\n", - "Look up a single isotopologue by name, or get the whole set as a table." + "Printing the calculation gives a formatted table, but usually you want the numbers themselves. Each isotopologue has three KIE values: the **uncorrected** one and two tunnelling-corrected ones, **Wigner** and **Bell** (both explained later). Look one up by name, or get the whole set as a table with `to_dataframe()` (which needs `pandas`):" ] }, { @@ -150,10 +152,10 @@ "id": "48cf8ee5", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.578572Z", - "iopub.status.busy": "2026-06-17T11:26:33.578509Z", - "iopub.status.idle": "2026-06-17T11:26:33.584820Z", - "shell.execute_reply": "2026-06-17T11:26:33.584501Z" + "iopub.execute_input": "2026-06-17T11:30:30.409219Z", + "iopub.status.busy": "2026-06-17T11:30:30.409159Z", + "iopub.status.idle": "2026-06-17T11:30:30.415486Z", + "shell.execute_reply": "2026-06-17T11:30:30.415068Z" } }, "outputs": [ @@ -161,7 +163,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "C1 -- uncorrected: 1.0127, Wigner: 1.0144, inverted parabola: 1.0147\n" + "C1 uncorrected: 1.0127, Wigner: 1.0144, inverted parabola: 1.0147\n" ] }, { @@ -230,7 +232,7 @@ ], "source": [ "c1 = calc.results[\"C1\"]\n", - "print(f\"C1 -- uncorrected: {c1.uncorrected:.4f}, Wigner: {c1.wigner:.4f}, inverted parabola: {c1.inverted_parabola:.4f}\")\n", + "print(f\"C1 uncorrected: {c1.uncorrected:.4f}, Wigner: {c1.wigner:.4f}, inverted parabola: {c1.inverted_parabola:.4f}\")\n", "\n", "display(calc.to_dataframe())" ] @@ -242,7 +244,7 @@ "source": [ "## Reusing parsed files\n", "\n", - "Passing file paths re-reads and re-parses each file on every call. A `System` parses a file once into a reusable object, so building the ground and transition states up front avoids repeating that work -- handy when you run many calculations on the same structures, such as a temperature scan." + "Every time you pass a filename, PyQuiver opens and re-parses that file. If you plan to run several calculations on the same structures, read each file once into a `System` object and reuse it. For example, to see how the KIE changes with temperature:" ] }, { @@ -251,10 +253,10 @@ "id": "d33f0bfe", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.585701Z", - "iopub.status.busy": "2026-06-17T11:26:33.585650Z", - "iopub.status.idle": "2026-06-17T11:26:33.589403Z", - "shell.execute_reply": "2026-06-17T11:26:33.589152Z" + "iopub.execute_input": "2026-06-17T11:30:30.416469Z", + "iopub.status.busy": "2026-06-17T11:30:30.416405Z", + "iopub.status.idle": "2026-06-17T11:30:30.420300Z", + "shell.execute_reply": "2026-06-17T11:30:30.419955Z" } }, "outputs": [ @@ -283,9 +285,9 @@ "id": "3ea4cb3f", "metadata": {}, "source": [ - "## Many files at once: `batch`\n", + "## Running many files at once\n", "\n", - "`batch` runs one configuration over many ground-state/transition-state pairs and returns a single table. You can supply the pairs three ways." + "Often you have many ground-state/transition-state pairs to run with the same configuration (for example, the same reaction at several levels of theory). `batch` runs them all and returns one combined table. You can describe the pairs in three equivalent ways." ] }, { @@ -293,7 +295,7 @@ "id": "f34f2d59", "metadata": {}, "source": [ - "**1. A dictionary** mapping a label to each `(ground_state, transition_state)` pair:" + "**1. A dictionary**, where each key is a label and each value is the `(ground_state, transition_state)` pair:" ] }, { @@ -302,10 +304,10 @@ "id": "a108601a", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.590467Z", - "iopub.status.busy": "2026-06-17T11:26:33.590411Z", - "iopub.status.idle": "2026-06-17T11:26:33.596603Z", - "shell.execute_reply": "2026-06-17T11:26:33.596276Z" + "iopub.execute_input": "2026-06-17T11:30:30.421214Z", + "iopub.status.busy": "2026-06-17T11:30:30.421164Z", + "iopub.status.idle": "2026-06-17T11:30:30.427376Z", + "shell.execute_reply": "2026-06-17T11:30:30.427038Z" } }, "outputs": [ @@ -417,7 +419,7 @@ "id": "f6a49746", "metadata": {}, "source": [ - "**2. A list of `(label, ground_state, transition_state)` triples** -- the same thing without a dictionary:" + "**2. A list of `(label, ground_state, transition_state)` rows**, if you would rather not write a dictionary:" ] }, { @@ -426,10 +428,10 @@ "id": "dd0119bc", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.597534Z", - "iopub.status.busy": "2026-06-17T11:26:33.597482Z", - "iopub.status.idle": "2026-06-17T11:26:33.603379Z", - "shell.execute_reply": "2026-06-17T11:26:33.603099Z" + "iopub.execute_input": "2026-06-17T11:30:30.428322Z", + "iopub.status.busy": "2026-06-17T11:30:30.428266Z", + "iopub.status.idle": "2026-06-17T11:30:30.434404Z", + "shell.execute_reply": "2026-06-17T11:30:30.434091Z" } }, "outputs": [ @@ -541,7 +543,7 @@ "id": "bd4e0f36", "metadata": {}, "source": [ - "**3. A list of `(ground_state, transition_state)` pairs** -- PyQuiver labels each one from the ground-state filename:" + "**3. A list of `(ground_state, transition_state)` pairs** with no labels, in which case PyQuiver names each row after its ground-state file:" ] }, { @@ -550,10 +552,10 @@ "id": "29785bb3", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.604272Z", - "iopub.status.busy": "2026-06-17T11:26:33.604225Z", - "iopub.status.idle": "2026-06-17T11:26:33.608571Z", - "shell.execute_reply": "2026-06-17T11:26:33.608225Z" + "iopub.execute_input": "2026-06-17T11:30:30.435343Z", + "iopub.status.busy": "2026-06-17T11:30:30.435287Z", + "iopub.status.idle": "2026-06-17T11:30:30.439640Z", + "shell.execute_reply": "2026-06-17T11:30:30.439304Z" } }, "outputs": [ @@ -639,11 +641,11 @@ "source": [ "## Tunnelling corrections\n", "\n", - "The imaginary mode at the transition state lets nuclei tunnel through the barrier, speeding up the reaction. PyQuiver reports every KIE three ways -- **uncorrected**, **Wigner**, and **Bell** (inverted parabola) -- and can also apply the **Skodje-Truhlar** correction.\n", + "Near the transition state, light nuclei can \"tunnel\" through the energy barrier instead of passing over it, which speeds the reaction up (most noticeably for hydrogen). PyQuiver reports every KIE three ways: **uncorrected** (no tunnelling) plus two common corrections, **Wigner** and **Bell** (the \"inverted parabola\").\n", "\n", - "The **Bell inverted parabola is the default** correction and is reported in the table above. Reach for **Skodje-Truhlar** when the imaginary frequency is very large, where the inverted-parabola approximation breaks down (it needs the barrier height, so it is computed on demand rather than reported automatically).\n", + "The **Bell inverted parabola is the default**; it is the corrected value you would normally quote, and it is already in the table above. A third correction, **Skodje-Truhlar**, is worth using when the imaginary frequency is very large and the inverted-parabola picture breaks down; because it also needs the reaction's barrier height, it is computed on request rather than reported automatically.\n", "\n", - "Wigner and Bell need only the imaginary frequency; in each case the KIE is multiplied by $\\kappa_{light}/\\kappa_{heavy}$, where for an imaginary mode of magnitude $\\tilde\\nu^\\ddag$ we write $u = {h c\\, \\tilde\\nu^\\ddag \\over k_B T}$." + "Each formula below gives a single-mode coefficient $\\kappa$, and the factor applied to a KIE is the light/heavy ratio $\\kappa_{light}/\\kappa_{heavy}$. For an imaginary mode of magnitude $\\tilde\\nu^\\ddag$, write $u = {h c\\, \\tilde\\nu^\\ddag \\over k_B T}$." ] }, { @@ -692,10 +694,10 @@ "id": "82a4aee6", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.609585Z", - "iopub.status.busy": "2026-06-17T11:26:33.609537Z", - "iopub.status.idle": "2026-06-17T11:26:33.615288Z", - "shell.execute_reply": "2026-06-17T11:26:33.614971Z" + "iopub.execute_input": "2026-06-17T11:30:30.440752Z", + "iopub.status.busy": "2026-06-17T11:30:30.440698Z", + "iopub.status.idle": "2026-06-17T11:30:30.446563Z", + "shell.execute_reply": "2026-06-17T11:30:30.446269Z" } }, "outputs": [ @@ -729,9 +731,9 @@ "id": "62ebddc9", "metadata": {}, "source": [ - "## Under the hood: reduced isotopic partition function ratios\n", + "## Under the hood: partition function ratios\n", "\n", - "A KIE is a ratio of reduced isotopic partition function ratios (RPFRs). You can compute these directly: build a light and a heavy `Isotopologue` and call `calculate_rpfr`." + "This section is optional. Internally a KIE is built from **reduced isotopic partition function ratios** (RPFRs), one for the ground state and one for the transition state, each comparing the light and heavy isotopologue. To look inside, build the light and heavy `Isotopologue` objects yourself and call `calculate_rpfr`:" ] }, { @@ -740,10 +742,10 @@ "id": "3df3abc6", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.616108Z", - "iopub.status.busy": "2026-06-17T11:26:33.616059Z", - "iopub.status.idle": "2026-06-17T11:26:33.617900Z", - "shell.execute_reply": "2026-06-17T11:26:33.617606Z" + "iopub.execute_input": "2026-06-17T11:30:30.447464Z", + "iopub.status.busy": "2026-06-17T11:30:30.447410Z", + "iopub.status.idle": "2026-06-17T11:30:30.449315Z", + "shell.execute_reply": "2026-06-17T11:30:30.449009Z" } }, "outputs": [ @@ -772,7 +774,7 @@ "id": "277715cb", "metadata": {}, "source": [ - "The per-mode contributions are returned data, not a debug log. `partition_components` gives the product, excitation, and zero-point factors for every vibrational mode:" + "You can also see how each vibrational mode contributes to that ratio. `partition_components` returns, for every mode, its frequency (product), excitation, and zero-point factors; their product is the mode's contribution to the RPFR:" ] }, { @@ -781,10 +783,10 @@ "id": "25d57409", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:26:33.618718Z", - "iopub.status.busy": "2026-06-17T11:26:33.618672Z", - "iopub.status.idle": "2026-06-17T11:26:33.622696Z", - "shell.execute_reply": "2026-06-17T11:26:33.622411Z" + "iopub.execute_input": "2026-06-17T11:30:30.450163Z", + "iopub.status.busy": "2026-06-17T11:30:30.450112Z", + "iopub.status.idle": "2026-06-17T11:30:30.454111Z", + "shell.execute_reply": "2026-06-17T11:30:30.453854Z" } }, "outputs": [ @@ -897,4 +899,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file From d9e6b10521cc4b415aad9f9dd9875a4808b16ecc Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 07:35:32 -0400 Subject: [PATCH 04/14] Show per-mode frequencies in the RPFR diagnostics The old DEBUG log printed each mode's light/heavy frequency, frequency ratio, ZPE factor, and contribution. The notebook's partition_components table had dropped the frequencies; add light_freq/heavy_freq columns back (and keep the excitation factor), so the data view matches and exceeds the old log. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/pyquiver.ipynb | 130 +++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 56 deletions(-) diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index d8b3d17..7f55369 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -7,7 +7,7 @@ "source": [ "# Kinetic isotope effects with PyQuiver\n", "\n", - "A **kinetic isotope effect (KIE)** compares how fast a reaction goes for two versions of the same molecule that differ only in their isotopes, for example a normal molecule versus one with a single \u00b9\u00b2C replaced by \u00b9\u00b3C. The heavier isotope vibrates a little more slowly, which nudges the activation barrier and therefore the rate; that small ratio is a classic probe of reaction mechanism.\n", + "A **kinetic isotope effect (KIE)** compares how fast a reaction goes for two versions of the same molecule that differ only in their isotopes, for example a normal molecule versus one with a single ¹²C replaced by ¹³C. The heavier isotope vibrates a little more slowly, which nudges the activation barrier and therefore the rate; that small ratio is a classic probe of reaction mechanism.\n", "\n", "PyQuiver computes KIEs from the vibrational frequencies of the **reactant** (the *ground state*) and the **transition state** (the high point along the reaction path), using the Bigeleisen-Mayer equation. For each of those two structures you supply a frequency calculation in the form of its Cartesian Hessian, plus a short configuration listing the isotopic substitutions you want.\n", "\n", @@ -20,10 +20,10 @@ "id": "2ed82d4d", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:29.835191Z", - "iopub.status.busy": "2026-06-17T11:30:29.834955Z", - "iopub.status.idle": "2026-06-17T11:30:30.394682Z", - "shell.execute_reply": "2026-06-17T11:30:30.394237Z" + "iopub.execute_input": "2026-06-17T11:35:10.797989Z", + "iopub.status.busy": "2026-06-17T11:35:10.797805Z", + "iopub.status.idle": "2026-06-17T11:35:11.370327Z", + "shell.execute_reply": "2026-06-17T11:35:11.369789Z" } }, "outputs": [], @@ -49,7 +49,7 @@ "\n", "Its `isotopologues` argument is a dictionary. Each key is a name you choose, and its value lists the substitutions to make as `(ground_state_atom, transition_state_atom, replacement)` tuples. Atom numbers start at 1 and follow the numbering in your output files; `replacement` is an isotope label from `weights.dat` such as `\"13C\"`, `\"2D\"`, or `\"17O\"`. Listing **more than one tuple substitutes several atoms at once**, as in `\"H/D\"` below, which deuterates two hydrogens together. (You can also give a plain number instead of a label to use an unusual mass, e.g. `(4, 4, 5000.0)`.)\n", "\n", - "The remaining settings are the `temperature` in kelvin, an empirical `scaling` factor applied to the frequencies, and `imag_threshold` in cm\u207b\u00b9 (a small imaginary frequency below this cutoff is ignored rather than treated as the reaction mode)." + "The remaining settings are the `temperature` in kelvin, an empirical `scaling` factor applied to the frequencies, and `imag_threshold` in cm⁻¹ (a small imaginary frequency below this cutoff is ignored rather than treated as the reaction mode)." ] }, { @@ -58,10 +58,10 @@ "id": "1eb8048d", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.395994Z", - "iopub.status.busy": "2026-06-17T11:30:30.395870Z", - "iopub.status.idle": "2026-06-17T11:30:30.401831Z", - "shell.execute_reply": "2026-06-17T11:30:30.401524Z" + "iopub.execute_input": "2026-06-17T11:35:11.371531Z", + "iopub.status.busy": "2026-06-17T11:35:11.371421Z", + "iopub.status.idle": "2026-06-17T11:35:11.377881Z", + "shell.execute_reply": "2026-06-17T11:35:11.377556Z" } }, "outputs": [ @@ -110,10 +110,10 @@ "id": "0b6d9ccc", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.402840Z", - "iopub.status.busy": "2026-06-17T11:30:30.402779Z", - "iopub.status.idle": "2026-06-17T11:30:30.408242Z", - "shell.execute_reply": "2026-06-17T11:30:30.407897Z" + "iopub.execute_input": "2026-06-17T11:35:11.378867Z", + "iopub.status.busy": "2026-06-17T11:35:11.378807Z", + "iopub.status.idle": "2026-06-17T11:35:11.383750Z", + "shell.execute_reply": "2026-06-17T11:35:11.383466Z" } }, "outputs": [ @@ -152,10 +152,10 @@ "id": "48cf8ee5", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.409219Z", - "iopub.status.busy": "2026-06-17T11:30:30.409159Z", - "iopub.status.idle": "2026-06-17T11:30:30.415486Z", - "shell.execute_reply": "2026-06-17T11:30:30.415068Z" + "iopub.execute_input": "2026-06-17T11:35:11.384705Z", + "iopub.status.busy": "2026-06-17T11:35:11.384652Z", + "iopub.status.idle": "2026-06-17T11:35:11.391506Z", + "shell.execute_reply": "2026-06-17T11:35:11.391178Z" } }, "outputs": [ @@ -253,10 +253,10 @@ "id": "d33f0bfe", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.416469Z", - "iopub.status.busy": "2026-06-17T11:30:30.416405Z", - "iopub.status.idle": "2026-06-17T11:30:30.420300Z", - "shell.execute_reply": "2026-06-17T11:30:30.419955Z" + "iopub.execute_input": "2026-06-17T11:35:11.392445Z", + "iopub.status.busy": "2026-06-17T11:35:11.392386Z", + "iopub.status.idle": "2026-06-17T11:35:11.396445Z", + "shell.execute_reply": "2026-06-17T11:35:11.396119Z" } }, "outputs": [ @@ -304,10 +304,10 @@ "id": "a108601a", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.421214Z", - "iopub.status.busy": "2026-06-17T11:30:30.421164Z", - "iopub.status.idle": "2026-06-17T11:30:30.427376Z", - "shell.execute_reply": "2026-06-17T11:30:30.427038Z" + "iopub.execute_input": "2026-06-17T11:35:11.397422Z", + "iopub.status.busy": "2026-06-17T11:35:11.397368Z", + "iopub.status.idle": "2026-06-17T11:35:11.403622Z", + "shell.execute_reply": "2026-06-17T11:35:11.403326Z" } }, "outputs": [ @@ -428,10 +428,10 @@ "id": "dd0119bc", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.428322Z", - "iopub.status.busy": "2026-06-17T11:30:30.428266Z", - "iopub.status.idle": "2026-06-17T11:30:30.434404Z", - "shell.execute_reply": "2026-06-17T11:30:30.434091Z" + "iopub.execute_input": "2026-06-17T11:35:11.404525Z", + "iopub.status.busy": "2026-06-17T11:35:11.404471Z", + "iopub.status.idle": "2026-06-17T11:35:11.410518Z", + "shell.execute_reply": "2026-06-17T11:35:11.410178Z" } }, "outputs": [ @@ -552,10 +552,10 @@ "id": "29785bb3", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.435343Z", - "iopub.status.busy": "2026-06-17T11:30:30.435287Z", - "iopub.status.idle": "2026-06-17T11:30:30.439640Z", - "shell.execute_reply": "2026-06-17T11:30:30.439304Z" + "iopub.execute_input": "2026-06-17T11:35:11.411411Z", + "iopub.status.busy": "2026-06-17T11:35:11.411357Z", + "iopub.status.idle": "2026-06-17T11:35:11.415728Z", + "shell.execute_reply": "2026-06-17T11:35:11.415355Z" } }, "outputs": [ @@ -694,10 +694,10 @@ "id": "82a4aee6", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.440752Z", - "iopub.status.busy": "2026-06-17T11:30:30.440698Z", - "iopub.status.idle": "2026-06-17T11:30:30.446563Z", - "shell.execute_reply": "2026-06-17T11:30:30.446269Z" + "iopub.execute_input": "2026-06-17T11:35:11.416747Z", + "iopub.status.busy": "2026-06-17T11:35:11.416689Z", + "iopub.status.idle": "2026-06-17T11:35:11.422986Z", + "shell.execute_reply": "2026-06-17T11:35:11.422743Z" } }, "outputs": [ @@ -742,10 +742,10 @@ "id": "3df3abc6", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.447464Z", - "iopub.status.busy": "2026-06-17T11:30:30.447410Z", - "iopub.status.idle": "2026-06-17T11:30:30.449315Z", - "shell.execute_reply": "2026-06-17T11:30:30.449009Z" + "iopub.execute_input": "2026-06-17T11:35:11.423940Z", + "iopub.status.busy": "2026-06-17T11:35:11.423888Z", + "iopub.status.idle": "2026-06-17T11:35:11.425749Z", + "shell.execute_reply": "2026-06-17T11:35:11.425493Z" } }, "outputs": [ @@ -774,7 +774,7 @@ "id": "277715cb", "metadata": {}, "source": [ - "You can also see how each vibrational mode contributes to that ratio. `partition_components` returns, for every mode, its frequency (product), excitation, and zero-point factors; their product is the mode's contribution to the RPFR:" + "You can also see how each vibrational mode contributes to that ratio. Below, each row is one mode: its light and heavy frequencies (cm⁻¹), the three Bigeleisen-Mayer factors (product, excitation, and zero-point), and their product, which is that mode's contribution to the RPFR:" ] }, { @@ -783,10 +783,10 @@ "id": "25d57409", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:30:30.450163Z", - "iopub.status.busy": "2026-06-17T11:30:30.450112Z", - "iopub.status.idle": "2026-06-17T11:30:30.454111Z", - "shell.execute_reply": "2026-06-17T11:30:30.453854Z" + "iopub.execute_input": "2026-06-17T11:35:11.426559Z", + "iopub.status.busy": "2026-06-17T11:35:11.426503Z", + "iopub.status.idle": "2026-06-17T11:35:11.431644Z", + "shell.execute_reply": "2026-06-17T11:35:11.431383Z" } }, "outputs": [ @@ -811,6 +811,8 @@ " \n", " \n", " \n", + " light_freq\n", + " heavy_freq\n", " product\n", " excitation\n", " ZPE\n", @@ -820,6 +822,8 @@ " \n", " \n", " 0\n", + " 67.225890\n", + " 67.790652\n", " 1.008401\n", " 0.992653\n", " 0.998967\n", @@ -827,6 +831,8 @@ " \n", " \n", " 1\n", + " 159.684161\n", + " 159.689475\n", " 1.000033\n", " 0.999976\n", " 0.999990\n", @@ -834,6 +840,8 @@ " \n", " \n", " 2\n", + " 178.499592\n", + " 179.501554\n", " 1.005613\n", " 0.996045\n", " 0.998168\n", @@ -841,6 +849,8 @@ " \n", " \n", " 3\n", + " 246.070898\n", + " 246.525660\n", " 1.001848\n", " 0.998863\n", " 0.999168\n", @@ -848,6 +858,8 @@ " \n", " \n", " 4\n", + " 273.262636\n", + " 276.476300\n", " 1.011760\n", " 0.993243\n", " 0.994135\n", @@ -858,12 +870,12 @@ "" ], "text/plain": [ - " product excitation ZPE contribution\n", - "0 1.008401 0.992653 0.998967 0.999957\n", - "1 1.000033 0.999976 0.999990 0.999999\n", - "2 1.005613 0.996045 0.998168 0.999801\n", - "3 1.001848 0.998863 0.999168 0.999877\n", - "4 1.011760 0.993243 0.994135 0.999030" + " light_freq heavy_freq product excitation ZPE contribution\n", + "0 67.225890 67.790652 1.008401 0.992653 0.998967 0.999957\n", + "1 159.684161 159.689475 1.000033 0.999976 0.999990 0.999999\n", + "2 178.499592 179.501554 1.005613 0.996045 0.998168 0.999801\n", + "3 246.070898 246.525660 1.001848 0.998863 0.999168 0.999877\n", + "4 273.262636 276.476300 1.011760 0.993243 0.994135 0.999030" ] }, "metadata": {}, @@ -872,8 +884,14 @@ ], "source": [ "components = partition_components(heavy_freqs, light_freqs, 393)\n", - "modes = pd.DataFrame(components, columns=[\"product\", \"excitation\", \"ZPE\"])\n", - "modes[\"contribution\"] = modes.prod(axis=1)\n", + "modes = pd.DataFrame({\n", + " \"light_freq\": light_freqs,\n", + " \"heavy_freq\": heavy_freqs,\n", + " \"product\": components[:, 0],\n", + " \"excitation\": components[:, 1],\n", + " \"ZPE\": components[:, 2],\n", + "})\n", + "modes[\"contribution\"] = modes[[\"product\", \"excitation\", \"ZPE\"]].prod(axis=1)\n", "display(modes.head())" ] } @@ -899,4 +917,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} From bd35624948a8811c7330e048b8f5e7d3ea3897b8 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 08:03:05 -0400 Subject: [PATCH 05/14] Address full code/doc/notebook review Code fixes: - the `pyquiver` console script printed the table twice and exited 1 (setuptools wraps the entry point in sys.exit(), which re-printed main()'s return value); point the script at a _console() wrapper that returns None - Config.__str__ raised ValueError when a replacement was a numeric mass; format it with !s - structured results (Results / to_dict / to_csv / to_dataframe and batch) now exclude the reference isotopologue, matching the printed table and get_row (it had been listed with its absolute value among the referenced ones) - Config.from_dict coerces numeric-string replacements ("5000.0") like the .config file parser Notebook: - fixed the swapped calculate_rpfr unpacking (heavy is index 2, light is index 3) so the diagnostic light/heavy frequency columns are labelled correctly and the per-mode factors multiply to the RPFR; clarified that the Skodje-Truhlar nu is in Hz; re-executed so embedded outputs are correct Docs: - TUTORIAL: refreshed stale KIE numbers, -v -> -vv for the config echo, src/weights.dat -> pyquiver/weights.dat, typo - README: to_dataframe column is `name` (not isotopologue); softened the "no positional indexing" claim; removed em dashes - DETAILS: Skodje spelling, page range 624-626, "Rearrangement", typos - SNIP/AUTOQUIVER: grammar/typo and an em dash Tests (193, 100% coverage): pin calculate_rpfr return order; validate the Skodje-Truhlar referencing arithmetic; Config.__str__ with a numeric mass; from_dict numeric-string coercion; results exclude the reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- DETAILS.md | 8 +-- README.md | 10 +-- pyproject.toml | 2 +- pyquiver/cli.py | 6 ++ pyquiver/config.py | 18 ++++- pyquiver/results.py | 9 ++- scripts/SNIP.md | 4 +- scripts/pyquiver.ipynb | 152 ++++++++++++++++++++-------------------- src/AUTOQUIVER.md | 4 +- test/test_config.py | 13 ++++ test/test_edge_cases.py | 19 ++++- test/test_results.py | 10 +-- test/test_tunneling.py | 23 ++++++ tutorial/TUTORIAL.md | 16 ++--- 14 files changed, 186 insertions(+), 108 deletions(-) diff --git a/DETAILS.md b/DETAILS.md index 917798a..5eb3d59 100644 --- a/DETAILS.md +++ b/DETAILS.md @@ -74,7 +74,7 @@ Valid configuration files have all of the following directives: * `imag_threshold`: the threshold (in units cm^-1) that defines the cutoff between small and large imaginary frequencies used to distinguish EIE from KIE calculations (typical value: 50) * `temperature`: the temperature in Kelvin * `reference_isoto[pomer/logue]`: possible values are `none` or the name of an isotopologue. If `none` is specified, the absolute KIEs will be reported. If the name of an isotopologue is specified, all KIEs will be divided the KIE values for this isotopologue. -* `mass_override_isot[pomer/logue]`: possible values are "default" or the name of an isotopologue. If the value "default" is specified, the masses of the light isotopologue will be the defaults found in `weights.dat`. If the name of an isotopolgoue is given, then that isotopologue is then used to replace the default mass behaviour of PyQuiver at a particular atom. For example, if the isotopomer `C2` replaces carbon 2 with 13C, then specifying `mass_overide_isotopomer C2` will place carbon-13 at C2 *for every KIE calculation*. +* `mass_override_isot[pomer/logue]`: possible values are "default" or the name of an isotopologue. If the value "default" is specified, the masses of the light isotopologue will be the defaults found in `weights.dat`. If the name of an isotopologue is given, then that isotopologue is used to replace the default mass behaviour of PyQuiver at a particular atom. For example, if the isotopomer `C2` replaces carbon 2 with 13C, then specifying `mass_override_isotopomer C2` will place carbon-13 at C2 *for every KIE calculation*. * `isoto[pomer/logue]`: the rule used for isotopic substitution. The expected fields are `name ground_state_atom_number transition_state_atom_number substitution`. The final field, `substitution` must correspond to a valid substitution weight. These weights are specified in `weights.dat` (e.g., `13C`, `18O`, `2D`). ### Input Files @@ -128,7 +128,7 @@ If input files are provided in a known format other than the *PyQuiver* standard The internal workings of *PyQuiver* are relatively simple. Essentially, a KIE calculation involves parsing the Hessian, mass-weighting, diagonalization with `np.eigvalsh`, calculation of the reduced isotopic partition functions, substitution into the Bigeleisen-Mayer equation, and tunnelling corrections. The tunnelling corrections are expected to work well for heavy atoms, but poorly for hydrogen, especially when considering primary KIEs. -The frequencies can optionally be scaled (see ref. 3), but this will probably not help much (the harmonic approximation is usually quite adequate). Note that *PyQuiver* is recognizees linear vs. non-linear molecules and removes the appropriate number of small translational/rotational modes. (The `frequency_threshold` keyword has been deprecated and is now ignored.) +The frequencies can optionally be scaled (see ref. 3), but this will probably not help much (the harmonic approximation is usually quite adequate). Note that *PyQuiver* recognizes linear vs. non-linear molecules and removes the appropriate number of small translational/rotational modes. (The `frequency_threshold` keyword has been deprecated and is now ignored.) The performance of *PyQuiver* is generally excellent, even for large systems. This is largely because of the efficiency of the `np.eigvalsh` implementation. When multiple isotopomers are calculated using the same configuration file, the reference isotopomer's frequencies are computed only once and cached (`Isotopologue.calculate_frequencies` short-circuits once `self.frequencies` is set), so the reference is not re-diagonalized for each isotopomer. @@ -153,6 +153,6 @@ Tunnelling corrections work best for heavy-atom KIEs. H/D KIEs are more challen * Radom *et al.* *J Phys. Chem.* **1996**, *100*, 16502. 4. **Tunnelling Corrections:** * Bell, R.P. *Chem. Soc. Rev.* **1974**, *3*, 513. - * Skodge, R.T.; Truhlar, D.G. *J. Phys. Chem.* **1981**, *85*, 624-628 -5. **Claisen Rearragenent KIEs:** + * Skodje, R.T.; Truhlar, D.G. *J. Phys. Chem.* **1981**, *85*, 624-626 +5. **Claisen Rearrangement KIEs:** * Meyer, M.P.; DelMonte, A.J.; Singleton, D.A. *J. Am. Chem. Soc.*, **1999**, *121*, 10865-10874. diff --git a/README.md b/README.md index 2cf4950..50a61f6 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,11 @@ print(calc) # human-readable table calc.to_dict() # {name: {uncorrected, wigner, inverted_parabola}} calc.to_csv("kies.csv") # CSV text / file calc.to_dataframe() # pandas DataFrame (needs the pandas extra) -calc.results["C1"].wigner # named access — no positional indexing +calc.results["C1"].wigner # named access to each result ``` `System` objects hold a parsed geometry and Hessian, so you can read each file -once and reuse it — handy for a parameter scan: +once and reuse it, which is handy for a parameter scan: ```python from pyquiver import System @@ -97,7 +97,7 @@ calc.skodje_truhlar(reactant_energy=E_sm, product_energy=E_pr, ts_energy=E_ts) # -> {isotopologue: corrected_KIE} ``` -To run one configuration over many structures, use `batch` — you build the +To run one configuration over many structures, use `batch`: you build the ground/transition-state pairs from your own file globbing (no naming convention assumed) and get back a table: @@ -108,7 +108,7 @@ results = batch("demo.config", { "b3lyp": ("b3lyp_gs.out", "b3lyp_ts.out"), "m06": ("m06_gs.out", "m06_ts.out"), }) -results.to_dataframe() # label, isotopologue, uncorrected, wigner, inverted_parabola +results.to_dataframe() # label, name, uncorrected, wigner, inverted_parabola results["b3lyp"] # the KIE_Calculation for that pair ``` @@ -117,7 +117,7 @@ inferred from the filename). Pass `energies={label: (reactant, ts, product)}` to add a Skodje-Truhlar column. PyQuiver logs through the standard `logging` module (logger name `pyquiver`) and -raises exceptions on bad input — it never prints on import or calls `sys.exit`, +raises exceptions on bad input; it never prints on import or calls `sys.exit`, so it is safe to use inside scripts and notebooks. ## Tutorial diff --git a/pyproject.toml b/pyproject.toml index 60f6aec..1119522 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ Homepage = "https://github.com/ekwan/PyQuiver" Repository = "https://github.com/ekwan/PyQuiver" [project.scripts] -pyquiver = "pyquiver.cli:main" +pyquiver = "pyquiver.cli:_console" [tool.setuptools] packages = ["pyquiver", "pyquiver.parsers"] diff --git a/pyquiver/cli.py b/pyquiver/cli.py index b0172f8..d84df9a 100644 --- a/pyquiver/cli.py +++ b/pyquiver/cli.py @@ -53,3 +53,9 @@ def main(argv=None): n_jobs=args.jobs) print(calc) return calc + + +def _console(): # pragma: no cover - thin console-script wrapper + # the console script must not return a value: setuptools wraps it in + # sys.exit(), which would re-print main()'s return and exit non-zero + main() diff --git a/pyquiver/config.py b/pyquiver/config.py index 8cd6a5b..048cbf4 100644 --- a/pyquiver/config.py +++ b/pyquiver/config.py @@ -104,16 +104,28 @@ def from_dict(cls, isotopologues, temperature, scaling, imag_threshold, config["reference_isotopologue"] = reference_isotopologue config["mass_override_isotopologue"] = mass_override_isotopologue + def _coerce(r): + # a numeric string ("5000.0") is treated as a mass, matching the + # .config file parser; a label ("13C") is left as-is + if isinstance(r, str): + try: + return float(r) + except ValueError: + return r + return r + normalized = OrderedDict() for name, rules in isotopologues.items(): if str(name) == "default": raise ValueError("name default is reserved.") + normalized_rules = [] for (from_atom, to_atom, replacement) in rules: if from_atom < 1 or to_atom < 1: raise ValueError("check atom numbers for isotopologue %s" % name) + replacement = _coerce(replacement) replacement_mass(replacement) # validates label or numeric mass - # preserve the replacement's type (label str or numeric mass) - normalized[str(name)] = [(int(f), int(t), r) for (f, t, r) in rules] + normalized_rules.append((int(from_atom), int(to_atom), replacement)) + normalized[str(name)] = normalized_rules self._finalize(config, normalized) return self @@ -211,6 +223,6 @@ def __str__(self): isotopologue = self.isotopologues[i] for j in range(len(isotopologue)): - to_string += " Isotopologue {0: >10s}, replacement {1: >2d}: replace gs atom {2: ^3d} and ts atom {3: ^3d} with {4: >3s}\n".format(i, j+1, isotopologue[j][0], isotopologue[j][1], isotopologue[j][2]) + to_string += " Isotopologue {0: >10s}, replacement {1: >2d}: replace gs atom {2: ^3d} and ts atom {3: ^3d} with {4!s: >3}\n".format(i, j+1, isotopologue[j][0], isotopologue[j][1], isotopologue[j][2]) return to_string[:-1] diff --git a/pyquiver/results.py b/pyquiver/results.py index 913ed64..3d10fd0 100644 --- a/pyquiver/results.py +++ b/pyquiver/results.py @@ -21,8 +21,13 @@ class Results(object): def __init__(self, calc): self.is_eie = (calc.eie_flag == 1) - self.reference_isotopologue = calc.config.reference_isotopologue - self._items = [k.result for k in calc.KIES.values()] + ref = calc.config.reference_isotopologue + self.reference_isotopologue = ref + # exclude the reference isotopologue, matching the printed table and + # get_row: its value is absolute while the others are referenced, so + # listing it alongside them would be inconsistent + keep = (lambda name: True) if ref in ("default", "none") else (lambda name: name != ref) + self._items = [k.result for name, k in calc.KIES.items() if keep(name)] def __iter__(self): return iter(self._items) diff --git a/scripts/SNIP.md b/scripts/SNIP.md index f7b329b..c733a83 100644 --- a/scripts/SNIP.md +++ b/scripts/SNIP.md @@ -1,9 +1,9 @@ ### Snipping Utility -The AWK script `snip.awk` is provided to convert Gaussian `.out` files into `.snip` files. These "snips" are contain only the geometry, energies, and frequencies, but retain enough of the Gaussian format for PyQuiver to read them. This feature is useful if you have many files and want to "compress" them to save room. +The AWK script `snip.awk` is provided to convert Gaussian `.out` files into `.snip` files. These "snips" contain only the geometry, energies, and frequencies, but retain enough of the Gaussian format for PyQuiver to read them. This feature is useful if you have many files and want to "compress" them to save room. To convert a collection of `.out` files to their corresponding `.snip` files: `awk -f snip.awk *.out` -Note that this will overwrite any existing snips with the same name without warning. (`awk` is a standard scripting langauge available on any platform.) +Note that this will overwrite any existing snips with the same name without warning. (`awk` is a standard scripting language available on any platform.) diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index 7f55369..ee94e75 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -20,10 +20,10 @@ "id": "2ed82d4d", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:10.797989Z", - "iopub.status.busy": "2026-06-17T11:35:10.797805Z", - "iopub.status.idle": "2026-06-17T11:35:11.370327Z", - "shell.execute_reply": "2026-06-17T11:35:11.369789Z" + "iopub.execute_input": "2026-06-17T12:01:50.763401Z", + "iopub.status.busy": "2026-06-17T12:01:50.762966Z", + "iopub.status.idle": "2026-06-17T12:01:51.356507Z", + "shell.execute_reply": "2026-06-17T12:01:51.356061Z" } }, "outputs": [], @@ -58,10 +58,10 @@ "id": "1eb8048d", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.371531Z", - "iopub.status.busy": "2026-06-17T11:35:11.371421Z", - "iopub.status.idle": "2026-06-17T11:35:11.377881Z", - "shell.execute_reply": "2026-06-17T11:35:11.377556Z" + "iopub.execute_input": "2026-06-17T12:01:51.357760Z", + "iopub.status.busy": "2026-06-17T12:01:51.357639Z", + "iopub.status.idle": "2026-06-17T12:01:51.362098Z", + "shell.execute_reply": "2026-06-17T12:01:51.361738Z" } }, "outputs": [ @@ -110,10 +110,10 @@ "id": "0b6d9ccc", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.378867Z", - "iopub.status.busy": "2026-06-17T11:35:11.378807Z", - "iopub.status.idle": "2026-06-17T11:35:11.383750Z", - "shell.execute_reply": "2026-06-17T11:35:11.383466Z" + "iopub.execute_input": "2026-06-17T12:01:51.363264Z", + "iopub.status.busy": "2026-06-17T12:01:51.363037Z", + "iopub.status.idle": "2026-06-17T12:01:51.368986Z", + "shell.execute_reply": "2026-06-17T12:01:51.368602Z" } }, "outputs": [ @@ -152,10 +152,10 @@ "id": "48cf8ee5", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.384705Z", - "iopub.status.busy": "2026-06-17T11:35:11.384652Z", - "iopub.status.idle": "2026-06-17T11:35:11.391506Z", - "shell.execute_reply": "2026-06-17T11:35:11.391178Z" + "iopub.execute_input": "2026-06-17T12:01:51.369961Z", + "iopub.status.busy": "2026-06-17T12:01:51.369898Z", + "iopub.status.idle": "2026-06-17T12:01:51.376210Z", + "shell.execute_reply": "2026-06-17T12:01:51.375866Z" } }, "outputs": [ @@ -253,10 +253,10 @@ "id": "d33f0bfe", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.392445Z", - "iopub.status.busy": "2026-06-17T11:35:11.392386Z", - "iopub.status.idle": "2026-06-17T11:35:11.396445Z", - "shell.execute_reply": "2026-06-17T11:35:11.396119Z" + "iopub.execute_input": "2026-06-17T12:01:51.377238Z", + "iopub.status.busy": "2026-06-17T12:01:51.377176Z", + "iopub.status.idle": "2026-06-17T12:01:51.381385Z", + "shell.execute_reply": "2026-06-17T12:01:51.381074Z" } }, "outputs": [ @@ -304,10 +304,10 @@ "id": "a108601a", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.397422Z", - "iopub.status.busy": "2026-06-17T11:35:11.397368Z", - "iopub.status.idle": "2026-06-17T11:35:11.403622Z", - "shell.execute_reply": "2026-06-17T11:35:11.403326Z" + "iopub.execute_input": "2026-06-17T12:01:51.382417Z", + "iopub.status.busy": "2026-06-17T12:01:51.382362Z", + "iopub.status.idle": "2026-06-17T12:01:51.390962Z", + "shell.execute_reply": "2026-06-17T12:01:51.390590Z" } }, "outputs": [ @@ -428,10 +428,10 @@ "id": "dd0119bc", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.404525Z", - "iopub.status.busy": "2026-06-17T11:35:11.404471Z", - "iopub.status.idle": "2026-06-17T11:35:11.410518Z", - "shell.execute_reply": "2026-06-17T11:35:11.410178Z" + "iopub.execute_input": "2026-06-17T12:01:51.391979Z", + "iopub.status.busy": "2026-06-17T12:01:51.391921Z", + "iopub.status.idle": "2026-06-17T12:01:51.398689Z", + "shell.execute_reply": "2026-06-17T12:01:51.398372Z" } }, "outputs": [ @@ -552,10 +552,10 @@ "id": "29785bb3", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.411411Z", - "iopub.status.busy": "2026-06-17T11:35:11.411357Z", - "iopub.status.idle": "2026-06-17T11:35:11.415728Z", - "shell.execute_reply": "2026-06-17T11:35:11.415355Z" + "iopub.execute_input": "2026-06-17T12:01:51.399903Z", + "iopub.status.busy": "2026-06-17T12:01:51.399839Z", + "iopub.status.idle": "2026-06-17T12:01:51.405032Z", + "shell.execute_reply": "2026-06-17T12:01:51.404677Z" } }, "outputs": [ @@ -675,7 +675,7 @@ "source": [ "### Skodje-Truhlar\n", "\n", - "An alternative (*J. Phys. Chem.* **1981**, 85, 624-626) that can be more applicable for very large imaginary frequencies. With $\\alpha = {2\\pi \\over h\\nu^\\ddag}$, $\\beta = {1 \\over k_B T}$, and the barrier height in the exothermic direction $V = V_{TS} - \\max(V_{SM}, V_{PR})$:\n", + "An alternative (*J. Phys. Chem.* **1981**, 85, 624-626) that can be more applicable for very large imaginary frequencies. (here $\\nu^\\ddag$ is the frequency in Hz, i.e. $c\\,\\tilde\\nu^\\ddag$.) With $\\alpha = {2\\pi \\over h\\nu^\\ddag}$, $\\beta = {1 \\over k_B T}$, and the barrier height in the exothermic direction $V = V_{TS} - \\max(V_{SM}, V_{PR})$:\n", "\n", "small $\\nu^\\ddag$ / high $T$ ($\\alpha > \\beta$):\n", "$$ \\kappa = {\\beta\\pi/\\alpha \\over \\sin(\\beta\\pi/\\alpha)} - {\\beta \\over \\alpha - \\beta}\\,e^{(\\beta-\\alpha)V} $$\n", @@ -694,10 +694,10 @@ "id": "82a4aee6", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.416747Z", - "iopub.status.busy": "2026-06-17T11:35:11.416689Z", - "iopub.status.idle": "2026-06-17T11:35:11.422986Z", - "shell.execute_reply": "2026-06-17T11:35:11.422743Z" + "iopub.execute_input": "2026-06-17T12:01:51.406381Z", + "iopub.status.busy": "2026-06-17T12:01:51.406107Z", + "iopub.status.idle": "2026-06-17T12:01:51.415556Z", + "shell.execute_reply": "2026-06-17T12:01:51.415014Z" } }, "outputs": [ @@ -742,10 +742,10 @@ "id": "3df3abc6", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.423940Z", - "iopub.status.busy": "2026-06-17T11:35:11.423888Z", - "iopub.status.idle": "2026-06-17T11:35:11.425749Z", - "shell.execute_reply": "2026-06-17T11:35:11.425493Z" + "iopub.execute_input": "2026-06-17T12:01:51.417479Z", + "iopub.status.busy": "2026-06-17T12:01:51.417364Z", + "iopub.status.idle": "2026-06-17T12:01:51.421297Z", + "shell.execute_reply": "2026-06-17T12:01:51.420621Z" } }, "outputs": [ @@ -765,7 +765,7 @@ "light = Isotopologue(\"light\", gs, gs_masses)\n", "heavy = Isotopologue(\"heavy\", gs, heavy_masses)\n", "\n", - "rpfr, imag_ratio, light_freqs, heavy_freqs = calculate_rpfr((light, heavy), 50.0, 0.9614, 393)\n", + "rpfr, imag_ratio, heavy_freqs, light_freqs = calculate_rpfr((light, heavy), 50.0, 0.9614, 393)\n", "print(f\"ground-state RPFR = {rpfr:.4f}\")" ] }, @@ -783,10 +783,10 @@ "id": "25d57409", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T11:35:11.426559Z", - "iopub.status.busy": "2026-06-17T11:35:11.426503Z", - "iopub.status.idle": "2026-06-17T11:35:11.431644Z", - "shell.execute_reply": "2026-06-17T11:35:11.431383Z" + "iopub.execute_input": "2026-06-17T12:01:51.423199Z", + "iopub.status.busy": "2026-06-17T12:01:51.422962Z", + "iopub.status.idle": "2026-06-17T12:01:51.432890Z", + "shell.execute_reply": "2026-06-17T12:01:51.431889Z" } }, "outputs": [ @@ -822,48 +822,48 @@ " \n", " \n", " 0\n", - " 67.225890\n", " 67.790652\n", - " 1.008401\n", - " 0.992653\n", - " 0.998967\n", - " 0.999957\n", + " 67.225890\n", + " 0.991669\n", + " 1.007402\n", + " 1.001034\n", + " 1.000043\n", " \n", " \n", " 1\n", - " 159.684161\n", " 159.689475\n", - " 1.000033\n", - " 0.999976\n", - " 0.999990\n", - " 0.999999\n", + " 159.684161\n", + " 0.999967\n", + " 1.000024\n", + " 1.000010\n", + " 1.000001\n", " \n", " \n", " 2\n", - " 178.499592\n", " 179.501554\n", - " 1.005613\n", - " 0.996045\n", - " 0.998168\n", - " 0.999801\n", + " 178.499592\n", + " 0.994418\n", + " 1.003970\n", + " 1.001836\n", + " 1.000199\n", " \n", " \n", " 3\n", - " 246.070898\n", " 246.525660\n", - " 1.001848\n", - " 0.998863\n", - " 0.999168\n", - " 0.999877\n", + " 246.070898\n", + " 0.998155\n", + " 1.001138\n", + " 1.000833\n", + " 1.000123\n", " \n", " \n", " 4\n", - " 273.262636\n", " 276.476300\n", - " 1.011760\n", - " 0.993243\n", - " 0.994135\n", - " 0.999030\n", + " 273.262636\n", + " 0.988376\n", + " 1.006802\n", + " 1.005900\n", + " 1.000971\n", " \n", " \n", "\n", @@ -871,11 +871,11 @@ ], "text/plain": [ " light_freq heavy_freq product excitation ZPE contribution\n", - "0 67.225890 67.790652 1.008401 0.992653 0.998967 0.999957\n", - "1 159.684161 159.689475 1.000033 0.999976 0.999990 0.999999\n", - "2 178.499592 179.501554 1.005613 0.996045 0.998168 0.999801\n", - "3 246.070898 246.525660 1.001848 0.998863 0.999168 0.999877\n", - "4 273.262636 276.476300 1.011760 0.993243 0.994135 0.999030" + "0 67.790652 67.225890 0.991669 1.007402 1.001034 1.000043\n", + "1 159.689475 159.684161 0.999967 1.000024 1.000010 1.000001\n", + "2 179.501554 178.499592 0.994418 1.003970 1.001836 1.000199\n", + "3 246.525660 246.070898 0.998155 1.001138 1.000833 1.000123\n", + "4 276.476300 273.262636 0.988376 1.006802 1.005900 1.000971" ] }, "metadata": {}, diff --git a/src/AUTOQUIVER.md b/src/AUTOQUIVER.md index b0d0e4e..d739846 100644 --- a/src/AUTOQUIVER.md +++ b/src/AUTOQUIVER.md @@ -24,8 +24,8 @@ results["b3lyp"] # the KIE_Calculation for that pair * a mapping `{label: (gs, ts)}` * an iterable of `(label, gs, ts)` triples -* an iterable of `(gs, ts)` pairs — the label is inferred from the ground-state - filename (without its extension) +* an iterable of `(gs, ts)` pairs, where the label is inferred from the + ground-state filename (without its extension) So you can discover and pair files however you like, then hand the result to `batch`. For example, zipping two globs: diff --git a/test/test_config.py b/test/test_config.py index 007ef52..42badf1 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -145,6 +145,19 @@ def test_config_file_numeric_mass(make_config): assert c.isotopologues["heavyC4"] == [(4, 4, 5000.0)] +def test_str_with_numeric_mass(make_config): + # str(config) must not crash when a replacement is a numeric mass + c = make_config(MINIMAL + "isotopologue heavyC4 4 4 5000.0\n") + assert "5000.0" in str(c) + + +def test_from_dict_coerces_numeric_string(): + # a numeric string is treated as a mass, like the .config file parser + c = Config.from_dict(isotopologues={"x": [(1, 1, "5000.0")]}, + temperature=300, scaling=0.96, imag_threshold=50) + assert c.isotopologues["x"] == [(1, 1, 5000.0)] + + def test_check_skips_element_match_for_numeric_mass(make_config, claisen_systems): # a custom numeric mass carries no element identity, so check() must not # try to validate the element and must not raise diff --git a/test/test_edge_cases.py b/test/test_edge_cases.py index f7d5eec..4b6622b 100644 --- a/test/test_edge_cases.py +++ b/test/test_edge_cases.py @@ -23,7 +23,8 @@ def test_results_iter_len_and_missing(tutorial): style="gaussian") res = calc.results assert len(res) == len(list(res)) - assert {r.name for r in res} == set(calc.KIES) + # the reference isotopologue is excluded from the structured results + assert {r.name for r in res} == set(calc.KIES) - {calc.config.reference_isotopologue} with pytest.raises(KeyError): res["does-not-exist"] @@ -222,6 +223,22 @@ def calculate_frequencies(self, imag_threshold, scaling=1.0): return self._r +def test_calculate_rpfr_return_order(tutorial): + # contract: (rpfr, imag_ratios, heavy_freqs, light_freqs) -- heavy at index + # 2, light at index 3. The heavier isotopologue has the lower frequencies. + from pyquiver import System, Isotopologue + from pyquiver.kie import calculate_rpfr + from pyquiver.constants import DEFAULT_MASSES, REPLACEMENTS + gs = System(tutorial("gaussian", "claisen_gs.out"), style="gaussian") + light_masses = [DEFAULT_MASSES[z] for z in gs.atomic_numbers] + heavy_masses = list(light_masses) + heavy_masses[0] = REPLACEMENTS["13C"] + light = Isotopologue("light", gs, light_masses) + heavy = Isotopologue("heavy", gs, heavy_masses) + rpfr, imag, heavy_freqs, light_freqs = calculate_rpfr((light, heavy), 50.0, 1.0, 393) + assert (heavy_freqs <= light_freqs + 1e-9).all() # heavy never higher + + def test_calculate_rpfr_imaginary_mismatch_warns(caplog): # light has one imaginary mode, heavy has none -> warn and drop it light = _FreqStub([], [-500.0], [1000.0, 1100.0]) diff --git a/test/test_results.py b/test/test_results.py index a3ceb53..a4ec8a8 100644 --- a/test/test_results.py +++ b/test/test_results.py @@ -27,7 +27,9 @@ def test_named_access(calc): def test_to_dict_kie(calc): d = calc.to_dict() - assert set(d) == set(calc.KIES) + # the reference isotopologue (C5) is excluded, like the printed table + assert set(d) == set(calc.KIES) - {"C5"} + assert "C5" not in d assert set(d["C1"]) == {"uncorrected", "wigner", "inverted_parabola"} assert d["C1"]["uncorrected"] == pytest.approx(1.010830, abs=1e-5) @@ -38,15 +40,15 @@ def test_to_csv_roundtrip(calc, tmp_path): assert path.read_text() == text lines = [l for l in text.splitlines() if l.strip()] assert lines[0] == "name,uncorrected,wigner,inverted_parabola" - # one data row per isotopologue - assert len(lines) == 1 + len(calc.KIES) + # header + one data row per reported isotopologue (reference excluded) + assert len(lines) == 1 + (len(calc.KIES) - 1) def test_to_dataframe(calc): pandas = pytest.importorskip("pandas") df = calc.to_dataframe() assert list(df.columns) == ["name", "uncorrected", "wigner", "inverted_parabola"] - assert len(df) == len(calc.KIES) + assert len(df) == len(calc.KIES) - 1 # reference excluded row = df[df["name"] == "C1"].iloc[0] assert row["wigner"] == pytest.approx(1.012486, abs=1e-5) diff --git a/test/test_tunneling.py b/test/test_tunneling.py index 363cf2f..0b1a14c 100644 --- a/test/test_tunneling.py +++ b/test/test_tunneling.py @@ -73,6 +73,29 @@ def test_bad_unit_rejected(calc): calc.skodje_truhlar(0.0, 0.0, 0.02, unit="kcal") +def test_method_referencing_arithmetic(calc): + # corrected[non-ref] = (referenced uncorrected KIE) * st_ratio / ref_ratio. + # Check C1 against an independent recomputation from the pure function. + from pyquiver.tunneling import skodje_truhlar + from pyquiver.constants import PHYSICAL_CONSTANTS + barrier = 0.02 * PHYSICAL_CONSTANTS["Eh"] + T = calc.config.temperature + + def imag(name): + k = calc.KIES[name] + light = min(k.ts_tuple[0].calculate_frequencies(50, scaling=calc.config.scaling)[1]) + heavy = min(k.ts_tuple[1].calculate_frequencies(50, scaling=calc.config.scaling)[1]) + return heavy, light + + ref = calc.config.reference_isotopologue + ref_ratio = skodje_truhlar(*imag(ref), T, barrier) + c1_ratio = skodje_truhlar(*imag("C1"), T, barrier) + expected = float(calc.KIES["C1"].value[0]) * c1_ratio / ref_ratio + + got = calc.skodje_truhlar(0.0, 0.0, 0.02)["C1"] # (reactant, product, ts) hartree + assert got == pytest.approx(expected) + + def test_method_without_reference(tutorial): # reference 'none' -> absolute ST-corrected KIEs (exercises the no-ref path) from pyquiver.config import Config diff --git a/tutorial/TUTORIAL.md b/tutorial/TUTORIAL.md index 195759a..2b0e4b3 100644 --- a/tutorial/TUTORIAL.md +++ b/tutorial/TUTORIAL.md @@ -16,7 +16,7 @@ Here, we'll focus on the use of PyQuiver with Gaussian. This tutorial requires If running with ORCA, use the `.hess` files instead of the output files. If running with Quiver Standard files, use the `.qin` files. -Note that the tutorial results will differ slightly with the provided ORCA files due to subtle differences in geometry. Thse differences are not experimentally meaningful and can be ignored. +Note that the tutorial results will differ slightly with the provided ORCA files due to subtle differences in geometry. These differences are not experimentally meaningful and can be ignored. ### Getting Started @@ -35,7 +35,7 @@ isotopomer O3 3 3 17O In each case, the isotopomer definition is followed by three parameters: the atom number in the ground state, the atom number in the transition state, and the isotope to substitute with. For example, for C1, atom 1 in the ground state and atom 1 in the transition state will be substituted with carbon-13. In general, *PyQuiver* will try to prevent you from entering isotopomers that do not make sense. -The definition of `13C` is drawn from `src/weights.dat`: +The definition of `13C` is drawn from `pyquiver/weights.dat`: ``` carbon,6,C,12.0,12C,12.0,13C,13.00335,14C,14.0031 @@ -58,7 +58,7 @@ pyquiver tutorial/gaussian/claisen_demo.config tutorial/gaussian/claisen_gs.out (The legacy `python src/quiver.py ...` invocation still works but is deprecated.) -When run from the command line, *PyQuiver* expects the names (in order) of the configuration file, the ground state file, and the transition state file. Pass `-v` to see the progress messages shown below (by default only the result table is printed). The expected output is: +When run from the command line, *PyQuiver* expects the names (in order) of the configuration file, the ground state file, and the transition state file. By default only the result table is printed; pass `-vv` to also see the configuration echo and progress messages shown below. The expected output is: ``` Reading data from claisen_gs.out with style gaussian @@ -80,12 +80,12 @@ Imag threshold (cm-1): 50 === PyQuiver Analysis === Isotopologue uncorrected Wigner inverted parabola KIE KIE KIE -Isotopologue C1 1.0091 1.0107 1.0110 +Isotopologue C1 1.0108 1.0125 1.0128 Isotopologue C2 1.0000 1.0000 1.0000 -Isotopologue O3 1.0153 1.0168 1.0171 -Isotopologue C4 1.0249 1.0276 1.0281 -Isotopologue C6 1.0111 1.0128 1.0131 -Isotopologue H/D 0.9515 0.9529 0.9532 +Isotopologue O3 1.0169 1.0184 1.0187 +Isotopologue C4 1.0278 1.0305 1.0310 +Isotopologue C6 1.0129 1.0146 1.0149 +Isotopologue H/D 0.9530 0.9545 0.9547 KIEs referenced to isotopologue C5, whose absolute KIEs are: Isotopologue C5 1.0019 1.0019 1.0019 From adad75ac867813ffb21307019d03ea2abae4c2d7 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 08:13:50 -0400 Subject: [PATCH 06/14] Warn for primary hydrogen KIEs and below-crossover Skodje-Truhlar Both regimes give unreliable tunnelling results, silently before. - skodje_truhlar now warns when the temperature is below the crossover (alpha < beta) for either imaginary mode, where the inverted-parabola model diverges. - KIE_Calculation warns when an isotopologue looks like a *primary* hydrogen KIE: a hydrogen (Z=1) substitution whose transition-state imaginary-frequency isotope ratio exceeds a threshold (the H sits in the reaction coordinate), where the harmonic Wigner/Bell corrections are unreliable. Secondary H KIEs (small imaginary ratio, e.g. the Claisen H/D) do not warn. Each KIE now stores ts_imag_ratio (the raw light/heavy TS imaginary ratio). Tests cover both warnings (and the no-warn cases); notebook notes the caveats. 197 tests, 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyquiver/kie.py | 30 ++++++++++++++++++++++++++++++ pyquiver/tunneling.py | 18 ++++++++++++++++++ scripts/pyquiver.ipynb | 12 +++++++----- test/test_kie.py | 21 +++++++++++++++++++++ test/test_tunneling.py | 15 +++++++++++++++ 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/pyquiver/kie.py b/pyquiver/kie.py index 86e42ac..88a136e 100644 --- a/pyquiver/kie.py +++ b/pyquiver/kie.py @@ -15,6 +15,11 @@ c = PHYSICAL_CONSTANTS["c"] # in cm . s kB = PHYSICAL_CONSTANTS["kB"] # in J/K +# a hydrogen substitution whose transition-state imaginary-frequency isotope +# ratio exceeds this is treated as a *primary* H/D KIE (the H sits in the +# reaction coordinate); secondary substitutions barely shift that frequency +PRIMARY_HYDROGEN_IMAG_RATIO = 1.1 + class KIE_Calculation(object): def __init__(self, config, gs, ts, style="gaussian", n_jobs=1): # n_jobs controls optional parallelism over isotopologues (default @@ -84,6 +89,7 @@ def _build(pair): for name, kie in built: KIES[name] = kie + self._warn_if_primary_hydrogen(name, kie) for name,k in KIES.items(): @@ -204,6 +210,26 @@ def _imag(kie): corrected[name] = float(kie.value[0]) * ratio / ref_ratio return corrected + def _warn_if_primary_hydrogen(self, name, kie): + """Warn when an isotopologue is a primary hydrogen KIE, where the + harmonic tunnelling corrections are unreliable. Treated as primary + hydrogen when a substitution replaces a hydrogen (Z=1) and the + transition-state imaginary-frequency isotope ratio is large (the H is + in the reaction coordinate).""" + if kie.eie_flag != 0 or kie.ts_imag_ratio is None: + return + if kie.ts_imag_ratio <= PRIMARY_HYDROGEN_IMAG_RATIO: + return + znums = self.ts_system.atomic_numbers + default_masses, sub_masses = kie.ts_tuple[0].masses, kie.ts_tuple[1].masses + substitutes_hydrogen = any(a != b and znums[i] == 1 + for i, (a, b) in enumerate(zip(default_masses, sub_masses))) + if substitutes_hydrogen: + logger.warning("isotopologue %s looks like a primary hydrogen KIE " + "(H/D in the reaction coordinate); the harmonic Wigner " + "and Bell tunnelling corrections are unreliable here", + name) + def build_mass_override_masses(self): config = self.config gs_system = self.gs_system @@ -299,6 +325,9 @@ def __init__(self, name, gs_tuple, ts_tuple, temperature, scaling, imag_threshol self.gs_tuple, self.ts_tuple = gs_tuple, ts_tuple self.temperature = temperature self.scaling = scaling + # raw transition-state imaginary-frequency isotope ratio (light/heavy); + # set for KIEs, left None for EIEs + self.ts_imag_ratio = None logger.debug("Calculating KIE for isotopologue %s.", name) self.value = self.calculate_kie() @@ -320,6 +349,7 @@ def calculate_kie(self): else: # pragma: no cover raise ValueError("quiver attempted to run a KIE calculation after an EIE calculation. Check the frequency threshold.") + self.ts_imag_ratio = float(ts_imag_ratios[0]) # raw light/heavy kies = ts_imag_ratios * rpfr_gs/rpfr_ts return kies else: diff --git a/pyquiver/tunneling.py b/pyquiver/tunneling.py index cc6e8eb..62bad29 100644 --- a/pyquiver/tunneling.py +++ b/pyquiver/tunneling.py @@ -13,16 +13,29 @@ heavy correction (i.e. the factor a KIE is multiplied by). """ +import logging + import numpy as np from .constants import PHYSICAL_CONSTANTS from .kie import wigner, bell # re-export so all corrections share one namespace +logger = logging.getLogger("pyquiver") + h = PHYSICAL_CONSTANTS["h"] # J s c = PHYSICAL_CONSTANTS["c"] # cm / s kB = PHYSICAL_CONSTANTS["kB"] # J / K Eh = PHYSICAL_CONSTANTS["Eh"] # J / hartree + +def _below_crossover(imag_wavenumber, temperature): + """True if T is below the Skodje-Truhlar crossover (alpha < beta) for this + imaginary mode, i.e. the deep-tunnelling regime where the model diverges.""" + imag_hz = np.abs(imag_wavenumber * c) + alpha = 2.0 * np.pi / (h * imag_hz) + beta = 1.0 / (kB * temperature) + return beta > alpha + __all__ = ["wigner", "bell", "skodje_truhlar", "skodje_truhlar_kappa"] @@ -54,6 +67,11 @@ def skodje_truhlar(ts_imag_heavy, ts_imag_light, temperature, barrier): """ if not (ts_imag_heavy < 0.0 and ts_imag_light < 0.0): raise ValueError("imaginary frequency passed to Skodje-Truhlar correction was real") + if _below_crossover(ts_imag_heavy, temperature) or _below_crossover(ts_imag_light, temperature): + logger.warning("temperature %.1f K is below the Skodje-Truhlar crossover " + "for this imaginary mode; the correction diverges in this " + "deep-tunnelling regime and should not be trusted", + temperature) light = skodje_truhlar_kappa(ts_imag_light, temperature, barrier) heavy = skodje_truhlar_kappa(ts_imag_heavy, temperature, barrier) return light / heavy diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index ee94e75..1025a9d 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -7,7 +7,7 @@ "source": [ "# Kinetic isotope effects with PyQuiver\n", "\n", - "A **kinetic isotope effect (KIE)** compares how fast a reaction goes for two versions of the same molecule that differ only in their isotopes, for example a normal molecule versus one with a single ¹²C replaced by ¹³C. The heavier isotope vibrates a little more slowly, which nudges the activation barrier and therefore the rate; that small ratio is a classic probe of reaction mechanism.\n", + "A **kinetic isotope effect (KIE)** compares how fast a reaction goes for two versions of the same molecule that differ only in their isotopes, for example a normal molecule versus one with a single \u00b9\u00b2C replaced by \u00b9\u00b3C. The heavier isotope vibrates a little more slowly, which nudges the activation barrier and therefore the rate; that small ratio is a classic probe of reaction mechanism.\n", "\n", "PyQuiver computes KIEs from the vibrational frequencies of the **reactant** (the *ground state*) and the **transition state** (the high point along the reaction path), using the Bigeleisen-Mayer equation. For each of those two structures you supply a frequency calculation in the form of its Cartesian Hessian, plus a short configuration listing the isotopic substitutions you want.\n", "\n", @@ -49,7 +49,7 @@ "\n", "Its `isotopologues` argument is a dictionary. Each key is a name you choose, and its value lists the substitutions to make as `(ground_state_atom, transition_state_atom, replacement)` tuples. Atom numbers start at 1 and follow the numbering in your output files; `replacement` is an isotope label from `weights.dat` such as `\"13C\"`, `\"2D\"`, or `\"17O\"`. Listing **more than one tuple substitutes several atoms at once**, as in `\"H/D\"` below, which deuterates two hydrogens together. (You can also give a plain number instead of a label to use an unusual mass, e.g. `(4, 4, 5000.0)`.)\n", "\n", - "The remaining settings are the `temperature` in kelvin, an empirical `scaling` factor applied to the frequencies, and `imag_threshold` in cm⁻¹ (a small imaginary frequency below this cutoff is ignored rather than treated as the reaction mode)." + "The remaining settings are the `temperature` in kelvin, an empirical `scaling` factor applied to the frequencies, and `imag_threshold` in cm\u207b\u00b9 (a small imaginary frequency below this cutoff is ignored rather than treated as the reaction mode)." ] }, { @@ -645,7 +645,9 @@ "\n", "The **Bell inverted parabola is the default**; it is the corrected value you would normally quote, and it is already in the table above. A third correction, **Skodje-Truhlar**, is worth using when the imaginary frequency is very large and the inverted-parabola picture breaks down; because it also needs the reaction's barrier height, it is computed on request rather than reported automatically.\n", "\n", - "Each formula below gives a single-mode coefficient $\\kappa$, and the factor applied to a KIE is the light/heavy ratio $\\kappa_{light}/\\kappa_{heavy}$. For an imaginary mode of magnitude $\\tilde\\nu^\\ddag$, write $u = {h c\\, \\tilde\\nu^\\ddag \\over k_B T}$." + "Each formula below gives a single-mode coefficient $\\kappa$, and the factor applied to a KIE is the light/heavy ratio $\\kappa_{light}/\\kappa_{heavy}$. For an imaginary mode of magnitude $\\tilde\\nu^\\ddag$, write $u = {h c\\, \\tilde\\nu^\\ddag \\over k_B T}$.\n", + "\n", + "PyQuiver warns when a correction is being used outside its range of validity: a **primary hydrogen KIE** (H/D in the reaction coordinate), where the harmonic corrections are unreliable, or a **Skodje-Truhlar** calculation **below its crossover temperature**, where the formula diverges." ] }, { @@ -774,7 +776,7 @@ "id": "277715cb", "metadata": {}, "source": [ - "You can also see how each vibrational mode contributes to that ratio. Below, each row is one mode: its light and heavy frequencies (cm⁻¹), the three Bigeleisen-Mayer factors (product, excitation, and zero-point), and their product, which is that mode's contribution to the RPFR:" + "You can also see how each vibrational mode contributes to that ratio. Below, each row is one mode: its light and heavy frequencies (cm\u207b\u00b9), the three Bigeleisen-Mayer factors (product, excitation, and zero-point), and their product, which is that mode's contribution to the RPFR:" ] }, { @@ -917,4 +919,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/test/test_kie.py b/test/test_kie.py index 308e9c2..b18283d 100644 --- a/test/test_kie.py +++ b/test/test_kie.py @@ -1,12 +1,15 @@ """Tests for the KIE/EIE orchestration in ``kie.py`` (the KIE class and the KIE_Calculation driver), beyond the pure physics functions.""" +import logging + import numpy as np import pytest from pyquiver.kie import KIE_Calculation from pyquiver.config import Config from pyquiver import quiver +import pyquiver.kie as kiemod CFG = ("gaussian", "claisen_demo.config") GS = ("gaussian", "claisen_gs.out") @@ -84,6 +87,24 @@ def test_rejects_bad_argument_types(tutorial): KIE_Calculation(123, tutorial(*GS), tutorial(*TS), style="g09") +def test_no_primary_hydrogen_warning_for_secondary(tutorial, caplog): + # the Claisen H/D is a secondary KIE (small TS imaginary ratio), so no warning + with caplog.at_level(logging.WARNING, logger="pyquiver"): + KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), style="gaussian") + assert not any("primary hydrogen" in m for m in caplog.messages) + + +def test_primary_hydrogen_warning(tutorial, caplog, monkeypatch): + # lower the threshold so the H/D substitution counts as "primary"; carbon and + # oxygen substitutions must not warn (they aren't hydrogen) + monkeypatch.setattr(kiemod, "PRIMARY_HYDROGEN_IMAG_RATIO", 1.0) + with caplog.at_level(logging.WARNING, logger="pyquiver"): + KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), style="gaussian") + primary = [m for m in caplog.messages if "primary hydrogen" in m] + assert any("H/D" in m for m in primary) + assert not any(("C1" in m or "O3" in m) for m in primary) + + def test_apply_reference_divides(kie): calc = kie() # apply_reference divides this KIE's value in place by the reference's; diff --git a/test/test_tunneling.py b/test/test_tunneling.py index 0b1a14c..8118851 100644 --- a/test/test_tunneling.py +++ b/test/test_tunneling.py @@ -1,6 +1,8 @@ """Tests for the Skodje-Truhlar tunnelling correction (pyquiver.tunneling and the KIE_Calculation.skodje_truhlar method).""" +import logging + import numpy as np import pytest @@ -31,6 +33,19 @@ def test_ratio_matches_kappa_definition(): assert skodje_truhlar(-500.0, -400.0, 300.0, barrier) == pytest.approx(expected) +def test_warns_below_crossover(caplog): + # large imaginary frequency at low T -> below the crossover -> warn + with caplog.at_level(logging.WARNING, logger="pyquiver"): + skodje_truhlar(-3000.0, -2800.0, 100.0, 0.03 * Eh) + assert any("crossover" in m for m in caplog.messages) + + +def test_no_crossover_warning_above(caplog): + with caplog.at_level(logging.WARNING, logger="pyquiver"): + skodje_truhlar(-500.0, -400.0, 300.0, 0.03 * Eh) + assert not any("crossover" in m for m in caplog.messages) + + def test_kappa_low_temperature_branch(): # a large imaginary frequency at low temperature gives alpha < beta, # exercising the deep-tunnelling form of the equation From 3d62098a6807115653e9f601c8574d4b355f167e Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 08:17:50 -0400 Subject: [PATCH 07/14] Detect primary hydrogen via reaction-mode participation Replace the imaginary-frequency-ratio proxy with a more direct test: how much the substituted hydrogen participates in the transition-state reaction mode. Use the mass-weighted mode composition (per-atom q^2 of the most-negative eigenvector), not raw Cartesian displacement -- Cartesian displacement is mass-amplified, so a light H always looks large (e.g. 0.15 for the secondary Claisen H/D) and fails to discriminate. The mass-weighted participation gives the Claisen H/D only 0.017 (correctly secondary) while the heavy C/O atoms carry the mode; a transferring (primary) H would carry a large fraction. - Isotopologue.reaction_mode_composition(): per-atom mass-weighted fraction of the reaction mode (memoised; one eigh on the TS). - KIE_Calculation warns when a substituted hydrogen's fraction exceeds PRIMARY_HYDROGEN_MODE_FRACTION (0.1). 197 tests, 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyquiver/kie.py | 37 +++++++++++++++++-------------------- pyquiver/quiver.py | 15 +++++++++++++++ test/test_kie.py | 6 +++--- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/pyquiver/kie.py b/pyquiver/kie.py index 88a136e..3dc89ae 100644 --- a/pyquiver/kie.py +++ b/pyquiver/kie.py @@ -15,10 +15,10 @@ c = PHYSICAL_CONSTANTS["c"] # in cm . s kB = PHYSICAL_CONSTANTS["kB"] # in J/K -# a hydrogen substitution whose transition-state imaginary-frequency isotope -# ratio exceeds this is treated as a *primary* H/D KIE (the H sits in the -# reaction coordinate); secondary substitutions barely shift that frequency -PRIMARY_HYDROGEN_IMAG_RATIO = 1.1 +# a hydrogen substitution is treated as a *primary* H/D KIE when the substituted +# atom carries at least this fraction of the (mass-weighted) reaction mode, i.e. +# it is part of the reaction coordinate; secondary hydrogens carry far less +PRIMARY_HYDROGEN_MODE_FRACTION = 0.1 class KIE_Calculation(object): def __init__(self, config, gs, ts, style="gaussian", n_jobs=1): @@ -213,22 +213,23 @@ def _imag(kie): def _warn_if_primary_hydrogen(self, name, kie): """Warn when an isotopologue is a primary hydrogen KIE, where the harmonic tunnelling corrections are unreliable. Treated as primary - hydrogen when a substitution replaces a hydrogen (Z=1) and the - transition-state imaginary-frequency isotope ratio is large (the H is - in the reaction coordinate).""" - if kie.eie_flag != 0 or kie.ts_imag_ratio is None: - return - if kie.ts_imag_ratio <= PRIMARY_HYDROGEN_IMAG_RATIO: + hydrogen when a substitution replaces a hydrogen (Z=1) that carries a + large fraction of the transition-state reaction mode (i.e. the H is in + the reaction coordinate).""" + if kie.eie_flag != 0: # no reaction mode for an EIE return znums = self.ts_system.atomic_numbers default_masses, sub_masses = kie.ts_tuple[0].masses, kie.ts_tuple[1].masses - substitutes_hydrogen = any(a != b and znums[i] == 1 - for i, (a, b) in enumerate(zip(default_masses, sub_masses))) - if substitutes_hydrogen: + substituted_hydrogens = [i for i, (a, b) in enumerate(zip(default_masses, sub_masses)) + if a != b and znums[i] == 1] + if not substituted_hydrogens: + return + composition = kie.ts_tuple[0].reaction_mode_composition() + if any(composition[i] > PRIMARY_HYDROGEN_MODE_FRACTION for i in substituted_hydrogens): logger.warning("isotopologue %s looks like a primary hydrogen KIE " - "(H/D in the reaction coordinate); the harmonic Wigner " - "and Bell tunnelling corrections are unreliable here", - name) + "(the substituted H participates strongly in the " + "reaction mode); the harmonic Wigner and Bell " + "tunnelling corrections are unreliable here", name) def build_mass_override_masses(self): config = self.config @@ -325,9 +326,6 @@ def __init__(self, name, gs_tuple, ts_tuple, temperature, scaling, imag_threshol self.gs_tuple, self.ts_tuple = gs_tuple, ts_tuple self.temperature = temperature self.scaling = scaling - # raw transition-state imaginary-frequency isotope ratio (light/heavy); - # set for KIEs, left None for EIEs - self.ts_imag_ratio = None logger.debug("Calculating KIE for isotopologue %s.", name) self.value = self.calculate_kie() @@ -349,7 +347,6 @@ def calculate_kie(self): else: # pragma: no cover raise ValueError("quiver attempted to run a KIE calculation after an EIE calculation. Check the frequency threshold.") - self.ts_imag_ratio = float(ts_imag_ratios[0]) # raw light/heavy kies = ts_imag_ratios * rpfr_gs/rpfr_ts return kies else: diff --git a/pyquiver/quiver.py b/pyquiver/quiver.py index 04e8cda..8ba6ee2 100644 --- a/pyquiver/quiver.py +++ b/pyquiver/quiver.py @@ -16,10 +16,25 @@ def __init__(self, id_, system, masses): self.system = system self.masses = masses self.frequencies = None + self._reaction_mode = None self.number_of_atoms = system.number_of_atoms self.mw_hessian = self.calculate_mw_hessian() + def reaction_mode_composition(self): + """Per-atom mass-weighted participation in the reaction mode (the most + negative eigenvalue of the mass-weighted Hessian), as fractions summing + to 1. An atom that is part of the reaction coordinate (e.g. a + transferring hydrogen) carries a large fraction; a spectator carries a + small one. Only meaningful for a transition state.""" + if self._reaction_mode is None: + conv_factor = PHYSICAL_CONSTANTS['Eh'] / (PHYSICAL_CONSTANTS['a0']**2 * PHYSICAL_CONSTANTS['amu']) + _, vecs = np.linalg.eigh(self.mw_hessian * conv_factor) + v = vecs[:, 0] # eigh returns ascending eigenvalues; most negative first + per_atom = (v.reshape(self.number_of_atoms, 3) ** 2).sum(axis=1) + self._reaction_mode = per_atom / per_atom.sum() + return self._reaction_mode + def __str__(self): returnString = "Isotopologue: %s\n" % self.name returnString += " masses: %s" % self.masses.__str__() diff --git a/test/test_kie.py b/test/test_kie.py index b18283d..6b4451d 100644 --- a/test/test_kie.py +++ b/test/test_kie.py @@ -95,9 +95,9 @@ def test_no_primary_hydrogen_warning_for_secondary(tutorial, caplog): def test_primary_hydrogen_warning(tutorial, caplog, monkeypatch): - # lower the threshold so the H/D substitution counts as "primary"; carbon and - # oxygen substitutions must not warn (they aren't hydrogen) - monkeypatch.setattr(kiemod, "PRIMARY_HYDROGEN_IMAG_RATIO", 1.0) + # lower the reaction-mode threshold so the H/D substitution counts as + # "primary"; carbon and oxygen substitutions must not warn (not hydrogen) + monkeypatch.setattr(kiemod, "PRIMARY_HYDROGEN_MODE_FRACTION", 0.01) with caplog.at_level(logging.WARNING, logger="pyquiver"): KIE_Calculation(tutorial(*CFG), tutorial(*GS), tutorial(*TS), style="gaussian") primary = [m for m in caplog.messages if "primary hydrogen" in m] From d8b9548711e32d682c32e6ec602ac3a732dd6f6b Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 08:19:07 -0400 Subject: [PATCH 08/14] Reword primary-H warning to flag the KIE prediction itself Make clear that for a primary H/D KIE the predicted value may be unreliable because tunnelling is large (not merely that the corrections are approximate). Notebook note updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyquiver/kie.py | 8 ++++---- scripts/pyquiver.ipynb | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyquiver/kie.py b/pyquiver/kie.py index 3dc89ae..a82b3ca 100644 --- a/pyquiver/kie.py +++ b/pyquiver/kie.py @@ -226,10 +226,10 @@ def _warn_if_primary_hydrogen(self, name, kie): return composition = kie.ts_tuple[0].reaction_mode_composition() if any(composition[i] > PRIMARY_HYDROGEN_MODE_FRACTION for i in substituted_hydrogens): - logger.warning("isotopologue %s looks like a primary hydrogen KIE " - "(the substituted H participates strongly in the " - "reaction mode); the harmonic Wigner and Bell " - "tunnelling corrections are unreliable here", name) + logger.warning("isotopologue %s is a primary hydrogen (H/D) KIE; the " + "predicted KIE may be unreliable because tunnelling is " + "large and the harmonic Wigner/Bell corrections are only " + "approximate in this regime", name) def build_mass_override_masses(self): config = self.config diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index 1025a9d..5766494 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -647,7 +647,7 @@ "\n", "Each formula below gives a single-mode coefficient $\\kappa$, and the factor applied to a KIE is the light/heavy ratio $\\kappa_{light}/\\kappa_{heavy}$. For an imaginary mode of magnitude $\\tilde\\nu^\\ddag$, write $u = {h c\\, \\tilde\\nu^\\ddag \\over k_B T}$.\n", "\n", - "PyQuiver warns when a correction is being used outside its range of validity: a **primary hydrogen KIE** (H/D in the reaction coordinate), where the harmonic corrections are unreliable, or a **Skodje-Truhlar** calculation **below its crossover temperature**, where the formula diverges." + "PyQuiver warns when a correction is being used outside its range of validity: a **primary hydrogen (H/D) KIE**, whose predicted value can be unreliable because tunnelling is large, or a **Skodje-Truhlar** calculation **below its crossover temperature**, where the formula diverges." ] }, { From bdd0d8218e870005b5cd32e755a442328a06e6b0 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 08:26:08 -0400 Subject: [PATCH 09/14] Rewrite notebook: GitHub-safe LaTeX, tighter, scientifically clearer Acting on three reviews (science, pedagogy, rendering): Rendering (the "doesn't render" cause): replace plain-TeX {a \over b} with \frac{}{} and \ddag with \ddagger throughout, and put display equations in their own blocks. These render in JupyterLab but break on GitHub/nbviewer. Pedagogy: example-driven and less verbose. First calculation is a single substitution; results-as-data follows immediately; multi-atom and custom-mass substitutions are introduced when first used; the batch "three ways" collapse to one example plus a sentence; tunnelling prose is trimmed. Added chemical interpretation of the numbers (normal vs inverse KIEs). Science: KIEs framed as a ZPE/free-energy effect (the electronic barrier is unchanged); softened "Bell is the default" for heavy-atom KIEs; primary-H caveat phrased as the harmonic treatment being approximate; noted the KIE is RPFR_GS/RPFR_TS times the imaginary-frequency ratio and tunnelling; renamed the diagnostic "product" column to "freq_ratio". Re-executed; outputs embedded. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/pyquiver.ipynb | 599 ++++++++++++++--------------------------- 1 file changed, 207 insertions(+), 392 deletions(-) diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index 5766494..17492cc 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -2,66 +2,59 @@ "cells": [ { "cell_type": "markdown", - "id": "a78b0dd8", + "id": "348a1d32", "metadata": {}, "source": [ "# Kinetic isotope effects with PyQuiver\n", "\n", - "A **kinetic isotope effect (KIE)** compares how fast a reaction goes for two versions of the same molecule that differ only in their isotopes, for example a normal molecule versus one with a single \u00b9\u00b2C replaced by \u00b9\u00b3C. The heavier isotope vibrates a little more slowly, which nudges the activation barrier and therefore the rate; that small ratio is a classic probe of reaction mechanism.\n", + "A **kinetic isotope effect (KIE)** is the ratio of reaction rates for two isotopologues, molecules that differ only by an isotopic substitution (for example ¹²C vs ¹³C at one atom). A heavier isotope lowers vibrational frequencies, and hence zero-point energies; because that change differs between the reactant and the transition state, the free-energy barrier, and so the rate, shifts slightly. The electronic barrier itself is unchanged, so a KIE is a sensitive, geometry-specific probe of mechanism.\n", "\n", - "PyQuiver computes KIEs from the vibrational frequencies of the **reactant** (the *ground state*) and the **transition state** (the high point along the reaction path), using the Bigeleisen-Mayer equation. For each of those two structures you supply a frequency calculation in the form of its Cartesian Hessian, plus a short configuration listing the isotopic substitutions you want.\n", - "\n", - "The examples below use the Claisen rearrangement files shipped with PyQuiver." + "PyQuiver computes KIEs from the vibrational frequencies of the reactant (the *ground state*) and the *transition state* using the Bigeleisen-Mayer equation. You provide the Cartesian Hessian from a frequency calculation on each, plus a short configuration of the substitutions to make." ] }, { "cell_type": "code", "execution_count": 1, - "id": "2ed82d4d", + "id": "661eabb0", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:50.763401Z", - "iopub.status.busy": "2026-06-17T12:01:50.762966Z", - "iopub.status.idle": "2026-06-17T12:01:51.356507Z", - "shell.execute_reply": "2026-06-17T12:01:51.356061Z" + "iopub.execute_input": "2026-06-17T12:25:39.908838Z", + "iopub.status.busy": "2026-06-17T12:25:39.908610Z", + "iopub.status.idle": "2026-06-17T12:25:40.530136Z", + "shell.execute_reply": "2026-06-17T12:25:40.529702Z" } }, "outputs": [], "source": [ - "import numpy as np\n", "import pandas as pd\n", - "import cctk\n", + "import cctk # optional; only used to read energies for Skodje-Truhlar\n", "from IPython.display import display\n", "\n", - "from pyquiver import KIE_Calculation, System, Config, Isotopologue, batch\n", + "from pyquiver import KIE_Calculation, Config, System, Isotopologue, batch\n", "from pyquiver.constants import DEFAULT_MASSES, REPLACEMENTS\n", "from pyquiver.kie import calculate_rpfr, partition_components" ] }, { "cell_type": "markdown", - "id": "02617220", + "id": "2bba6f5e", "metadata": {}, "source": [ - "## A simple calculation\n", - "\n", - "A calculation needs three things: a **configuration** (what to substitute, and the conditions), a **ground-state** file, and a **transition-state** file. The easiest way to make the configuration is `Config.from_dict`.\n", - "\n", - "Its `isotopologues` argument is a dictionary. Each key is a name you choose, and its value lists the substitutions to make as `(ground_state_atom, transition_state_atom, replacement)` tuples. Atom numbers start at 1 and follow the numbering in your output files; `replacement` is an isotope label from `weights.dat` such as `\"13C\"`, `\"2D\"`, or `\"17O\"`. Listing **more than one tuple substitutes several atoms at once**, as in `\"H/D\"` below, which deuterates two hydrogens together. (You can also give a plain number instead of a label to use an unusual mass, e.g. `(4, 4, 5000.0)`.)\n", + "## Your first calculation\n", "\n", - "The remaining settings are the `temperature` in kelvin, an empirical `scaling` factor applied to the frequencies, and `imag_threshold` in cm\u207b\u00b9 (a small imaginary frequency below this cutoff is ignored rather than treated as the reaction mode)." + "A calculation needs a **configuration**, a **ground-state** file, and a **transition-state** file. Build the configuration in Python with `Config.from_dict`: each isotopologue is a name mapped to a list of `(ground_atom, ts_atom, isotope)` substitutions, with atoms numbered as in your output file. Here we place a single ¹³C at atom 1. (`temperature` is in K, `scaling` is an empirical frequency-scaling factor, and `imag_threshold` in cm⁻¹ is the cutoff below which a small imaginary frequency is ignored rather than treated as the reaction mode.)" ] }, { "cell_type": "code", "execution_count": 2, - "id": "1eb8048d", + "id": "68119487", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.357760Z", - "iopub.status.busy": "2026-06-17T12:01:51.357639Z", - "iopub.status.idle": "2026-06-17T12:01:51.362098Z", - "shell.execute_reply": "2026-06-17T12:01:51.361738Z" + "iopub.execute_input": "2026-06-17T12:25:40.531372Z", + "iopub.status.busy": "2026-06-17T12:25:40.531262Z", + "iopub.status.idle": "2026-06-17T12:25:40.536884Z", + "shell.execute_reply": "2026-06-17T12:25:40.536516Z" } }, "outputs": [ @@ -74,8 +67,6 @@ "Isotopologue uncorrected Wigner inverted parabola\n", " KIE KIE KIE\n", "Isotopologue C1 1.0127 1.0144 1.0147 \n", - "Isotopologue O3 1.0188 1.0204 1.0206 \n", - "Isotopologue H/D 0.9548 0.9563 0.9566 \n", "\n", "Absolute KIEs are given.\n" ] @@ -83,14 +74,9 @@ ], "source": [ "config = Config.from_dict(\n", - " isotopologues={\n", - " \"C1\": [(1, 1, \"13C\")], # single substitution\n", - " \"O3\": [(3, 3, \"17O\")],\n", - " \"H/D\": [(7, 7, \"2D\"), (8, 8, \"2D\")], # double substitution: two atoms at once\n", - " },\n", + " isotopologues={\"C1\": [(1, 1, \"13C\")]},\n", " temperature=393, scaling=0.9614, imag_threshold=50,\n", ")\n", - "\n", "calc = KIE_Calculation(config, \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\",\n", " style=\"gaussian\")\n", "print(calc)" @@ -98,22 +84,24 @@ }, { "cell_type": "markdown", - "id": "0075c551", + "id": "af85e604", "metadata": {}, "source": [ - "The same calculation works with output from other programs; you only change `style`. PyQuiver reads Gaussian output files, ORCA `.hess` files, and its own compact `.qin` format, and the configuration stays exactly the same:" + "## Reading the result\n", + "\n", + "Each isotopologue gets three numbers: the **uncorrected** KIE and two tunnelling-corrected versions (**Wigner** and **Bell**, explained below). A value above 1 is a *normal* KIE (the light isotope reacts faster); below 1 is *inverse*. The ¹³C effect at C1 here is a small normal KIE near 1.01, typical of a carbon whose bonding changes at the transition state." ] }, { "cell_type": "code", "execution_count": 3, - "id": "0b6d9ccc", + "id": "c6c14337", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.363264Z", - "iopub.status.busy": "2026-06-17T12:01:51.363037Z", - "iopub.status.idle": "2026-06-17T12:01:51.368986Z", - "shell.execute_reply": "2026-06-17T12:01:51.368602Z" + "iopub.execute_input": "2026-06-17T12:25:40.537851Z", + "iopub.status.busy": "2026-06-17T12:25:40.537783Z", + "iopub.status.idle": "2026-06-17T12:25:40.544079Z", + "shell.execute_reply": "2026-06-17T12:25:40.543737Z" } }, "outputs": [ @@ -121,51 +109,85 @@ "name": "stdout", "output_type": "stream", "text": [ - "Gaussian C1: 1.0127\n", - "ORCA C1: 1.0130\n", - "native C1: 1.0127\n" + "KIEResult(name='C1', uncorrected=1.012746371067466, wigner=1.014443004304243, inverted_parabola=1.014743121186644)\n" ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nameuncorrectedwignerinverted_parabola
0C11.0127461.0144431.014743
\n", + "
" + ], + "text/plain": [ + " name uncorrected wigner inverted_parabola\n", + "0 C1 1.012746 1.014443 1.014743" + ] + }, + "metadata": {}, + "output_type": "display_data" } ], "source": [ - "orca = KIE_Calculation(config, \"../tutorial/orca/claisen_gs_freq.hess\", \"../tutorial/orca/claisen_ts_freq.hess\", style=\"orca\")\n", - "native = KIE_Calculation(config, \"../tutorial/pyquiver/claisen_gs.qin\", \"../tutorial/pyquiver/claisen_ts.qin\", style=\"pyquiver\")\n", - "\n", - "print(f\"Gaussian C1: {calc.results['C1'].uncorrected:.4f}\")\n", - "print(f\"ORCA C1: {orca.results['C1'].uncorrected:.4f}\")\n", - "print(f\"native C1: {native.results['C1'].uncorrected:.4f}\")" + "print(calc.results[\"C1\"]) # one isotopologue, by name\n", + "display(calc.to_dataframe()) # the whole set as a table" ] }, { "cell_type": "markdown", - "id": "f1367d70", + "id": "4e90fc24", "metadata": {}, "source": [ - "## Getting the results as data\n", + "## Substituting several positions\n", "\n", - "Printing the calculation gives a formatted table, but usually you want the numbers themselves. Each isotopologue has three KIE values: the **uncorrected** one and two tunnelling-corrected ones, **Wigner** and **Bell** (both explained later). Look one up by name, or get the whole set as a table with `to_dataframe()` (which needs `pandas`):" + "List more than one tuple to substitute several atoms in the same isotopologue, and use any `weights.dat` label (`13C`, `2D`, `17O`, ...); a bare number works too, for an unusual mass. Below, `H/D` deuterates two hydrogens at once. Note its KIE is *inverse* (below 1), an inverse secondary deuterium effect, while ¹³C and ¹⁷O give small normal effects." ] }, { "cell_type": "code", "execution_count": 4, - "id": "48cf8ee5", + "id": "d1ed9f2f", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.369961Z", - "iopub.status.busy": "2026-06-17T12:01:51.369898Z", - "iopub.status.idle": "2026-06-17T12:01:51.376210Z", - "shell.execute_reply": "2026-06-17T12:01:51.375866Z" + "iopub.execute_input": "2026-06-17T12:25:40.544940Z", + "iopub.status.busy": "2026-06-17T12:25:40.544887Z", + "iopub.status.idle": "2026-06-17T12:25:40.549663Z", + "shell.execute_reply": "2026-06-17T12:25:40.549380Z" } }, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "C1 uncorrected: 1.0127, Wigner: 1.0144, inverted parabola: 1.0147\n" - ] - }, { "data": { "text/html": [ @@ -231,32 +253,39 @@ } ], "source": [ - "c1 = calc.results[\"C1\"]\n", - "print(f\"C1 uncorrected: {c1.uncorrected:.4f}, Wigner: {c1.wigner:.4f}, inverted parabola: {c1.inverted_parabola:.4f}\")\n", - "\n", + "config = Config.from_dict(\n", + " isotopologues={\n", + " \"C1\": [(1, 1, \"13C\")],\n", + " \"O3\": [(3, 3, \"17O\")],\n", + " \"H/D\": [(7, 7, \"2D\"), (8, 8, \"2D\")], # two atoms at once\n", + " },\n", + " temperature=393, scaling=0.9614, imag_threshold=50,\n", + ")\n", + "calc = KIE_Calculation(config, \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\",\n", + " style=\"gaussian\")\n", "display(calc.to_dataframe())" ] }, { "cell_type": "markdown", - "id": "6f5ab41f", + "id": "a0c9392f", "metadata": {}, "source": [ - "## Reusing parsed files\n", + "## Other input formats\n", "\n", - "Every time you pass a filename, PyQuiver opens and re-parses that file. If you plan to run several calculations on the same structures, read each file once into a `System` object and reuse it. For example, to see how the KIE changes with temperature:" + "The same configuration runs on Gaussian, ORCA (`.hess`), or PyQuiver's native `.qin` files; just change `style`." ] }, { "cell_type": "code", "execution_count": 5, - "id": "d33f0bfe", + "id": "124ea698", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.377238Z", - "iopub.status.busy": "2026-06-17T12:01:51.377176Z", - "iopub.status.idle": "2026-06-17T12:01:51.381385Z", - "shell.execute_reply": "2026-06-17T12:01:51.381074Z" + "iopub.execute_input": "2026-06-17T12:25:40.550626Z", + "iopub.status.busy": "2026-06-17T12:25:40.550566Z", + "iopub.status.idle": "2026-06-17T12:25:40.555830Z", + "shell.execute_reply": "2026-06-17T12:25:40.555497Z" } }, "outputs": [ @@ -264,174 +293,84 @@ "name": "stdout", "output_type": "stream", "text": [ - "298 K: C1 KIE = 1.0144\n", - "350 K: C1 KIE = 1.0134\n", - "393 K: C1 KIE = 1.0127\n" + "Gaussian C1: 1.0127\n", + "ORCA C1: 1.0130\n", + "native C1: 1.0127\n" ] } ], "source": [ - "gs = System(\"../tutorial/gaussian/claisen_gs.out\")\n", - "ts = System(\"../tutorial/gaussian/claisen_ts.out\")\n", + "orca = KIE_Calculation(config, \"../tutorial/orca/claisen_gs_freq.hess\", \"../tutorial/orca/claisen_ts_freq.hess\", style=\"orca\")\n", + "native = KIE_Calculation(config, \"../tutorial/pyquiver/claisen_gs.qin\", \"../tutorial/pyquiver/claisen_ts.qin\", style=\"pyquiver\")\n", "\n", - "for T in (298, 350, 393):\n", - " cfg = Config.from_dict(isotopologues={\"C1\": [(1, 1, \"13C\")]},\n", - " temperature=T, scaling=0.9614, imag_threshold=50)\n", - " print(f\"{T} K: C1 KIE = {KIE_Calculation(cfg, gs, ts).results['C1'].uncorrected:.4f}\")" + "print(f\"Gaussian C1: {calc.results['C1'].uncorrected:.4f}\")\n", + "print(f\"ORCA C1: {orca.results['C1'].uncorrected:.4f}\")\n", + "print(f\"native C1: {native.results['C1'].uncorrected:.4f}\")" ] }, { "cell_type": "markdown", - "id": "3ea4cb3f", + "id": "5d7a7087", "metadata": {}, "source": [ - "## Running many files at once\n", + "## Parameter scans\n", "\n", - "Often you have many ground-state/transition-state pairs to run with the same configuration (for example, the same reaction at several levels of theory). `batch` runs them all and returns one combined table. You can describe the pairs in three equivalent ways." - ] - }, - { - "cell_type": "markdown", - "id": "f34f2d59", - "metadata": {}, - "source": [ - "**1. A dictionary**, where each key is a label and each value is the `(ground_state, transition_state)` pair:" + "Reading a file into a `System` parses it once, so you can reuse it across many calculations, for instance to scan temperature:" ] }, { "cell_type": "code", "execution_count": 6, - "id": "a108601a", + "id": "fffea2ea", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.382417Z", - "iopub.status.busy": "2026-06-17T12:01:51.382362Z", - "iopub.status.idle": "2026-06-17T12:01:51.390962Z", - "shell.execute_reply": "2026-06-17T12:01:51.390590Z" + "iopub.execute_input": "2026-06-17T12:25:40.556663Z", + "iopub.status.busy": "2026-06-17T12:25:40.556611Z", + "iopub.status.idle": "2026-06-17T12:25:40.560340Z", + "shell.execute_reply": "2026-06-17T12:25:40.560067Z" } }, "outputs": [ { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
labelnameuncorrectedwignerinverted_parabola
0run1C11.0127461.0144431.014743
1run1O31.0188421.0203651.020635
2run1H/D0.9548370.9563110.956572
3run2C11.0127461.0144431.014743
4run2O31.0188421.0203651.020635
5run2H/D0.9548370.9563110.956572
\n", - "
" - ], - "text/plain": [ - " label name uncorrected wigner inverted_parabola\n", - "0 run1 C1 1.012746 1.014443 1.014743\n", - "1 run1 O3 1.018842 1.020365 1.020635\n", - "2 run1 H/D 0.954837 0.956311 0.956572\n", - "3 run2 C1 1.012746 1.014443 1.014743\n", - "4 run2 O3 1.018842 1.020365 1.020635\n", - "5 run2 H/D 0.954837 0.956311 0.956572" - ] - }, - "metadata": {}, - "output_type": "display_data" + "name": "stdout", + "output_type": "stream", + "text": [ + "298 K: C1 KIE = 1.0144\n", + "350 K: C1 KIE = 1.0134\n", + "393 K: C1 KIE = 1.0127\n" + ] } ], "source": [ - "results = batch(config, {\n", - " \"run1\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", - " \"run2\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", - "})\n", - "display(results.to_dataframe())" + "gs = System(\"../tutorial/gaussian/claisen_gs.out\")\n", + "ts = System(\"../tutorial/gaussian/claisen_ts.out\")\n", + "\n", + "for T in (298, 350, 393):\n", + " cfg = Config.from_dict(isotopologues={\"C1\": [(1, 1, \"13C\")]},\n", + " temperature=T, scaling=0.9614, imag_threshold=50)\n", + " print(f\"{T} K: C1 KIE = {KIE_Calculation(cfg, gs, ts).results['C1'].uncorrected:.4f}\")" ] }, { "cell_type": "markdown", - "id": "f6a49746", + "id": "eacd0524", "metadata": {}, "source": [ - "**2. A list of `(label, ground_state, transition_state)` rows**, if you would rather not write a dictionary:" + "## Many structures at once\n", + "\n", + "`batch` runs one configuration over many ground-state/transition-state pairs and returns a single table. Give it a `{label: (gs, ts)}` dictionary (or a list of `(label, gs, ts)` triples, or bare `(gs, ts)` pairs labelled by filename)." ] }, { "cell_type": "code", "execution_count": 7, - "id": "dd0119bc", + "id": "4260b7b1", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.391979Z", - "iopub.status.busy": "2026-06-17T12:01:51.391921Z", - "iopub.status.idle": "2026-06-17T12:01:51.398689Z", - "shell.execute_reply": "2026-06-17T12:01:51.398372Z" + "iopub.execute_input": "2026-06-17T12:25:40.561183Z", + "iopub.status.busy": "2026-06-17T12:25:40.561130Z", + "iopub.status.idle": "2026-06-17T12:25:40.567464Z", + "shell.execute_reply": "2026-06-17T12:25:40.567123Z" } }, "outputs": [ @@ -466,7 +405,7 @@ " \n", " \n", " 0\n", - " run1\n", + " b3lyp\n", " C1\n", " 1.012746\n", " 1.014443\n", @@ -474,7 +413,7 @@ " \n", " \n", " 1\n", - " run1\n", + " b3lyp\n", " O3\n", " 1.018842\n", " 1.020365\n", @@ -482,7 +421,7 @@ " \n", " \n", " 2\n", - " run1\n", + " b3lyp\n", " H/D\n", " 0.954837\n", " 0.956311\n", @@ -490,7 +429,7 @@ " \n", " \n", " 3\n", - " run2\n", + " m06\n", " C1\n", " 1.012746\n", " 1.014443\n", @@ -498,7 +437,7 @@ " \n", " \n", " 4\n", - " run2\n", + " m06\n", " O3\n", " 1.018842\n", " 1.020365\n", @@ -506,107 +445,7 @@ " \n", " \n", " 5\n", - " run2\n", - " H/D\n", - " 0.954837\n", - " 0.956311\n", - " 0.956572\n", - " \n", - " \n", - "\n", - "" - ], - "text/plain": [ - " label name uncorrected wigner inverted_parabola\n", - "0 run1 C1 1.012746 1.014443 1.014743\n", - "1 run1 O3 1.018842 1.020365 1.020635\n", - "2 run1 H/D 0.954837 0.956311 0.956572\n", - "3 run2 C1 1.012746 1.014443 1.014743\n", - "4 run2 O3 1.018842 1.020365 1.020635\n", - "5 run2 H/D 0.954837 0.956311 0.956572" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "results = batch(config, [\n", - " (\"run1\", \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", - " (\"run2\", \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", - "])\n", - "display(results.to_dataframe())" - ] - }, - { - "cell_type": "markdown", - "id": "bd4e0f36", - "metadata": {}, - "source": [ - "**3. A list of `(ground_state, transition_state)` pairs** with no labels, in which case PyQuiver names each row after its ground-state file:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "29785bb3", - "metadata": { - "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.399903Z", - "iopub.status.busy": "2026-06-17T12:01:51.399839Z", - "iopub.status.idle": "2026-06-17T12:01:51.405032Z", - "shell.execute_reply": "2026-06-17T12:01:51.404677Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", " \n", " \n", " \n", @@ -617,10 +456,13 @@ "" ], "text/plain": [ - " label name uncorrected wigner inverted_parabola\n", - "0 claisen_gs C1 1.012746 1.014443 1.014743\n", - "1 claisen_gs O3 1.018842 1.020365 1.020635\n", - "2 claisen_gs H/D 0.954837 0.956311 0.956572" + " label name uncorrected wigner inverted_parabola\n", + "0 b3lyp C1 1.012746 1.014443 1.014743\n", + "1 b3lyp O3 1.018842 1.020365 1.020635\n", + "2 b3lyp H/D 0.954837 0.956311 0.956572\n", + "3 m06 C1 1.012746 1.014443 1.014743\n", + "4 m06 O3 1.018842 1.020365 1.020635\n", + "5 m06 H/D 0.954837 0.956311 0.956572" ] }, "metadata": {}, @@ -628,78 +470,55 @@ } ], "source": [ - "results = batch(config, [\n", - " (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", - "])\n", + "results = batch(config, {\n", + " \"b3lyp\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + " \"m06\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", + "})\n", "display(results.to_dataframe())" ] }, { "cell_type": "markdown", - "id": "64de6147", + "id": "16eb7b85", "metadata": {}, "source": [ "## Tunnelling corrections\n", "\n", - "Near the transition state, light nuclei can \"tunnel\" through the energy barrier instead of passing over it, which speeds the reaction up (most noticeably for hydrogen). PyQuiver reports every KIE three ways: **uncorrected** (no tunnelling) plus two common corrections, **Wigner** and **Bell** (the \"inverted parabola\").\n", + "Light nuclei can tunnel through the barrier near the transition state, speeding the reaction up. PyQuiver always reports the **uncorrected** KIE plus two corrections, **Wigner** and **Bell** (the inverted parabola); Bell is the default. For heavy-atom KIEs (¹³C, ¹⁷O) the corrections barely change the number, so the choice rarely matters. For hydrogen they can be large, and for a **primary** H/D KIE (the deuterated atom lies in the reaction coordinate) the harmonic treatment is only approximate, so PyQuiver warns and the prediction should be treated with caution.\n", "\n", - "The **Bell inverted parabola is the default**; it is the corrected value you would normally quote, and it is already in the table above. A third correction, **Skodje-Truhlar**, is worth using when the imaginary frequency is very large and the inverted-parabola picture breaks down; because it also needs the reaction's barrier height, it is computed on request rather than reported automatically.\n", + "Writing $u = \\frac{h c\\, \\tilde\\nu}{k_B T}$ for an imaginary mode of magnitude $\\tilde\\nu$ (cm⁻¹), the single-mode coefficients are\n", "\n", - "Each formula below gives a single-mode coefficient $\\kappa$, and the factor applied to a KIE is the light/heavy ratio $\\kappa_{light}/\\kappa_{heavy}$. For an imaginary mode of magnitude $\\tilde\\nu^\\ddag$, write $u = {h c\\, \\tilde\\nu^\\ddag \\over k_B T}$.\n", + "$$\\kappa_{\\text{Wigner}} = 1 + \\frac{u^2}{24}, \\qquad \\kappa_{\\text{Bell}} = \\frac{u/2}{\\sin(u/2)},$$\n", "\n", - "PyQuiver warns when a correction is being used outside its range of validity: a **primary hydrogen (H/D) KIE**, whose predicted value can be unreliable because tunnelling is large, or a **Skodje-Truhlar** calculation **below its crossover temperature**, where the formula diverges." + "and the factor applied to a KIE is $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$." ] }, { "cell_type": "markdown", - "id": "fd7d3fe2", - "metadata": {}, - "source": [ - "### Wigner\n", - "\n", - "$$ \\kappa = 1 + {u^2 \\over 24} $$" - ] - }, - { - "cell_type": "markdown", - "id": "dea76ced", - "metadata": {}, - "source": [ - "### Bell (inverted parabola)\n", - "\n", - "$$ \\kappa = {u/2 \\over \\sin(u/2)} $$" - ] - }, - { - "cell_type": "markdown", - "id": "1506dcd8", + "id": "1cd23132", "metadata": {}, "source": [ "### Skodje-Truhlar\n", "\n", - "An alternative (*J. Phys. Chem.* **1981**, 85, 624-626) that can be more applicable for very large imaginary frequencies. (here $\\nu^\\ddag$ is the frequency in Hz, i.e. $c\\,\\tilde\\nu^\\ddag$.) With $\\alpha = {2\\pi \\over h\\nu^\\ddag}$, $\\beta = {1 \\over k_B T}$, and the barrier height in the exothermic direction $V = V_{TS} - \\max(V_{SM}, V_{PR})$:\n", - "\n", - "small $\\nu^\\ddag$ / high $T$ ($\\alpha > \\beta$):\n", - "$$ \\kappa = {\\beta\\pi/\\alpha \\over \\sin(\\beta\\pi/\\alpha)} - {\\beta \\over \\alpha - \\beta}\\,e^{(\\beta-\\alpha)V} $$\n", + "When the imaginary frequency is large the inverted parabola is unreliable, and the Skodje-Truhlar correction (*J. Phys. Chem.* **1981**, *85*, 624) is preferable. It needs the barrier height, so you supply the reactant, transition-state, and product energies. With $\\alpha = \\frac{2\\pi}{h\\nu^{\\ddagger}}$, $\\beta = \\frac{1}{k_B T}$ (where $\\nu^{\\ddagger} = c\\,\\tilde\\nu^{\\ddagger}$ is the frequency in Hz), and barrier $V = V_{\\text{TS}} - \\max(V_{\\text{reactant}}, V_{\\text{product}})$:\n", "\n", - "large $\\nu^\\ddag$ / low $T$ ($\\alpha < \\beta$):\n", - "$$ \\kappa = {\\beta \\over \\beta-\\alpha}\\left(e^{(\\beta-\\alpha)V} - 1\\right) $$\n", + "$$\\kappa = \\frac{\\beta\\pi/\\alpha}{\\sin(\\beta\\pi/\\alpha)} - \\frac{\\beta}{\\alpha-\\beta}\\,e^{(\\beta-\\alpha)V} \\quad (\\alpha > \\beta),$$\n", "\n", - "and $\\kappa = {\\beta\\pi/\\alpha \\over \\sin(\\beta\\pi/\\alpha)}$ when $\\alpha = \\beta$.\n", + "$$\\kappa = \\frac{\\beta}{\\beta-\\alpha}\\left(e^{(\\beta-\\alpha)V} - 1\\right) \\quad (\\alpha < \\beta).$$\n", "\n", - "Skodje-Truhlar needs electronic energies, read here from the Gaussian outputs with `cctk`:" + "Below the crossover ($\\alpha = \\beta$) the expression diverges, and PyQuiver warns." ] }, { "cell_type": "code", - "execution_count": 9, - "id": "82a4aee6", + "execution_count": 8, + "id": "6153ddef", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.406381Z", - "iopub.status.busy": "2026-06-17T12:01:51.406107Z", - "iopub.status.idle": "2026-06-17T12:01:51.415556Z", - "shell.execute_reply": "2026-06-17T12:01:51.415014Z" + "iopub.execute_input": "2026-06-17T12:25:40.568415Z", + "iopub.status.busy": "2026-06-17T12:25:40.568359Z", + "iopub.status.idle": "2026-06-17T12:25:40.574204Z", + "shell.execute_reply": "2026-06-17T12:25:40.573866Z" } }, "outputs": [ @@ -711,7 +530,7 @@ " ('H/D', np.float64(0.956572091397035))])" ] }, - "execution_count": 9, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -719,35 +538,35 @@ "source": [ "def energy(path):\n", " ensemble = cctk.GaussianFile.read_file(path).ensemble\n", - " return ensemble.get_properties_dict(ensemble.molecules[-1])[\"energy\"]\n", + " return ensemble.get_properties_dict(ensemble.molecules[-1])[\"energy\"] # hartree\n", "\n", "E_reactant = energy(\"../tutorial/gaussian/claisen_gs.out\")\n", "E_ts = energy(\"../tutorial/gaussian/claisen_ts.out\")\n", "\n", - "# energies in hartree; product taken equal to the reactant here\n", + "# product taken equal to the reactant here\n", "calc.skodje_truhlar(reactant_energy=E_reactant, product_energy=E_reactant, ts_energy=E_ts)" ] }, { "cell_type": "markdown", - "id": "62ebddc9", + "id": "122d7a20", "metadata": {}, "source": [ "## Under the hood: partition function ratios\n", "\n", - "This section is optional. Internally a KIE is built from **reduced isotopic partition function ratios** (RPFRs), one for the ground state and one for the transition state, each comparing the light and heavy isotopologue. To look inside, build the light and heavy `Isotopologue` objects yourself and call `calculate_rpfr`:" + "*(Optional.)* A KIE is the ratio of two reduced isotopic partition function ratios (RPFRs), one for the ground state and one for the transition state, times the classical imaginary-frequency ratio and the tunnelling factor. You can compute an RPFR directly from a light and a heavy `Isotopologue`:" ] }, { "cell_type": "code", - "execution_count": 10, - "id": "3df3abc6", + "execution_count": 9, + "id": "3dc0f56a", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.417479Z", - "iopub.status.busy": "2026-06-17T12:01:51.417364Z", - "iopub.status.idle": "2026-06-17T12:01:51.421297Z", - "shell.execute_reply": "2026-06-17T12:01:51.420621Z" + "iopub.execute_input": "2026-06-17T12:25:40.575109Z", + "iopub.status.busy": "2026-06-17T12:25:40.575055Z", + "iopub.status.idle": "2026-06-17T12:25:40.576975Z", + "shell.execute_reply": "2026-06-17T12:25:40.576722Z" } }, "outputs": [ @@ -773,22 +592,22 @@ }, { "cell_type": "markdown", - "id": "277715cb", + "id": "c2fda086", "metadata": {}, "source": [ - "You can also see how each vibrational mode contributes to that ratio. Below, each row is one mode: its light and heavy frequencies (cm\u207b\u00b9), the three Bigeleisen-Mayer factors (product, excitation, and zero-point), and their product, which is that mode's contribution to the RPFR:" + "Each mode's contribution to the RPFR factors into a frequency ratio, an excitation term, and a zero-point term (their product is the contribution):" ] }, { "cell_type": "code", - "execution_count": 11, - "id": "25d57409", + "execution_count": 10, + "id": "097d069b", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:01:51.423199Z", - "iopub.status.busy": "2026-06-17T12:01:51.422962Z", - "iopub.status.idle": "2026-06-17T12:01:51.432890Z", - "shell.execute_reply": "2026-06-17T12:01:51.431889Z" + "iopub.execute_input": "2026-06-17T12:25:40.577761Z", + "iopub.status.busy": "2026-06-17T12:25:40.577709Z", + "iopub.status.idle": "2026-06-17T12:25:40.583062Z", + "shell.execute_reply": "2026-06-17T12:25:40.582715Z" } }, "outputs": [ @@ -815,7 +634,7 @@ " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", @@ -872,12 +691,12 @@ "" ], "text/plain": [ - " light_freq heavy_freq product excitation ZPE contribution\n", - "0 67.790652 67.225890 0.991669 1.007402 1.001034 1.000043\n", - "1 159.689475 159.684161 0.999967 1.000024 1.000010 1.000001\n", - "2 179.501554 178.499592 0.994418 1.003970 1.001836 1.000199\n", - "3 246.525660 246.070898 0.998155 1.001138 1.000833 1.000123\n", - "4 276.476300 273.262636 0.988376 1.006802 1.005900 1.000971" + " light_freq heavy_freq freq_ratio excitation ZPE contribution\n", + "0 67.790652 67.225890 0.991669 1.007402 1.001034 1.000043\n", + "1 159.689475 159.684161 0.999967 1.000024 1.000010 1.000001\n", + "2 179.501554 178.499592 0.994418 1.003970 1.001836 1.000199\n", + "3 246.525660 246.070898 0.998155 1.001138 1.000833 1.000123\n", + "4 276.476300 273.262636 0.988376 1.006802 1.005900 1.000971" ] }, "metadata": {}, @@ -886,14 +705,10 @@ ], "source": [ "components = partition_components(heavy_freqs, light_freqs, 393)\n", - "modes = pd.DataFrame({\n", - " \"light_freq\": light_freqs,\n", - " \"heavy_freq\": heavy_freqs,\n", - " \"product\": components[:, 0],\n", - " \"excitation\": components[:, 1],\n", - " \"ZPE\": components[:, 2],\n", - "})\n", - "modes[\"contribution\"] = modes[[\"product\", \"excitation\", \"ZPE\"]].prod(axis=1)\n", + "modes = pd.DataFrame(components, columns=[\"freq_ratio\", \"excitation\", \"ZPE\"])\n", + "modes.insert(0, \"light_freq\", light_freqs)\n", + "modes.insert(1, \"heavy_freq\", heavy_freqs)\n", + "modes[\"contribution\"] = modes[[\"freq_ratio\", \"excitation\", \"ZPE\"]].prod(axis=1)\n", "display(modes.head())" ] } @@ -919,4 +734,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} From 1c2e4b35e29b14eee6e6533085632cfcbcf1ecb9 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 08:27:46 -0400 Subject: [PATCH 10/14] Restore full Skodje-Truhlar math in the notebook Keep all the math: re-add the natural-frequency definition and the alpha=beta case alongside the alpha>beta and alpha --- scripts/pyquiver.ipynb | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index 17492cc..d40bb79 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -7,7 +7,7 @@ "source": [ "# Kinetic isotope effects with PyQuiver\n", "\n", - "A **kinetic isotope effect (KIE)** is the ratio of reaction rates for two isotopologues, molecules that differ only by an isotopic substitution (for example ¹²C vs ¹³C at one atom). A heavier isotope lowers vibrational frequencies, and hence zero-point energies; because that change differs between the reactant and the transition state, the free-energy barrier, and so the rate, shifts slightly. The electronic barrier itself is unchanged, so a KIE is a sensitive, geometry-specific probe of mechanism.\n", + "A **kinetic isotope effect (KIE)** is the ratio of reaction rates for two isotopologues, molecules that differ only by an isotopic substitution (for example \u00b9\u00b2C vs \u00b9\u00b3C at one atom). A heavier isotope lowers vibrational frequencies, and hence zero-point energies; because that change differs between the reactant and the transition state, the free-energy barrier, and so the rate, shifts slightly. The electronic barrier itself is unchanged, so a KIE is a sensitive, geometry-specific probe of mechanism.\n", "\n", "PyQuiver computes KIEs from the vibrational frequencies of the reactant (the *ground state*) and the *transition state* using the Bigeleisen-Mayer equation. You provide the Cartesian Hessian from a frequency calculation on each, plus a short configuration of the substitutions to make." ] @@ -42,7 +42,7 @@ "source": [ "## Your first calculation\n", "\n", - "A calculation needs a **configuration**, a **ground-state** file, and a **transition-state** file. Build the configuration in Python with `Config.from_dict`: each isotopologue is a name mapped to a list of `(ground_atom, ts_atom, isotope)` substitutions, with atoms numbered as in your output file. Here we place a single ¹³C at atom 1. (`temperature` is in K, `scaling` is an empirical frequency-scaling factor, and `imag_threshold` in cm⁻¹ is the cutoff below which a small imaginary frequency is ignored rather than treated as the reaction mode.)" + "A calculation needs a **configuration**, a **ground-state** file, and a **transition-state** file. Build the configuration in Python with `Config.from_dict`: each isotopologue is a name mapped to a list of `(ground_atom, ts_atom, isotope)` substitutions, with atoms numbered as in your output file. Here we place a single \u00b9\u00b3C at atom 1. (`temperature` is in K, `scaling` is an empirical frequency-scaling factor, and `imag_threshold` in cm\u207b\u00b9 is the cutoff below which a small imaginary frequency is ignored rather than treated as the reaction mode.)" ] }, { @@ -89,7 +89,7 @@ "source": [ "## Reading the result\n", "\n", - "Each isotopologue gets three numbers: the **uncorrected** KIE and two tunnelling-corrected versions (**Wigner** and **Bell**, explained below). A value above 1 is a *normal* KIE (the light isotope reacts faster); below 1 is *inverse*. The ¹³C effect at C1 here is a small normal KIE near 1.01, typical of a carbon whose bonding changes at the transition state." + "Each isotopologue gets three numbers: the **uncorrected** KIE and two tunnelling-corrected versions (**Wigner** and **Bell**, explained below). A value above 1 is a *normal* KIE (the light isotope reacts faster); below 1 is *inverse*. The \u00b9\u00b3C effect at C1 here is a small normal KIE near 1.01, typical of a carbon whose bonding changes at the transition state." ] }, { @@ -172,7 +172,7 @@ "source": [ "## Substituting several positions\n", "\n", - "List more than one tuple to substitute several atoms in the same isotopologue, and use any `weights.dat` label (`13C`, `2D`, `17O`, ...); a bare number works too, for an unusual mass. Below, `H/D` deuterates two hydrogens at once. Note its KIE is *inverse* (below 1), an inverse secondary deuterium effect, while ¹³C and ¹⁷O give small normal effects." + "List more than one tuple to substitute several atoms in the same isotopologue, and use any `weights.dat` label (`13C`, `2D`, `17O`, ...); a bare number works too, for an unusual mass. Below, `H/D` deuterates two hydrogens at once. Note its KIE is *inverse* (below 1), an inverse secondary deuterium effect, while \u00b9\u00b3C and \u00b9\u2077O give small normal effects." ] }, { @@ -484,9 +484,9 @@ "source": [ "## Tunnelling corrections\n", "\n", - "Light nuclei can tunnel through the barrier near the transition state, speeding the reaction up. PyQuiver always reports the **uncorrected** KIE plus two corrections, **Wigner** and **Bell** (the inverted parabola); Bell is the default. For heavy-atom KIEs (¹³C, ¹⁷O) the corrections barely change the number, so the choice rarely matters. For hydrogen they can be large, and for a **primary** H/D KIE (the deuterated atom lies in the reaction coordinate) the harmonic treatment is only approximate, so PyQuiver warns and the prediction should be treated with caution.\n", + "Light nuclei can tunnel through the barrier near the transition state, speeding the reaction up. PyQuiver always reports the **uncorrected** KIE plus two corrections, **Wigner** and **Bell** (the inverted parabola); Bell is the default. For heavy-atom KIEs (\u00b9\u00b3C, \u00b9\u2077O) the corrections barely change the number, so the choice rarely matters. For hydrogen they can be large, and for a **primary** H/D KIE (the deuterated atom lies in the reaction coordinate) the harmonic treatment is only approximate, so PyQuiver warns and the prediction should be treated with caution.\n", "\n", - "Writing $u = \\frac{h c\\, \\tilde\\nu}{k_B T}$ for an imaginary mode of magnitude $\\tilde\\nu$ (cm⁻¹), the single-mode coefficients are\n", + "Writing $u = \\frac{h c\\, \\tilde\\nu}{k_B T}$ for an imaginary mode of magnitude $\\tilde\\nu$ (cm\u207b\u00b9), the single-mode coefficients are\n", "\n", "$$\\kappa_{\\text{Wigner}} = 1 + \\frac{u^2}{24}, \\qquad \\kappa_{\\text{Bell}} = \\frac{u/2}{\\sin(u/2)},$$\n", "\n", @@ -500,13 +500,27 @@ "source": [ "### Skodje-Truhlar\n", "\n", - "When the imaginary frequency is large the inverted parabola is unreliable, and the Skodje-Truhlar correction (*J. Phys. Chem.* **1981**, *85*, 624) is preferable. It needs the barrier height, so you supply the reactant, transition-state, and product energies. With $\\alpha = \\frac{2\\pi}{h\\nu^{\\ddagger}}$, $\\beta = \\frac{1}{k_B T}$ (where $\\nu^{\\ddagger} = c\\,\\tilde\\nu^{\\ddagger}$ is the frequency in Hz), and barrier $V = V_{\\text{TS}} - \\max(V_{\\text{reactant}}, V_{\\text{product}})$:\n", + "When the imaginary frequency is large the inverted parabola is unreliable, and the Skodje-Truhlar correction (*J. Phys. Chem.* **1981**, *85*, 624) is preferable. It needs the barrier height, so you supply the reactant, transition-state, and product energies.\n", "\n", - "$$\\kappa = \\frac{\\beta\\pi/\\alpha}{\\sin(\\beta\\pi/\\alpha)} - \\frac{\\beta}{\\alpha-\\beta}\\,e^{(\\beta-\\alpha)V} \\quad (\\alpha > \\beta),$$\n", + "The natural frequency of the imaginary mode (from its force constant $k^{\\ddagger}$ and reduced mass $\\mu^{\\ddagger}$), handled automatically by PyQuiver, is\n", "\n", - "$$\\kappa = \\frac{\\beta}{\\beta-\\alpha}\\left(e^{(\\beta-\\alpha)V} - 1\\right) \\quad (\\alpha < \\beta).$$\n", + "$$\\nu^{\\ddagger} = \\frac{1}{2\\pi}\\sqrt{\\frac{k^{\\ddagger}}{\\mu^{\\ddagger}}}.$$\n", "\n", - "Below the crossover ($\\alpha = \\beta$) the expression diverges, and PyQuiver warns." + "With $\\alpha = \\frac{2\\pi}{h\\nu^{\\ddagger}}$, $\\beta = \\frac{1}{k_B T}$ (so $\\nu^{\\ddagger} = c\\,\\tilde\\nu^{\\ddagger}$ is in Hz), and the barrier height in the exothermic direction $V = V_{\\text{TS}} - \\max(V_{\\text{reactant}}, V_{\\text{product}})$, there are three cases.\n", + "\n", + "For a small $\\nu^{\\ddagger}$ / high $T$ ($\\alpha > \\beta$):\n", + "\n", + "$$\\kappa = \\frac{\\beta\\pi/\\alpha}{\\sin(\\beta\\pi/\\alpha)} - \\frac{\\beta}{\\alpha-\\beta}\\,e^{(\\beta-\\alpha)V}.$$\n", + "\n", + "For a large $\\nu^{\\ddagger}$ / low $T$ ($\\alpha < \\beta$):\n", + "\n", + "$$\\kappa = \\frac{\\beta}{\\beta-\\alpha}\\left(e^{(\\beta-\\alpha)V} - 1\\right).$$\n", + "\n", + "And at the crossover $\\alpha = \\beta$:\n", + "\n", + "$$\\kappa = \\frac{\\beta\\pi/\\alpha}{\\sin(\\beta\\pi/\\alpha)}.$$\n", + "\n", + "Below the crossover temperature ($\\alpha < \\beta$) the correction grows without bound, so PyQuiver warns there. As with Wigner and Bell, the factor applied to a KIE is $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$." ] }, { @@ -734,4 +748,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file From d6d9160be94b382930ae93eee96663d799e9b002 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 18:16:09 -0400 Subject: [PATCH 11/14] Simplify batch to a dict-only API; tutorial prose pass batch now takes one form, a {label: (gs, ts)} dictionary (the Pythonic "one obvious way"); the (label, gs, ts) triple and bare (gs, ts) forms are removed. You build the dict from disk yourself, e.g. with a comprehension over a glob and a lambda to generate labels. Tests and docs (README, AUTOQUIVER) updated. Notebook: rewritten background and "Hello Isotope" sections; batch section now shows the dict form plus building it from files with a label lambda; re-executed so outputs are embedded. 195 tests, 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 11 +- pyquiver/batch.py | 40 ++---- scripts/pyquiver.ipynb | 267 ++++++++++++++++++++++++++++++----------- src/AUTOQUIVER.md | 28 ++--- test/test_batch.py | 18 +-- 5 files changed, 224 insertions(+), 140 deletions(-) diff --git a/README.md b/README.md index 50a61f6..e731979 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,8 @@ calc.skodje_truhlar(reactant_energy=E_sm, product_energy=E_pr, ts_energy=E_ts) # -> {isotopologue: corrected_KIE} ``` -To run one configuration over many structures, use `batch`: you build the -ground/transition-state pairs from your own file globbing (no naming -convention assumed) and get back a table: +To run one configuration over many structures, use `batch` with a +`{label: (gs, ts)}` dictionary and get back one table: ```python from pyquiver import batch @@ -112,9 +111,9 @@ results.to_dataframe() # label, name, uncorrected, wigner, inverted_parab results["b3lyp"] # the KIE_Calculation for that pair ``` -Pairs may also be `(label, gs, ts)` triples or bare `(gs, ts)` tuples (label -inferred from the filename). Pass `energies={label: (reactant, ts, product)}` -to add a Skodje-Truhlar column. +You normally build that dictionary from the files on disk, e.g. +`{f.removesuffix("_gs.out"): (f, f.replace("_gs", "_ts")) for f in glob.glob("*_gs.out")}`. +Pass `energies={label: (reactant, ts, product)}` to add a Skodje-Truhlar column. PyQuiver logs through the standard `logging` module (logger name `pyquiver`) and raises exceptions on bad input; it never prints on import or calls `sys.exit`, diff --git a/pyquiver/batch.py b/pyquiver/batch.py index 58d50f7..00fa579 100644 --- a/pyquiver/batch.py +++ b/pyquiver/batch.py @@ -1,45 +1,25 @@ """Run a KIE/EIE calculation over many ground-state/transition-state pairs. -File discovery and pairing are left to ordinary Python (glob, pathlib, ...); -``batch`` just maps one configuration over the pairs you give it and collects -the results into a table. No filename convention is assumed:: +``batch`` maps one configuration over a ``{label: (gs, ts)}`` dictionary and +collects the results into a table. File discovery is left to ordinary Python, +so you build that dictionary however you like (e.g. with a dict comprehension +over a glob):: import glob from pyquiver import batch - gs = sorted(glob.glob("*_reactant.out")) - ts = sorted(glob.glob("*_ts.out")) - results = batch("demo.config", zip(gs, ts)) # labels inferred from filenames + pairs = {f.split("/")[-1].removesuffix("_gs.out"): (f, f.replace("_gs", "_ts")) + for f in glob.glob("*_gs.out")} + results = batch("demo.config", pairs) results.to_dataframe() - -Pairs may be given as a mapping ``{label: (gs, ts)}``, an iterable of -``(label, gs, ts)`` triples, or an iterable of ``(gs, ts)`` pairs (in which -case the label is the ground-state filename without its extension). """ -import os from collections import OrderedDict from .config import Config from .kie import KIE_Calculation -def _normalize_pairs(pairs): - """Yield (label, gs, ts) from any of the supported pair formats.""" - items = pairs.items() if hasattr(pairs, "items") else pairs - for item in items: - if hasattr(pairs, "items"): - label, (gs, ts) = item - elif len(item) == 3: - label, gs, ts = item - elif len(item) == 2: - gs, ts = item - label = os.path.splitext(os.path.basename(str(gs)))[0] - else: - raise ValueError("each pair must be (gs, ts) or (label, gs, ts)") - yield label, gs, ts - - class BatchResults(object): """Results of a :func:`batch` run: one KIE_Calculation per label.""" @@ -103,9 +83,7 @@ def batch(config, pairs, style="gaussian", n_jobs=1, energies=None): """Run a KIE calculation for each ground-state/transition-state pair. ``config`` is a path to a .config file or a :class:`~pyquiver.Config`. - ``pairs`` is a mapping ``{label: (gs, ts)}`` or an iterable of ``(gs, ts)`` - or ``(label, gs, ts)`` (see the module docstring). Returns - :class:`BatchResults`. + ``pairs`` is a ``{label: (gs, ts)}`` dictionary. Returns :class:`BatchResults`. If ``energies`` is given, a Skodje-Truhlar correction is computed for every pair and included in the results. It maps each label to the three @@ -119,7 +97,7 @@ def batch(config, pairs, style="gaussian", n_jobs=1, energies=None): calcs = OrderedDict() st = OrderedDict() if energies is not None else None - for label, gs, ts in _normalize_pairs(pairs): + for label, (gs, ts) in pairs.items(): calc = KIE_Calculation(config, gs, ts, style=style, n_jobs=n_jobs) calcs[label] = calc if energies is not None: diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index d40bb79..2774cef 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -1,33 +1,23 @@ { "cells": [ - { - "cell_type": "markdown", - "id": "348a1d32", - "metadata": {}, - "source": [ - "# Kinetic isotope effects with PyQuiver\n", - "\n", - "A **kinetic isotope effect (KIE)** is the ratio of reaction rates for two isotopologues, molecules that differ only by an isotopic substitution (for example \u00b9\u00b2C vs \u00b9\u00b3C at one atom). A heavier isotope lowers vibrational frequencies, and hence zero-point energies; because that change differs between the reactant and the transition state, the free-energy barrier, and so the rate, shifts slightly. The electronic barrier itself is unchanged, so a KIE is a sensitive, geometry-specific probe of mechanism.\n", - "\n", - "PyQuiver computes KIEs from the vibrational frequencies of the reactant (the *ground state*) and the *transition state* using the Bigeleisen-Mayer equation. You provide the Cartesian Hessian from a frequency calculation on each, plus a short configuration of the substitutions to make." - ] - }, { "cell_type": "code", "execution_count": 1, "id": "661eabb0", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:39.908838Z", - "iopub.status.busy": "2026-06-17T12:25:39.908610Z", - "iopub.status.idle": "2026-06-17T12:25:40.530136Z", - "shell.execute_reply": "2026-06-17T12:25:40.529702Z" + "iopub.execute_input": "2026-06-17T22:13:46.462077Z", + "iopub.status.busy": "2026-06-17T22:13:46.461916Z", + "iopub.status.idle": "2026-06-17T22:13:47.054997Z", + "shell.execute_reply": "2026-06-17T22:13:47.054592Z" } }, "outputs": [], "source": [ + "import glob\n", + "import os\n", "import pandas as pd\n", - "import cctk # optional; only used to read energies for Skodje-Truhlar\n", + "import cctk\n", "from IPython.display import display\n", "\n", "from pyquiver import KIE_Calculation, Config, System, Isotopologue, batch\n", @@ -35,14 +25,48 @@ "from pyquiver.kie import calculate_rpfr, partition_components" ] }, + { + "cell_type": "markdown", + "id": "348a1d32", + "metadata": {}, + "source": [ + "# PyQuiver Tutorial\n", + "\n", + "This notebook explains how to run KIE calculations using *PyQuiver*." + ] + }, + { + "cell_type": "markdown", + "id": "7985a51f-0f3c-4dfa-8e97-75f23c06364b", + "metadata": {}, + "source": [ + "### A little background:\n", + "\n", + "A **kinetic isotope effect (KIE)** is the ratio of rate constants for a given reaction on isotopic substitution (conventionally, light over heavy). Two molecules that differ by isotopic substitution (e.g., replacement of ¹²C by ¹³C) are called **isotopologues**.\n", + "\n", + "Here's some intuition for why KIEs exist in the first place. Think about bonds as balls connected by springs. Those balls are always vibrating, and you can think of a reaction as the spring vibrating so hard that it breaks. The faster the balls are vibrating, the more often the spring is really stretched, and the faster the reaction. Now, if we replace one of the balls with a heavier ball, the vibrations will slow down, and the reactions will be slower. By studying how rate constants change with the position of substitution, we can learn something about the mechanism.\n", + "\n", + "What is \"the mechanism,\" exactly? One way to think about that is it's the shape of the potential energy surface (PES), which is roughly how strong the springs are in our analogy. When we change the masses of the balls, the springs don't change stiffness (i.e., the mechanism doesn't change). This is the Born-Oppenheimer approximation.\n", + "\n", + "PyQuiver computes KIEs from the vibrational frequencies of the reactant (the *ground state*) and the *transition state* using the Bigeleisen-Mayer equation. You specify which isotopic substitutions you want to make, as well as the ground and transition state files." + ] + }, { "cell_type": "markdown", "id": "2bba6f5e", "metadata": {}, "source": [ - "## Your first calculation\n", + "### Hello Isotope\n", + "\n", + "Every calculation needs three ingredients: a **configuration** containing the desired isotopic substitutions, plus a frequency calculation for the **ground state** (the reactant) and one for the **transition state**. (It's your job to ensure that these are appropriately connected and valid states for the question at hand. Also, if you are using Gaussian, be sure to turn the verbose `#p` flag on.)\n", + "\n", + "`\"C1\": [(1, 1, \"13C\")]` : call the isotopologue `C1`, replace atom 1 in the ground state and atom 1 in the transition state with ¹³C. (The \"natural\" isotope for each atomic number is defined in `weights.dat`.)\n", + "\n", + "(Atom numbers start from 1, not 0.)\n", "\n", - "A calculation needs a **configuration**, a **ground-state** file, and a **transition-state** file. Build the configuration in Python with `Config.from_dict`: each isotopologue is a name mapped to a list of `(ground_atom, ts_atom, isotope)` substitutions, with atoms numbered as in your output file. Here we place a single \u00b9\u00b3C at atom 1. (`temperature` is in K, `scaling` is an empirical frequency-scaling factor, and `imag_threshold` in cm\u207b\u00b9 is the cutoff below which a small imaginary frequency is ignored rather than treated as the reaction mode.)" + "The remaining settings are the `temperature` (K), a `scaling` factor applied to the frequencies (an empirical correction for anharmonicity), and `imag_threshold` (cm⁻¹). Any imaginary frequency smaller than `imag_threshold` is treated as numerical noise and dropped.\n", + "\n", + "Here's a simple example using the Claisen rearrangement:" ] }, { @@ -51,10 +75,10 @@ "id": "68119487", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.531372Z", - "iopub.status.busy": "2026-06-17T12:25:40.531262Z", - "iopub.status.idle": "2026-06-17T12:25:40.536884Z", - "shell.execute_reply": "2026-06-17T12:25:40.536516Z" + "iopub.execute_input": "2026-06-17T22:13:47.056367Z", + "iopub.status.busy": "2026-06-17T22:13:47.056246Z", + "iopub.status.idle": "2026-06-17T22:13:47.061662Z", + "shell.execute_reply": "2026-06-17T22:13:47.061307Z" } }, "outputs": [ @@ -89,7 +113,9 @@ "source": [ "## Reading the result\n", "\n", - "Each isotopologue gets three numbers: the **uncorrected** KIE and two tunnelling-corrected versions (**Wigner** and **Bell**, explained below). A value above 1 is a *normal* KIE (the light isotope reacts faster); below 1 is *inverse*. The \u00b9\u00b3C effect at C1 here is a small normal KIE near 1.01, typical of a carbon whose bonding changes at the transition state." + "Each isotopologue gets three numbers. The **uncorrected** number is the semi-classical KIE in the absence of tunneling. However, since lighter isotopologues will tunnel more easily than heavy ones, the KIE needs a correction factor (>1). The **Wigner** and **Bell** inverted parabola corrections are calculated automatically (most people just take the Bell).\n", + "\n", + "The ¹³C effect at C1 here is a small normal KIE near 1.01, typical of a carbon whose bonding changes at the transition state." ] }, { @@ -98,10 +124,10 @@ "id": "c6c14337", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.537851Z", - "iopub.status.busy": "2026-06-17T12:25:40.537783Z", - "iopub.status.idle": "2026-06-17T12:25:40.544079Z", - "shell.execute_reply": "2026-06-17T12:25:40.543737Z" + "iopub.execute_input": "2026-06-17T22:13:47.062688Z", + "iopub.status.busy": "2026-06-17T22:13:47.062624Z", + "iopub.status.idle": "2026-06-17T22:13:47.069294Z", + "shell.execute_reply": "2026-06-17T22:13:47.069064Z" } }, "outputs": [ @@ -170,9 +196,7 @@ "id": "4e90fc24", "metadata": {}, "source": [ - "## Substituting several positions\n", - "\n", - "List more than one tuple to substitute several atoms in the same isotopologue, and use any `weights.dat` label (`13C`, `2D`, `17O`, ...); a bare number works too, for an unusual mass. Below, `H/D` deuterates two hydrogens at once. Note its KIE is *inverse* (below 1), an inverse secondary deuterium effect, while \u00b9\u00b3C and \u00b9\u2077O give small normal effects." + "Here's how to make multiple substitutions in the same isotopologue:" ] }, { @@ -181,10 +205,10 @@ "id": "d1ed9f2f", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.544940Z", - "iopub.status.busy": "2026-06-17T12:25:40.544887Z", - "iopub.status.idle": "2026-06-17T12:25:40.549663Z", - "shell.execute_reply": "2026-06-17T12:25:40.549380Z" + "iopub.execute_input": "2026-06-17T22:13:47.070324Z", + "iopub.status.busy": "2026-06-17T22:13:47.070270Z", + "iopub.status.idle": "2026-06-17T22:13:47.075034Z", + "shell.execute_reply": "2026-06-17T22:13:47.074790Z" } }, "outputs": [ @@ -273,7 +297,7 @@ "source": [ "## Other input formats\n", "\n", - "The same configuration runs on Gaussian, ORCA (`.hess`), or PyQuiver's native `.qin` files; just change `style`." + "The same configuration runs on Gaussian, ORCA (`.hess`), or PyQuiver's native `.qin` files; just change `style`. Different programs could give slightly different Hessians and therefore slightly different KIEs." ] }, { @@ -282,10 +306,10 @@ "id": "124ea698", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.550626Z", - "iopub.status.busy": "2026-06-17T12:25:40.550566Z", - "iopub.status.idle": "2026-06-17T12:25:40.555830Z", - "shell.execute_reply": "2026-06-17T12:25:40.555497Z" + "iopub.execute_input": "2026-06-17T22:13:47.076013Z", + "iopub.status.busy": "2026-06-17T22:13:47.075958Z", + "iopub.status.idle": "2026-06-17T22:13:47.081030Z", + "shell.execute_reply": "2026-06-17T22:13:47.080758Z" } }, "outputs": [ @@ -315,7 +339,7 @@ "source": [ "## Parameter scans\n", "\n", - "Reading a file into a `System` parses it once, so you can reuse it across many calculations, for instance to scan temperature:" + "Reading a file into a `System` parses it once, so you can reuse it across many calculations to save time. For instance, you could calculate KIE as a function of temperature:" ] }, { @@ -324,10 +348,10 @@ "id": "fffea2ea", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.556663Z", - "iopub.status.busy": "2026-06-17T12:25:40.556611Z", - "iopub.status.idle": "2026-06-17T12:25:40.560340Z", - "shell.execute_reply": "2026-06-17T12:25:40.560067Z" + "iopub.execute_input": "2026-06-17T22:13:47.081978Z", + "iopub.status.busy": "2026-06-17T22:13:47.081927Z", + "iopub.status.idle": "2026-06-17T22:13:47.085732Z", + "shell.execute_reply": "2026-06-17T22:13:47.085477Z" } }, "outputs": [ @@ -353,24 +377,24 @@ }, { "cell_type": "markdown", - "id": "eacd0524", + "id": "698250fd", "metadata": {}, "source": [ "## Many structures at once\n", "\n", - "`batch` runs one configuration over many ground-state/transition-state pairs and returns a single table. Give it a `{label: (gs, ts)}` dictionary (or a list of `(label, gs, ts)` triples, or bare `(gs, ts)` pairs labelled by filename)." + "`batch` runs one configuration over many ground-state/transition-state pairs and returns a single table. Give it a `{label: (gs, ts)}` dictionary, where each label is a name you choose for that pair:" ] }, { "cell_type": "code", "execution_count": 7, - "id": "4260b7b1", + "id": "18f140e9", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.561183Z", - "iopub.status.busy": "2026-06-17T12:25:40.561130Z", - "iopub.status.idle": "2026-06-17T12:25:40.567464Z", - "shell.execute_reply": "2026-06-17T12:25:40.567123Z" + "iopub.execute_input": "2026-06-17T22:13:47.086661Z", + "iopub.status.busy": "2026-06-17T22:13:47.086604Z", + "iopub.status.idle": "2026-06-17T22:13:47.092946Z", + "shell.execute_reply": "2026-06-17T22:13:47.092651Z" } }, "outputs": [ @@ -477,6 +501,105 @@ "display(results.to_dataframe())" ] }, + { + "cell_type": "markdown", + "id": "08854ed8", + "metadata": {}, + "source": [ + "Usually you build that dictionary from the files on disk. Here we glob the ground-state files, turn each filename into a label with a small `lambda`, and pair it with the matching transition state:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "5c51ab94", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-17T22:13:47.093834Z", + "iopub.status.busy": "2026-06-17T22:13:47.093786Z", + "iopub.status.idle": "2026-06-17T22:13:47.098508Z", + "shell.execute_reply": "2026-06-17T22:13:47.098243Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "
labelnameuncorrectedwignerinverted_parabola
0claisen_gsC11.0127461.0144431.014743
1claisen_gsO31.0188421.0203651.020635
2claisen_gsm06H/D0.9548370.956311light_freqheavy_freqproductfreq_ratioexcitationZPEcontribution
\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
labelnameuncorrectedwignerinverted_parabola
0claisenC11.0127461.0144431.014743
1claisenO31.0188421.0203651.020635
2claisenH/D0.9548370.9563110.956572
\n", + "
" + ], + "text/plain": [ + " label name uncorrected wigner inverted_parabola\n", + "0 claisen C1 1.012746 1.014443 1.014743\n", + "1 claisen O3 1.018842 1.020365 1.020635\n", + "2 claisen H/D 0.954837 0.956311 0.956572" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "label = lambda path: os.path.basename(path).removesuffix(\"_gs.out\")\n", + "\n", + "gs_files = sorted(glob.glob(\"../tutorial/gaussian/*_gs.out\"))\n", + "pairs = {label(gs): (gs, gs.replace(\"_gs\", \"_ts\")) for gs in gs_files}\n", + "\n", + "results = batch(config, pairs)\n", + "display(results.to_dataframe())" + ] + }, { "cell_type": "markdown", "id": "16eb7b85", @@ -484,9 +607,9 @@ "source": [ "## Tunnelling corrections\n", "\n", - "Light nuclei can tunnel through the barrier near the transition state, speeding the reaction up. PyQuiver always reports the **uncorrected** KIE plus two corrections, **Wigner** and **Bell** (the inverted parabola); Bell is the default. For heavy-atom KIEs (\u00b9\u00b3C, \u00b9\u2077O) the corrections barely change the number, so the choice rarely matters. For hydrogen they can be large, and for a **primary** H/D KIE (the deuterated atom lies in the reaction coordinate) the harmonic treatment is only approximate, so PyQuiver warns and the prediction should be treated with caution.\n", + "Light nuclei can tunnel through the barrier near the transition state, speeding the reaction up. PyQuiver always reports the **uncorrected** KIE plus two corrections, **Wigner** and **Bell** (the inverted parabola); Bell is the default. For heavy-atom KIEs (¹³C, ¹⁷O) the corrections barely change the number, so the choice rarely matters. For hydrogen they can be large, and for a **primary** H/D KIE (the deuterated atom lies in the reaction coordinate) the harmonic treatment is only approximate, so PyQuiver warns and the prediction should be treated with caution.\n", "\n", - "Writing $u = \\frac{h c\\, \\tilde\\nu}{k_B T}$ for an imaginary mode of magnitude $\\tilde\\nu$ (cm\u207b\u00b9), the single-mode coefficients are\n", + "Writing $u = \\frac{h c\\, \\tilde\\nu}{k_B T}$ for an imaginary mode of magnitude $\\tilde\\nu$ (cm⁻¹), the single-mode coefficients are\n", "\n", "$$\\kappa_{\\text{Wigner}} = 1 + \\frac{u^2}{24}, \\qquad \\kappa_{\\text{Bell}} = \\frac{u/2}{\\sin(u/2)},$$\n", "\n", @@ -525,14 +648,14 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "id": "6153ddef", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.568415Z", - "iopub.status.busy": "2026-06-17T12:25:40.568359Z", - "iopub.status.idle": "2026-06-17T12:25:40.574204Z", - "shell.execute_reply": "2026-06-17T12:25:40.573866Z" + "iopub.execute_input": "2026-06-17T22:13:47.099455Z", + "iopub.status.busy": "2026-06-17T22:13:47.099407Z", + "iopub.status.idle": "2026-06-17T22:13:47.105148Z", + "shell.execute_reply": "2026-06-17T22:13:47.104804Z" } }, "outputs": [ @@ -544,7 +667,7 @@ " ('H/D', np.float64(0.956572091397035))])" ] }, - "execution_count": 8, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -573,14 +696,14 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 10, "id": "3dc0f56a", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.575109Z", - "iopub.status.busy": "2026-06-17T12:25:40.575055Z", - "iopub.status.idle": "2026-06-17T12:25:40.576975Z", - "shell.execute_reply": "2026-06-17T12:25:40.576722Z" + "iopub.execute_input": "2026-06-17T22:13:47.106030Z", + "iopub.status.busy": "2026-06-17T22:13:47.105981Z", + "iopub.status.idle": "2026-06-17T22:13:47.107734Z", + "shell.execute_reply": "2026-06-17T22:13:47.107439Z" } }, "outputs": [ @@ -614,14 +737,14 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "id": "097d069b", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T12:25:40.577761Z", - "iopub.status.busy": "2026-06-17T12:25:40.577709Z", - "iopub.status.idle": "2026-06-17T12:25:40.583062Z", - "shell.execute_reply": "2026-06-17T12:25:40.582715Z" + "iopub.execute_input": "2026-06-17T22:13:47.108547Z", + "iopub.status.busy": "2026-06-17T22:13:47.108498Z", + "iopub.status.idle": "2026-06-17T22:13:47.113980Z", + "shell.execute_reply": "2026-06-17T22:13:47.113649Z" } }, "outputs": [ @@ -729,7 +852,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -748,4 +871,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/src/AUTOQUIVER.md b/src/AUTOQUIVER.md index d739846..7c3cfca 100644 --- a/src/AUTOQUIVER.md +++ b/src/AUTOQUIVER.md @@ -1,9 +1,9 @@ # Batch processing To run one configuration over many ground-state/transition-state pairs, use -`pyquiver.batch`. File discovery and pairing are left to ordinary Python, so no -filename convention is assumed; `batch` just maps the configuration over the -pairs you give it and collects the results into a table. +`pyquiver.batch` with a `{label: (gs, ts)}` dictionary. File discovery is left +to ordinary Python, so no filename convention is assumed; `batch` just maps the +configuration over the pairs you give it and collects the results into a table. ```python from pyquiver import batch @@ -18,27 +18,23 @@ results.to_csv("kies.csv") results["b3lyp"] # the KIE_Calculation for that pair ``` -## Specifying pairs +## Building the pairs -`pairs` may be given three ways: - -* a mapping `{label: (gs, ts)}` -* an iterable of `(label, gs, ts)` triples -* an iterable of `(gs, ts)` pairs, where the label is inferred from the - ground-state filename (without its extension) - -So you can discover and pair files however you like, then hand the result to -`batch`. For example, zipping two globs: +You usually build the dictionary from the files on disk. Glob the ground-state +files, derive a label from each filename, and pair it with the matching +transition state: ```python import glob from pyquiver import batch -gs = sorted(glob.glob("*_reactant.out")) -ts = sorted(glob.glob("*_ts.out")) -results = batch("demo.config", zip(gs, ts)) +label = lambda path: path.removesuffix("_gs.out") +pairs = {label(gs): (gs, gs.replace("_gs", "_ts")) for gs in sorted(glob.glob("*_gs.out"))} +results = batch("demo.config", pairs) ``` +Adjust the `label`/`replace` rule to match your own naming. + ## Skodje-Truhlar in batch Pass `energies` mapping each label to its three single-point electronic diff --git a/test/test_batch.py b/test/test_batch.py index ae804d1..9922ae8 100644 --- a/test/test_batch.py +++ b/test/test_batch.py @@ -21,25 +21,13 @@ def test_pairs_as_mapping(files): assert set(results) == {"a", "b"} assert len(results) == 2 assert "C1" in results["a"].KIES # __getitem__ -> KIE_Calculation + assert [label for label, _ in results.items()] == ["a", "b"] # order preserved -def test_pairs_as_triples(files): - cfg, gs, ts = files - results = batch(cfg, [("low", gs, ts), ("high", gs, ts)]) - assert [label for label, _ in results.items()] == ["low", "high"] - - -def test_pairs_as_two_tuples_infer_label(files): - cfg, gs, ts = files - results = batch(cfg, [(gs, ts)]) - # label inferred from the gs filename without its extension - assert "claisen_gs" in results - - -def test_bad_pair_shape_rejected(files): +def test_bad_pair_value_rejected(files): cfg, gs, ts = files with pytest.raises(ValueError): - batch(cfg, [(gs,)]) + batch(cfg, {"a": (gs,)}) # value must be a (gs, ts) pair def test_to_records_and_dataframe(files): From ce63384643751f4341b6008d585ed1a18cd4e8b5 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 18:20:28 -0400 Subject: [PATCH 12/14] Notebook: label batch runs by level of theory, not molecule The glob example labelled the run "claisen" (the molecule), which is pointless. Batch's purpose is screening, so the label should be the axis you vary (the level of theory). Keep one runnable dict example labelled b3lyp/m06, and make the build-from-disk version an illustrative snippet that derives the level of theory from filenames (b3lyp_gs.out -> b3lyp) with a lambda. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/pyquiver.ipynb | 193 ++++++++++++----------------------------- 1 file changed, 55 insertions(+), 138 deletions(-) diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index 2774cef..0250ea7 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -6,10 +6,10 @@ "id": "661eabb0", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:46.462077Z", - "iopub.status.busy": "2026-06-17T22:13:46.461916Z", - "iopub.status.idle": "2026-06-17T22:13:47.054997Z", - "shell.execute_reply": "2026-06-17T22:13:47.054592Z" + "iopub.execute_input": "2026-06-17T22:19:56.554170Z", + "iopub.status.busy": "2026-06-17T22:19:56.554112Z", + "iopub.status.idle": "2026-06-17T22:19:57.039301Z", + "shell.execute_reply": "2026-06-17T22:19:57.038932Z" } }, "outputs": [], @@ -75,10 +75,10 @@ "id": "68119487", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.056367Z", - "iopub.status.busy": "2026-06-17T22:13:47.056246Z", - "iopub.status.idle": "2026-06-17T22:13:47.061662Z", - "shell.execute_reply": "2026-06-17T22:13:47.061307Z" + "iopub.execute_input": "2026-06-17T22:19:57.040560Z", + "iopub.status.busy": "2026-06-17T22:19:57.040457Z", + "iopub.status.idle": "2026-06-17T22:19:57.045839Z", + "shell.execute_reply": "2026-06-17T22:19:57.045549Z" } }, "outputs": [ @@ -124,10 +124,10 @@ "id": "c6c14337", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.062688Z", - "iopub.status.busy": "2026-06-17T22:13:47.062624Z", - "iopub.status.idle": "2026-06-17T22:13:47.069294Z", - "shell.execute_reply": "2026-06-17T22:13:47.069064Z" + "iopub.execute_input": "2026-06-17T22:19:57.046725Z", + "iopub.status.busy": "2026-06-17T22:19:57.046672Z", + "iopub.status.idle": "2026-06-17T22:19:57.053738Z", + "shell.execute_reply": "2026-06-17T22:19:57.053471Z" } }, "outputs": [ @@ -205,10 +205,10 @@ "id": "d1ed9f2f", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.070324Z", - "iopub.status.busy": "2026-06-17T22:13:47.070270Z", - "iopub.status.idle": "2026-06-17T22:13:47.075034Z", - "shell.execute_reply": "2026-06-17T22:13:47.074790Z" + "iopub.execute_input": "2026-06-17T22:19:57.054693Z", + "iopub.status.busy": "2026-06-17T22:19:57.054640Z", + "iopub.status.idle": "2026-06-17T22:19:57.059481Z", + "shell.execute_reply": "2026-06-17T22:19:57.059192Z" } }, "outputs": [ @@ -306,10 +306,10 @@ "id": "124ea698", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.076013Z", - "iopub.status.busy": "2026-06-17T22:13:47.075958Z", - "iopub.status.idle": "2026-06-17T22:13:47.081030Z", - "shell.execute_reply": "2026-06-17T22:13:47.080758Z" + "iopub.execute_input": "2026-06-17T22:19:57.060311Z", + "iopub.status.busy": "2026-06-17T22:19:57.060249Z", + "iopub.status.idle": "2026-06-17T22:19:57.065555Z", + "shell.execute_reply": "2026-06-17T22:19:57.065208Z" } }, "outputs": [ @@ -348,10 +348,10 @@ "id": "fffea2ea", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.081978Z", - "iopub.status.busy": "2026-06-17T22:13:47.081927Z", - "iopub.status.idle": "2026-06-17T22:13:47.085732Z", - "shell.execute_reply": "2026-06-17T22:13:47.085477Z" + "iopub.execute_input": "2026-06-17T22:19:57.066377Z", + "iopub.status.busy": "2026-06-17T22:19:57.066326Z", + "iopub.status.idle": "2026-06-17T22:19:57.070052Z", + "shell.execute_reply": "2026-06-17T22:19:57.069813Z" } }, "outputs": [ @@ -377,24 +377,24 @@ }, { "cell_type": "markdown", - "id": "698250fd", + "id": "ded0c9a4", "metadata": {}, "source": [ "## Many structures at once\n", "\n", - "`batch` runs one configuration over many ground-state/transition-state pairs and returns a single table. Give it a `{label: (gs, ts)}` dictionary, where each label is a name you choose for that pair:" + "`batch` runs one configuration over many ground-state/transition-state pairs and returns a single table. Give it a `{label: (gs, ts)}` dictionary; the labels are whatever you are screening, most often the level of theory:" ] }, { "cell_type": "code", "execution_count": 7, - "id": "18f140e9", + "id": "9efc070e", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.086661Z", - "iopub.status.busy": "2026-06-17T22:13:47.086604Z", - "iopub.status.idle": "2026-06-17T22:13:47.092946Z", - "shell.execute_reply": "2026-06-17T22:13:47.092651Z" + "iopub.execute_input": "2026-06-17T22:19:57.070975Z", + "iopub.status.busy": "2026-06-17T22:19:57.070925Z", + "iopub.status.idle": "2026-06-17T22:19:57.077727Z", + "shell.execute_reply": "2026-06-17T22:19:57.077449Z" } }, "outputs": [ @@ -494,6 +494,7 @@ } ], "source": [ + "# each label is a separate calculation; this demo just reuses one structure's files\n", "results = batch(config, {\n", " \"b3lyp\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", " \"m06\": (\"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\"),\n", @@ -503,101 +504,17 @@ }, { "cell_type": "markdown", - "id": "08854ed8", + "id": "f493ba64", "metadata": {}, "source": [ - "Usually you build that dictionary from the files on disk. Here we glob the ground-state files, turn each filename into a label with a small `lambda`, and pair it with the matching transition state:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "5c51ab94", - "metadata": { - "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.093834Z", - "iopub.status.busy": "2026-06-17T22:13:47.093786Z", - "iopub.status.idle": "2026-06-17T22:13:47.098508Z", - "shell.execute_reply": "2026-06-17T22:13:47.098243Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
labelnameuncorrectedwignerinverted_parabola
0claisenC11.0127461.0144431.014743
1claisenO31.0188421.0203651.020635
2claisenH/D0.9548370.9563110.956572
\n", - "
" - ], - "text/plain": [ - " label name uncorrected wigner inverted_parabola\n", - "0 claisen C1 1.012746 1.014443 1.014743\n", - "1 claisen O3 1.018842 1.020365 1.020635\n", - "2 claisen H/D 0.954837 0.956311 0.956572" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "label = lambda path: os.path.basename(path).removesuffix(\"_gs.out\")\n", - "\n", - "gs_files = sorted(glob.glob(\"../tutorial/gaussian/*_gs.out\"))\n", - "pairs = {label(gs): (gs, gs.replace(\"_gs\", \"_ts\")) for gs in gs_files}\n", + "Usually you build that dictionary from the files on disk. If each level of theory has its own pair of files (say `b3lyp_gs.out`, `b3lyp_ts.out`, `m06_gs.out`, ...), a small `lambda` turns each filename into its label:\n", "\n", + "```python\n", + "level = lambda gs: os.path.basename(gs).removesuffix(\"_gs.out\") # the level of theory\n", + "pairs = {level(gs): (gs, gs.replace(\"_gs\", \"_ts\"))\n", + " for gs in sorted(glob.glob(\"calcs/*_gs.out\"))}\n", "results = batch(config, pairs)\n", - "display(results.to_dataframe())" + "```" ] }, { @@ -648,14 +565,14 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "id": "6153ddef", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.099455Z", - "iopub.status.busy": "2026-06-17T22:13:47.099407Z", - "iopub.status.idle": "2026-06-17T22:13:47.105148Z", - "shell.execute_reply": "2026-06-17T22:13:47.104804Z" + "iopub.execute_input": "2026-06-17T22:19:57.078885Z", + "iopub.status.busy": "2026-06-17T22:19:57.078821Z", + "iopub.status.idle": "2026-06-17T22:19:57.084839Z", + "shell.execute_reply": "2026-06-17T22:19:57.084601Z" } }, "outputs": [ @@ -667,7 +584,7 @@ " ('H/D', np.float64(0.956572091397035))])" ] }, - "execution_count": 9, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -696,14 +613,14 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "id": "3dc0f56a", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.106030Z", - "iopub.status.busy": "2026-06-17T22:13:47.105981Z", - "iopub.status.idle": "2026-06-17T22:13:47.107734Z", - "shell.execute_reply": "2026-06-17T22:13:47.107439Z" + "iopub.execute_input": "2026-06-17T22:19:57.085771Z", + "iopub.status.busy": "2026-06-17T22:19:57.085713Z", + "iopub.status.idle": "2026-06-17T22:19:57.087707Z", + "shell.execute_reply": "2026-06-17T22:19:57.087454Z" } }, "outputs": [ @@ -737,14 +654,14 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "id": "097d069b", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:13:47.108547Z", - "iopub.status.busy": "2026-06-17T22:13:47.108498Z", - "iopub.status.idle": "2026-06-17T22:13:47.113980Z", - "shell.execute_reply": "2026-06-17T22:13:47.113649Z" + "iopub.execute_input": "2026-06-17T22:19:57.088513Z", + "iopub.status.busy": "2026-06-17T22:19:57.088457Z", + "iopub.status.idle": "2026-06-17T22:19:57.094481Z", + "shell.execute_reply": "2026-06-17T22:19:57.094106Z" } }, "outputs": [ From 49b6b36895729151184278ced3c9bc4ef6257949 Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 18:34:47 -0400 Subject: [PATCH 13/14] Rename "inverted parabola" to "infinite parabola"; credit Occhialini Bell's kappa = (u/2)/sin(u/2) is the correction for an *infinite* (untruncated) inverted parabolic barrier, conventionally the "infinite parabola"; that is the precise name, and it diverges at the crossover (sin(u/2) -> 0) as expected. Rename throughout: the KIEResult.infinite_parabola field, the printed table header, get_row, docstrings, tests, README/DETAILS/AUTOQUIVER/TUTORIAL, and the notebook (re-executed so outputs/headers refresh). "infinite" and "inverted" are the same length, so fixed-width tables stay aligned. Also credit Gino Occhialini for the Skodje-Truhlar tunnelling correction in the README acknowledgements. 195 tests, 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 +-- pyquiver/__init__.py | 2 +- pyquiver/kie.py | 6 +-- pyquiver/results.py | 6 +-- pyquiver/tunneling.py | 2 +- scripts/pyquiver.ipynb | 108 ++++++++++++++++++++-------------------- src/AUTOQUIVER.md | 2 +- test/test_kie.py | 2 +- test/test_physics.py | 2 +- test/test_regression.py | 2 +- test/test_results.py | 8 +-- tutorial/TUTORIAL.md | 2 +- 12 files changed, 76 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index e731979..c17738f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ * automatically read frequencies from [`Gaussian`](https://gaussian.com) (g09/g16) and [`ORCA`](https://orcaforum.kofo.mpg.de/app.php/portal) output files * excellent performance for larger systems, with optional multi-threading * custom isotopic substitutions and arbitrary temperature -* tunnelling corrections: Wigner, Bell inverted parabola, and Skodje-Truhlar +* tunnelling corrections: Wigner, Bell infinite parabola, and Skodje-Truhlar * command line **or** a clean Python API with structured (pandas-ready) results ### Installation @@ -63,7 +63,7 @@ config = Config.from_dict( calc = KIE_Calculation(config, "gs.out", "ts.out", style="gaussian") print(calc) # human-readable table -calc.to_dict() # {name: {uncorrected, wigner, inverted_parabola}} +calc.to_dict() # {name: {uncorrected, wigner, infinite_parabola}} calc.to_csv("kies.csv") # CSV text / file calc.to_dataframe() # pandas DataFrame (needs the pandas extra) calc.results["C1"].wigner # named access to each result @@ -107,7 +107,7 @@ results = batch("demo.config", { "b3lyp": ("b3lyp_gs.out", "b3lyp_ts.out"), "m06": ("m06_gs.out", "m06_ts.out"), }) -results.to_dataframe() # label, name, uncorrected, wigner, inverted_parabola +results.to_dataframe() # label, name, uncorrected, wigner, infinite_parabola results["b3lyp"] # the KIE_Calculation for that pair ``` @@ -133,7 +133,7 @@ To learn how to use *PyQuiver*, please look at the [tutorial](tutorial/TUTORIAL. *PyQuiver* was written by Thayer Anderson and Eugene Kwan. Please email `ekwan16@gmail.com` with any questions. We will gladly try to help you. -We thank Gregor Giesen for writing the code to read ORCA output. We thank Corin Wagen for miscellaneous optimization. We also thank Christoph Riplinger and Giovanni Bistoni for valuable discussions. +We thank Gregor Giesen for writing the code to read ORCA output. We thank Gino Occhialini for the Skodje-Truhlar tunnelling correction. We thank Corin Wagen for miscellaneous optimization. We also thank Christoph Riplinger and Giovanni Bistoni for valuable discussions. ## How to Cite diff --git a/pyquiver/__init__.py b/pyquiver/__init__.py index eea9b07..ea5610e 100644 --- a/pyquiver/__init__.py +++ b/pyquiver/__init__.py @@ -9,7 +9,7 @@ from pyquiver import KIE_Calculation calc = KIE_Calculation("demo.config", "gs.out", "ts.out", style="g09") print(calc) - calc.KIES["C1"].value # (raw, Wigner, inverted-parabola) KIE + calc.KIES["C1"].value # (raw, Wigner, infinite-parabola) KIE """ import logging as _logging diff --git a/pyquiver/kie.py b/pyquiver/kie.py index a82b3ca..b9c41ca 100644 --- a/pyquiver/kie.py +++ b/pyquiver/kie.py @@ -112,7 +112,7 @@ def _build(pair): self.KIES = KIES # retrieves KIEs for autoquiver output - # if report_tunnelling = True, the first number will be the inverted parabola KIE + # if report_tunnelling = True, the first number will be the infinite parabola KIE # and the second number will be the tunnelling correction def get_row(self, report_tunnelling=False): title_row = "" @@ -297,7 +297,7 @@ def make_isotopologues(self): def __str__(self): string = "\n=== PyQuiver Analysis ===\n" if self.eie_flag == 0: - string += "Isotopologue uncorrected Wigner inverted parabola\n" + string += "Isotopologue uncorrected Wigner infinite parabola\n" string += " KIE KIE KIE" else: string += "Isotopologue EIE" @@ -471,7 +471,7 @@ def wigner(ts_imag_heavy, ts_imag_light, temperature): return correction_factor -# calculates the Bell inverted parabola tunneling correction +# calculates the Bell infinite parabola tunneling correction # multiplies the KIE by a factor of (u_H/u_D)*(sin(u_D/2)/sin(u_H/2)) # assumes the frequencies are sorted in ascending order def bell(ts_imag_heavy, ts_imag_light, temperature): diff --git a/pyquiver/results.py b/pyquiver/results.py index 3d10fd0..582ba53 100644 --- a/pyquiver/results.py +++ b/pyquiver/results.py @@ -10,7 +10,7 @@ # named view of a kinetic isotope effect (the three reported columns) KIEResult = namedtuple("KIEResult", - ["name", "uncorrected", "wigner", "inverted_parabola"]) + ["name", "uncorrected", "wigner", "infinite_parabola"]) # named view of an equilibrium isotope effect EIEResult = namedtuple("EIEResult", ["name", "value"]) @@ -44,7 +44,7 @@ def __getitem__(self, name): @property def columns(self): return ["name", "value"] if self.is_eie else \ - ["name", "uncorrected", "wigner", "inverted_parabola"] + ["name", "uncorrected", "wigner", "infinite_parabola"] def to_records(self): """Return a list of dicts, one per isotopologue.""" @@ -56,7 +56,7 @@ def to_dict(self): return {r.name: r.value for r in self._items} return {r.name: {"uncorrected": r.uncorrected, "wigner": r.wigner, - "inverted_parabola": r.inverted_parabola} + "infinite_parabola": r.infinite_parabola} for r in self._items} def to_dataframe(self): diff --git a/pyquiver/tunneling.py b/pyquiver/tunneling.py index 62bad29..fecebb4 100644 --- a/pyquiver/tunneling.py +++ b/pyquiver/tunneling.py @@ -5,7 +5,7 @@ * :func:`wigner` and :func:`bell` (re-exported from :mod:`pyquiver.kie`) need only the imaginary frequencies and the temperature. * :func:`skodje_truhlar` additionally needs the reaction barrier height - (J. Phys. Chem. 1981, 85, 624-626). It is more appropriate than the inverted + (J. Phys. Chem. 1981, 85, 624-626). It is more appropriate than the infinite parabola for very large imaginary frequencies. All three take the imaginary frequencies as negative wavenumbers (cm^-1), the diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index 0250ea7..ac8fd70 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -6,10 +6,10 @@ "id": "661eabb0", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:56.554170Z", - "iopub.status.busy": "2026-06-17T22:19:56.554112Z", - "iopub.status.idle": "2026-06-17T22:19:57.039301Z", - "shell.execute_reply": "2026-06-17T22:19:57.038932Z" + "iopub.execute_input": "2026-06-17T22:34:17.422926Z", + "iopub.status.busy": "2026-06-17T22:34:17.422768Z", + "iopub.status.idle": "2026-06-17T22:34:17.942346Z", + "shell.execute_reply": "2026-06-17T22:34:17.941937Z" } }, "outputs": [], @@ -75,10 +75,10 @@ "id": "68119487", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.040560Z", - "iopub.status.busy": "2026-06-17T22:19:57.040457Z", - "iopub.status.idle": "2026-06-17T22:19:57.045839Z", - "shell.execute_reply": "2026-06-17T22:19:57.045549Z" + "iopub.execute_input": "2026-06-17T22:34:17.943705Z", + "iopub.status.busy": "2026-06-17T22:34:17.943600Z", + "iopub.status.idle": "2026-06-17T22:34:17.949038Z", + "shell.execute_reply": "2026-06-17T22:34:17.948649Z" } }, "outputs": [ @@ -88,7 +88,7 @@ "text": [ "\n", "=== PyQuiver Analysis ===\n", - "Isotopologue uncorrected Wigner inverted parabola\n", + "Isotopologue uncorrected Wigner infinite parabola\n", " KIE KIE KIE\n", "Isotopologue C1 1.0127 1.0144 1.0147 \n", "\n", @@ -113,7 +113,7 @@ "source": [ "## Reading the result\n", "\n", - "Each isotopologue gets three numbers. The **uncorrected** number is the semi-classical KIE in the absence of tunneling. However, since lighter isotopologues will tunnel more easily than heavy ones, the KIE needs a correction factor (>1). The **Wigner** and **Bell** inverted parabola corrections are calculated automatically (most people just take the Bell).\n", + "Each isotopologue gets three numbers. The **uncorrected** number is the semi-classical KIE in the absence of tunneling. However, since lighter isotopologues will tunnel more easily than heavy ones, the KIE needs a correction factor (>1). The **Wigner** and **Bell** infinite parabola corrections are calculated automatically (most people just take the Bell).\n", "\n", "The ¹³C effect at C1 here is a small normal KIE near 1.01, typical of a carbon whose bonding changes at the transition state." ] @@ -124,10 +124,10 @@ "id": "c6c14337", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.046725Z", - "iopub.status.busy": "2026-06-17T22:19:57.046672Z", - "iopub.status.idle": "2026-06-17T22:19:57.053738Z", - "shell.execute_reply": "2026-06-17T22:19:57.053471Z" + "iopub.execute_input": "2026-06-17T22:34:17.949949Z", + "iopub.status.busy": "2026-06-17T22:34:17.949888Z", + "iopub.status.idle": "2026-06-17T22:34:17.956916Z", + "shell.execute_reply": "2026-06-17T22:34:17.956589Z" } }, "outputs": [ @@ -135,7 +135,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "KIEResult(name='C1', uncorrected=1.012746371067466, wigner=1.014443004304243, inverted_parabola=1.014743121186644)\n" + "KIEResult(name='C1', uncorrected=1.012746371067466, wigner=1.014443004304243, infinite_parabola=1.014743121186644)\n" ] }, { @@ -162,7 +162,7 @@ " name\n", " uncorrected\n", " wigner\n", - " inverted_parabola\n", + " infinite_parabola\n", " \n", " \n", " \n", @@ -178,7 +178,7 @@ "" ], "text/plain": [ - " name uncorrected wigner inverted_parabola\n", + " name uncorrected wigner infinite_parabola\n", "0 C1 1.012746 1.014443 1.014743" ] }, @@ -205,10 +205,10 @@ "id": "d1ed9f2f", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.054693Z", - "iopub.status.busy": "2026-06-17T22:19:57.054640Z", - "iopub.status.idle": "2026-06-17T22:19:57.059481Z", - "shell.execute_reply": "2026-06-17T22:19:57.059192Z" + "iopub.execute_input": "2026-06-17T22:34:17.957852Z", + "iopub.status.busy": "2026-06-17T22:34:17.957799Z", + "iopub.status.idle": "2026-06-17T22:34:17.962737Z", + "shell.execute_reply": "2026-06-17T22:34:17.962387Z" } }, "outputs": [ @@ -236,7 +236,7 @@ " name\n", " uncorrected\n", " wigner\n", - " inverted_parabola\n", + " infinite_parabola\n", " \n", " \n", " \n", @@ -266,7 +266,7 @@ "" ], "text/plain": [ - " name uncorrected wigner inverted_parabola\n", + " name uncorrected wigner infinite_parabola\n", "0 C1 1.012746 1.014443 1.014743\n", "1 O3 1.018842 1.020365 1.020635\n", "2 H/D 0.954837 0.956311 0.956572" @@ -306,10 +306,10 @@ "id": "124ea698", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.060311Z", - "iopub.status.busy": "2026-06-17T22:19:57.060249Z", - "iopub.status.idle": "2026-06-17T22:19:57.065555Z", - "shell.execute_reply": "2026-06-17T22:19:57.065208Z" + "iopub.execute_input": "2026-06-17T22:34:17.963667Z", + "iopub.status.busy": "2026-06-17T22:34:17.963612Z", + "iopub.status.idle": "2026-06-17T22:34:17.969132Z", + "shell.execute_reply": "2026-06-17T22:34:17.968807Z" } }, "outputs": [ @@ -348,10 +348,10 @@ "id": "fffea2ea", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.066377Z", - "iopub.status.busy": "2026-06-17T22:19:57.066326Z", - "iopub.status.idle": "2026-06-17T22:19:57.070052Z", - "shell.execute_reply": "2026-06-17T22:19:57.069813Z" + "iopub.execute_input": "2026-06-17T22:34:17.970063Z", + "iopub.status.busy": "2026-06-17T22:34:17.970001Z", + "iopub.status.idle": "2026-06-17T22:34:17.973832Z", + "shell.execute_reply": "2026-06-17T22:34:17.973479Z" } }, "outputs": [ @@ -391,10 +391,10 @@ "id": "9efc070e", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.070975Z", - "iopub.status.busy": "2026-06-17T22:19:57.070925Z", - "iopub.status.idle": "2026-06-17T22:19:57.077727Z", - "shell.execute_reply": "2026-06-17T22:19:57.077449Z" + "iopub.execute_input": "2026-06-17T22:34:17.974694Z", + "iopub.status.busy": "2026-06-17T22:34:17.974636Z", + "iopub.status.idle": "2026-06-17T22:34:17.981122Z", + "shell.execute_reply": "2026-06-17T22:34:17.980809Z" } }, "outputs": [ @@ -423,7 +423,7 @@ " name\n", " uncorrected\n", " wigner\n", - " inverted_parabola\n", + " infinite_parabola\n", " \n", " \n", " \n", @@ -480,7 +480,7 @@ "" ], "text/plain": [ - " label name uncorrected wigner inverted_parabola\n", + " label name uncorrected wigner infinite_parabola\n", "0 b3lyp C1 1.012746 1.014443 1.014743\n", "1 b3lyp O3 1.018842 1.020365 1.020635\n", "2 b3lyp H/D 0.954837 0.956311 0.956572\n", @@ -524,13 +524,15 @@ "source": [ "## Tunnelling corrections\n", "\n", - "Light nuclei can tunnel through the barrier near the transition state, speeding the reaction up. PyQuiver always reports the **uncorrected** KIE plus two corrections, **Wigner** and **Bell** (the inverted parabola); Bell is the default. For heavy-atom KIEs (¹³C, ¹⁷O) the corrections barely change the number, so the choice rarely matters. For hydrogen they can be large, and for a **primary** H/D KIE (the deuterated atom lies in the reaction coordinate) the harmonic treatment is only approximate, so PyQuiver warns and the prediction should be treated with caution.\n", + "Recall that we have the Wigner and Bell infinite parabola tunneling corrections to the transmission coefficients $\\kappa$\n", "\n", - "Writing $u = \\frac{h c\\, \\tilde\\nu}{k_B T}$ for an imaginary mode of magnitude $\\tilde\\nu$ (cm⁻¹), the single-mode coefficients are\n", + "Writing $u = \\frac{h c\\, \\tilde\\nu}{k_B T}$ for an imaginary mode of magnitude $\\tilde\\nu$ (cm⁻¹),\n", + "\n", + "we have for each isotopologue:\n", "\n", "$$\\kappa_{\\text{Wigner}} = 1 + \\frac{u^2}{24}, \\qquad \\kappa_{\\text{Bell}} = \\frac{u/2}{\\sin(u/2)},$$\n", "\n", - "and the factor applied to a KIE is $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$." + "Thus, the tunneling correction factors are $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$." ] }, { @@ -540,7 +542,7 @@ "source": [ "### Skodje-Truhlar\n", "\n", - "When the imaginary frequency is large the inverted parabola is unreliable, and the Skodje-Truhlar correction (*J. Phys. Chem.* **1981**, *85*, 624) is preferable. It needs the barrier height, so you supply the reactant, transition-state, and product energies.\n", + "When the imaginary frequency is large the infinite parabola is unreliable, and the Skodje-Truhlar correction (*J. Phys. Chem.* **1981**, *85*, 624) is preferable. It needs the barrier height, so you supply the reactant, transition-state, and product energies.\n", "\n", "The natural frequency of the imaginary mode (from its force constant $k^{\\ddagger}$ and reduced mass $\\mu^{\\ddagger}$), handled automatically by PyQuiver, is\n", "\n", @@ -569,10 +571,10 @@ "id": "6153ddef", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.078885Z", - "iopub.status.busy": "2026-06-17T22:19:57.078821Z", - "iopub.status.idle": "2026-06-17T22:19:57.084839Z", - "shell.execute_reply": "2026-06-17T22:19:57.084601Z" + "iopub.execute_input": "2026-06-17T22:34:17.982073Z", + "iopub.status.busy": "2026-06-17T22:34:17.982023Z", + "iopub.status.idle": "2026-06-17T22:34:17.987732Z", + "shell.execute_reply": "2026-06-17T22:34:17.987424Z" } }, "outputs": [ @@ -617,10 +619,10 @@ "id": "3dc0f56a", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.085771Z", - "iopub.status.busy": "2026-06-17T22:19:57.085713Z", - "iopub.status.idle": "2026-06-17T22:19:57.087707Z", - "shell.execute_reply": "2026-06-17T22:19:57.087454Z" + "iopub.execute_input": "2026-06-17T22:34:17.988657Z", + "iopub.status.busy": "2026-06-17T22:34:17.988605Z", + "iopub.status.idle": "2026-06-17T22:34:17.990446Z", + "shell.execute_reply": "2026-06-17T22:34:17.990218Z" } }, "outputs": [ @@ -658,10 +660,10 @@ "id": "097d069b", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:19:57.088513Z", - "iopub.status.busy": "2026-06-17T22:19:57.088457Z", - "iopub.status.idle": "2026-06-17T22:19:57.094481Z", - "shell.execute_reply": "2026-06-17T22:19:57.094106Z" + "iopub.execute_input": "2026-06-17T22:34:17.991353Z", + "iopub.status.busy": "2026-06-17T22:34:17.991302Z", + "iopub.status.idle": "2026-06-17T22:34:17.996775Z", + "shell.execute_reply": "2026-06-17T22:34:17.996394Z" } }, "outputs": [ diff --git a/src/AUTOQUIVER.md b/src/AUTOQUIVER.md index 7c3cfca..2abae0b 100644 --- a/src/AUTOQUIVER.md +++ b/src/AUTOQUIVER.md @@ -13,7 +13,7 @@ results = batch("demo.config", { "m06": ("m06_gs.out", "m06_ts.out"), }, style="gaussian") -results.to_dataframe() # label, name, uncorrected, wigner, inverted_parabola +results.to_dataframe() # label, name, uncorrected, wigner, infinite_parabola results.to_csv("kies.csv") results["b3lyp"] # the KIE_Calculation for that pair ``` diff --git a/test/test_kie.py b/test/test_kie.py index 6b4451d..abeb56e 100644 --- a/test/test_kie.py +++ b/test/test_kie.py @@ -27,7 +27,7 @@ def _run(gs=GS, ts=TS): def test_transition_state_is_kie(kie): # a real transition state has an imaginary mode -> KIE (eie_flag 0), and - # each value is a 3-vector (raw, Wigner, inverted parabola) + # each value is a 3-vector (raw, Wigner, infinite parabola) calc = kie() assert calc.eie_flag == 0 assert len(calc.KIES["C1"].value) == 3 diff --git a/test/test_physics.py b/test/test_physics.py index bf36d8c..37d5ef5 100644 --- a/test/test_physics.py +++ b/test/test_physics.py @@ -57,7 +57,7 @@ def test_wigner_rejects_real_frequencies(): wigner(500.0, 500.0, 300.0) -# --- Bell inverted-parabola correction -------------------------------------- +# --- Bell infinite-parabola correction -------------------------------------- def test_bell_unity_for_equal_frequencies(): assert bell(-500.0, -500.0, 300.0) == pytest.approx(1.0) diff --git a/test/test_regression.py b/test/test_regression.py index 21f8940..b8ec754 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -15,7 +15,7 @@ from pyquiver.kie import KIE_Calculation # Each entry: style -> (config, gs, ts files) and the expected -# (raw, Wigner, inverted-parabola) KIE per isotopologue. Non-reference +# (raw, Wigner, infinite-parabola) KIE per isotopologue. Non-reference # isotopologues are already divided by the C5 reference. GOLDEN = { "g09": { diff --git a/test/test_results.py b/test/test_results.py index a4ec8a8..672a78f 100644 --- a/test/test_results.py +++ b/test/test_results.py @@ -21,7 +21,7 @@ def test_named_access(calc): assert isinstance(r, KIEResult) assert r.name == "C1" # the three columns match the positional .value vector - assert (r.uncorrected, r.wigner, r.inverted_parabola) == \ + assert (r.uncorrected, r.wigner, r.infinite_parabola) == \ pytest.approx(tuple(float(v) for v in calc.KIES["C1"].value)) @@ -30,7 +30,7 @@ def test_to_dict_kie(calc): # the reference isotopologue (C5) is excluded, like the printed table assert set(d) == set(calc.KIES) - {"C5"} assert "C5" not in d - assert set(d["C1"]) == {"uncorrected", "wigner", "inverted_parabola"} + assert set(d["C1"]) == {"uncorrected", "wigner", "infinite_parabola"} assert d["C1"]["uncorrected"] == pytest.approx(1.010830, abs=1e-5) @@ -39,7 +39,7 @@ def test_to_csv_roundtrip(calc, tmp_path): text = calc.to_csv(str(path)) assert path.read_text() == text lines = [l for l in text.splitlines() if l.strip()] - assert lines[0] == "name,uncorrected,wigner,inverted_parabola" + assert lines[0] == "name,uncorrected,wigner,infinite_parabola" # header + one data row per reported isotopologue (reference excluded) assert len(lines) == 1 + (len(calc.KIES) - 1) @@ -47,7 +47,7 @@ def test_to_csv_roundtrip(calc, tmp_path): def test_to_dataframe(calc): pandas = pytest.importorskip("pandas") df = calc.to_dataframe() - assert list(df.columns) == ["name", "uncorrected", "wigner", "inverted_parabola"] + assert list(df.columns) == ["name", "uncorrected", "wigner", "infinite_parabola"] assert len(df) == len(calc.KIES) - 1 # reference excluded row = df[df["name"] == "C1"].iloc[0] assert row["wigner"] == pytest.approx(1.012486, abs=1e-5) diff --git a/tutorial/TUTORIAL.md b/tutorial/TUTORIAL.md index 2b0e4b3..4bb067b 100644 --- a/tutorial/TUTORIAL.md +++ b/tutorial/TUTORIAL.md @@ -78,7 +78,7 @@ Imag threshold (cm-1): 50 Isotopologue O3, replacement 1: replace gs atom 3 and ts atom 3 with 17O === PyQuiver Analysis === -Isotopologue uncorrected Wigner inverted parabola +Isotopologue uncorrected Wigner infinite parabola KIE KIE KIE Isotopologue C1 1.0108 1.0125 1.0128 Isotopologue C2 1.0000 1.0000 1.0000 From 8d687d26960eb12f624184308345f8147da7230c Mon Sep 17 00:00:00 2001 From: Eugene Kwan Date: Wed, 17 Jun 2026 18:53:51 -0400 Subject: [PATCH 14/14] Add Wigner/Bell tunnelling references; align docs and strengthen tests - DETAILS.md: add Wigner (1932), Bell (1959 + 1980 book) references alongside the existing Chem. Soc. Rev. review; fix QUIVER citation year (1989) and align the CLI help block with actual `pyquiver -h` - notebook: cite Wigner/Bell in the tunnelling cell; clarify that only Bell is the infinite-parabola model - test_batch: assert the Skodje-Truhlar batch column equals a direct KIE_Calculation.skodje_truhlar call (guards the energy-tuple mapping) Co-Authored-By: Claude Opus 4.8 (1M context) --- DETAILS.md | 15 +++--- scripts/pyquiver.ipynb | 104 ++++++++++++++++++++--------------------- test/test_batch.py | 8 +++- 3 files changed, 67 insertions(+), 60 deletions(-) diff --git a/DETAILS.md b/DETAILS.md index 5eb3d59..0192b7f 100644 --- a/DETAILS.md +++ b/DETAILS.md @@ -22,7 +22,7 @@ Once installed (`pip install pyquiver-kie`), run *PyQuiver* from the command lin pyquiver config_file ground_state_file transition_state_file ``` -For more details, run `pyquiver -h` to display the following help message: +For more details, run `pyquiver -h`. The help message looks like: ``` usage: pyquiver [-h] [-v] [-s STYLE] [-j JOBS] config gs ts @@ -34,13 +34,13 @@ positional arguments: gs ground state file path ts transition state file path -optional arguments: +options: -h, --help show this help message and exit - -v, --verbose print progress/diagnostic logging (repeatable: -vv) + -v, --verbose print debug information (repeatable) -s STYLE, --style STYLE style of input files (gaussian, orca, or pyquiver) - -j JOBS, --jobs JOBS worker threads for the isotopologue computations - (default 1; -1 uses all cores) + -j JOBS, --jobs JOBS number of worker threads for the isotopologue + computations (default 1; -1 uses all cores) ``` This command will calculate the KIEs or EIEs associated with the isotopic substitutions specified in the configuration file. For details, see the tutorial above. (The legacy `python src/quiver.py ...` invocation still works but is deprecated.) @@ -147,11 +147,14 @@ Tunnelling corrections work best for heavy-atom KIEs. H/D KIEs are more challen * Wolfsberg, M. *Acc. Chem. Res.*, **1972**, *5*, 225. * Wolfsberg, M. *et al.* Isotope Effects in the Chemical, Geological, and Bio Sciences 2. **QUIVER:** - * Saunders, M.; Laidig, K.E. Wolfsberg, M. *J. Am. Chem. Soc.*, **1988**, *111*, 8989. + * Saunders, M.; Laidig, K.E.; Wolfsberg, M. *J. Am. Chem. Soc.*, **1989**, *111*, 8989. 3. **Scaling Factors:** * Wong *et al.* *Chem. Phys. Lett.* **1996**, *256*, 391-399. * Radom *et al.* *J Phys. Chem.* **1996**, *100*, 16502. 4. **Tunnelling Corrections:** + * Wigner, E. *Z. Phys. Chem. B* **1932**, *19*, 203. + * Bell, R.P. *Trans. Faraday Soc.* **1959**, *55*, 1. + * Bell, R.P. The Tunnel Effect in Chemistry; Chapman & Hall: London, **1980**. * Bell, R.P. *Chem. Soc. Rev.* **1974**, *3*, 513. * Skodje, R.T.; Truhlar, D.G. *J. Phys. Chem.* **1981**, *85*, 624-626 5. **Claisen Rearrangement KIEs:** diff --git a/scripts/pyquiver.ipynb b/scripts/pyquiver.ipynb index ac8fd70..c0ccb63 100644 --- a/scripts/pyquiver.ipynb +++ b/scripts/pyquiver.ipynb @@ -6,10 +6,10 @@ "id": "661eabb0", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.422926Z", - "iopub.status.busy": "2026-06-17T22:34:17.422768Z", - "iopub.status.idle": "2026-06-17T22:34:17.942346Z", - "shell.execute_reply": "2026-06-17T22:34:17.941937Z" + "iopub.execute_input": "2026-06-17T22:45:16.677358Z", + "iopub.status.busy": "2026-06-17T22:45:16.677190Z", + "iopub.status.idle": "2026-06-17T22:45:17.186789Z", + "shell.execute_reply": "2026-06-17T22:45:17.186358Z" } }, "outputs": [], @@ -75,10 +75,10 @@ "id": "68119487", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.943705Z", - "iopub.status.busy": "2026-06-17T22:34:17.943600Z", - "iopub.status.idle": "2026-06-17T22:34:17.949038Z", - "shell.execute_reply": "2026-06-17T22:34:17.948649Z" + "iopub.execute_input": "2026-06-17T22:45:17.188153Z", + "iopub.status.busy": "2026-06-17T22:45:17.188037Z", + "iopub.status.idle": "2026-06-17T22:45:17.193404Z", + "shell.execute_reply": "2026-06-17T22:45:17.193063Z" } }, "outputs": [ @@ -110,13 +110,7 @@ "cell_type": "markdown", "id": "af85e604", "metadata": {}, - "source": [ - "## Reading the result\n", - "\n", - "Each isotopologue gets three numbers. The **uncorrected** number is the semi-classical KIE in the absence of tunneling. However, since lighter isotopologues will tunnel more easily than heavy ones, the KIE needs a correction factor (>1). The **Wigner** and **Bell** infinite parabola corrections are calculated automatically (most people just take the Bell).\n", - "\n", - "The ¹³C effect at C1 here is a small normal KIE near 1.01, typical of a carbon whose bonding changes at the transition state." - ] + "source": "## Reading the result\n\nEach isotopologue gets three numbers. The **uncorrected** number is the semi-classical KIE in the absence of tunneling. However, since lighter isotopologues will tunnel more easily than heavy ones, the KIE needs a correction factor (>1). The **Wigner** and **Bell** (infinite parabola) corrections are calculated automatically (most people just take the Bell).\n\nThe ¹³C effect at C1 here is a small normal KIE near 1.01, typical of a carbon whose bonding changes at the transition state." }, { "cell_type": "code", @@ -124,10 +118,10 @@ "id": "c6c14337", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.949949Z", - "iopub.status.busy": "2026-06-17T22:34:17.949888Z", - "iopub.status.idle": "2026-06-17T22:34:17.956916Z", - "shell.execute_reply": "2026-06-17T22:34:17.956589Z" + "iopub.execute_input": "2026-06-17T22:45:17.194406Z", + "iopub.status.busy": "2026-06-17T22:45:17.194337Z", + "iopub.status.idle": "2026-06-17T22:45:17.201446Z", + "shell.execute_reply": "2026-06-17T22:45:17.201109Z" } }, "outputs": [ @@ -205,10 +199,10 @@ "id": "d1ed9f2f", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.957852Z", - "iopub.status.busy": "2026-06-17T22:34:17.957799Z", - "iopub.status.idle": "2026-06-17T22:34:17.962737Z", - "shell.execute_reply": "2026-06-17T22:34:17.962387Z" + "iopub.execute_input": "2026-06-17T22:45:17.202330Z", + "iopub.status.busy": "2026-06-17T22:45:17.202276Z", + "iopub.status.idle": "2026-06-17T22:45:17.207139Z", + "shell.execute_reply": "2026-06-17T22:45:17.206814Z" } }, "outputs": [ @@ -306,10 +300,10 @@ "id": "124ea698", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.963667Z", - "iopub.status.busy": "2026-06-17T22:34:17.963612Z", - "iopub.status.idle": "2026-06-17T22:34:17.969132Z", - "shell.execute_reply": "2026-06-17T22:34:17.968807Z" + "iopub.execute_input": "2026-06-17T22:45:17.207989Z", + "iopub.status.busy": "2026-06-17T22:45:17.207938Z", + "iopub.status.idle": "2026-06-17T22:45:17.213327Z", + "shell.execute_reply": "2026-06-17T22:45:17.213000Z" } }, "outputs": [ @@ -348,10 +342,10 @@ "id": "fffea2ea", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.970063Z", - "iopub.status.busy": "2026-06-17T22:34:17.970001Z", - "iopub.status.idle": "2026-06-17T22:34:17.973832Z", - "shell.execute_reply": "2026-06-17T22:34:17.973479Z" + "iopub.execute_input": "2026-06-17T22:45:17.214208Z", + "iopub.status.busy": "2026-06-17T22:45:17.214140Z", + "iopub.status.idle": "2026-06-17T22:45:17.218048Z", + "shell.execute_reply": "2026-06-17T22:45:17.217670Z" } }, "outputs": [ @@ -391,10 +385,10 @@ "id": "9efc070e", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.974694Z", - "iopub.status.busy": "2026-06-17T22:34:17.974636Z", - "iopub.status.idle": "2026-06-17T22:34:17.981122Z", - "shell.execute_reply": "2026-06-17T22:34:17.980809Z" + "iopub.execute_input": "2026-06-17T22:45:17.218906Z", + "iopub.status.busy": "2026-06-17T22:45:17.218857Z", + "iopub.status.idle": "2026-06-17T22:45:17.225199Z", + "shell.execute_reply": "2026-06-17T22:45:17.224909Z" } }, "outputs": [ @@ -532,7 +526,11 @@ "\n", "$$\\kappa_{\\text{Wigner}} = 1 + \\frac{u^2}{24}, \\qquad \\kappa_{\\text{Bell}} = \\frac{u/2}{\\sin(u/2)},$$\n", "\n", - "Thus, the tunneling correction factors are $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$." + "Thus, the tunneling correction factors are $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$.\n", + "\n", + "**Note**: Calculating primary H/D KIEs is likely unreliable. You might want to try POLYRATE instead. (That isn't guaranteed to work perfectly, either, which is why heavy atom KIEs are so nice. They're really much easier to predict and are therefore more diagnostic.)\n", + "\n", + "References: Wigner, E. *Z. Phys. Chem. B* **1932**, *19*, 203. Bell, R. P. *Trans. Faraday Soc.* **1959**, *55*, 1; see also Bell, R. P. *The Tunnel Effect in Chemistry*; Chapman & Hall: London, **1980**." ] }, { @@ -542,7 +540,7 @@ "source": [ "### Skodje-Truhlar\n", "\n", - "When the imaginary frequency is large the infinite parabola is unreliable, and the Skodje-Truhlar correction (*J. Phys. Chem.* **1981**, *85*, 624) is preferable. It needs the barrier height, so you supply the reactant, transition-state, and product energies.\n", + "When the imaginary frequency is large the infinite parabola approximation is unreliable, and the Skodje-Truhlar correction (*J. Phys. Chem.* **1981**, *85*, 624) is preferable. It needs the barrier height, so you supply the reactant, transition-state, and product energies.\n", "\n", "The natural frequency of the imaginary mode (from its force constant $k^{\\ddagger}$ and reduced mass $\\mu^{\\ddagger}$), handled automatically by PyQuiver, is\n", "\n", @@ -562,7 +560,7 @@ "\n", "$$\\kappa = \\frac{\\beta\\pi/\\alpha}{\\sin(\\beta\\pi/\\alpha)}.$$\n", "\n", - "Below the crossover temperature ($\\alpha < \\beta$) the correction grows without bound, so PyQuiver warns there. As with Wigner and Bell, the factor applied to a KIE is $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$." + "Below the crossover temperature ($\\alpha < \\beta$) the correction grows without bound (*PyQuiver* will warn you). As with Wigner and Bell, the factor applied to a KIE is $\\kappa_{\\text{light}}/\\kappa_{\\text{heavy}}$." ] }, { @@ -571,10 +569,10 @@ "id": "6153ddef", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.982073Z", - "iopub.status.busy": "2026-06-17T22:34:17.982023Z", - "iopub.status.idle": "2026-06-17T22:34:17.987732Z", - "shell.execute_reply": "2026-06-17T22:34:17.987424Z" + "iopub.execute_input": "2026-06-17T22:45:17.226351Z", + "iopub.status.busy": "2026-06-17T22:45:17.226297Z", + "iopub.status.idle": "2026-06-17T22:45:17.232169Z", + "shell.execute_reply": "2026-06-17T22:45:17.231869Z" } }, "outputs": [ @@ -610,7 +608,7 @@ "source": [ "## Under the hood: partition function ratios\n", "\n", - "*(Optional.)* A KIE is the ratio of two reduced isotopic partition function ratios (RPFRs), one for the ground state and one for the transition state, times the classical imaginary-frequency ratio and the tunnelling factor. You can compute an RPFR directly from a light and a heavy `Isotopologue`:" + "A KIE is the ratio of two reduced isotopic partition function ratios (RPFRs), one for the ground state and one for the transition state, times the classical imaginary-frequency ratio and the tunnelling factor. You can compute an RPFR directly from a light and a heavy `Isotopologue`:" ] }, { @@ -619,10 +617,10 @@ "id": "3dc0f56a", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.988657Z", - "iopub.status.busy": "2026-06-17T22:34:17.988605Z", - "iopub.status.idle": "2026-06-17T22:34:17.990446Z", - "shell.execute_reply": "2026-06-17T22:34:17.990218Z" + "iopub.execute_input": "2026-06-17T22:45:17.233050Z", + "iopub.status.busy": "2026-06-17T22:45:17.232996Z", + "iopub.status.idle": "2026-06-17T22:45:17.235066Z", + "shell.execute_reply": "2026-06-17T22:45:17.234796Z" } }, "outputs": [ @@ -651,7 +649,7 @@ "id": "c2fda086", "metadata": {}, "source": [ - "Each mode's contribution to the RPFR factors into a frequency ratio, an excitation term, and a zero-point term (their product is the contribution):" + "Each mode's contribution to the RPFR factors into a frequency ratio, an excitation term, and a zero-point term (their product is the contribution). This analysis is useful if you want to figure out which modes are contributing most to the predicted KIE (and why)." ] }, { @@ -660,10 +658,10 @@ "id": "097d069b", "metadata": { "execution": { - "iopub.execute_input": "2026-06-17T22:34:17.991353Z", - "iopub.status.busy": "2026-06-17T22:34:17.991302Z", - "iopub.status.idle": "2026-06-17T22:34:17.996775Z", - "shell.execute_reply": "2026-06-17T22:34:17.996394Z" + "iopub.execute_input": "2026-06-17T22:45:17.235898Z", + "iopub.status.busy": "2026-06-17T22:45:17.235849Z", + "iopub.status.idle": "2026-06-17T22:45:17.241224Z", + "shell.execute_reply": "2026-06-17T22:45:17.240892Z" } }, "outputs": [ @@ -790,4 +788,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/test/test_batch.py b/test/test_batch.py index 9922ae8..46aa170 100644 --- a/test/test_batch.py +++ b/test/test_batch.py @@ -2,7 +2,7 @@ import pytest -from pyquiver import batch +from pyquiver import batch, KIE_Calculation from pyquiver.batch import BatchResults CONFIG = ("gaussian", "claisen_demo.config") @@ -78,3 +78,9 @@ def test_skodje_truhlar_column(files): by_iso = {r["name"]: r for r in results.to_records()} assert "skodje_truhlar" in by_iso["C1"] assert by_iso["C1"]["skodje_truhlar"] is not None + + # the batch energy tuple is (reactant, ts, product); confirm it is mapped + # onto skodje_truhlar(reactant, product, ts) and not silently transposed + direct = KIE_Calculation(cfg, gs, ts).skodje_truhlar( + reactant_energy=0.0, product_energy=0.0, ts_energy=0.02) + assert by_iso["C1"]["skodje_truhlar"] == pytest.approx(direct["C1"])