diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bdb1f61
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
+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: Sync environment (package + dev/pandas extras)
+ run: uv sync --extra dev --extra pandas
+ - name: Run tests with coverage
+ run: |
+ uv run --no-sync coverage run --source=pyquiver -m pytest -q
+ uv run --no-sync 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
+ - name: Build sdist + wheel and validate
+ run: |
+ uv build
+ uvx 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..0192b7f 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`. The help message looks like:
```
-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.
@@ -34,19 +34,29 @@ 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 when the verbose flag is set debug information is
- printed
+ -v, --verbose print debug information (repeatable)
-s STYLE, --style STYLE
- style of input files (g09, orca, or pyquiver)
+ style of input files (gaussian, orca, or pyquiver)
+ -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.
+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
@@ -64,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
@@ -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.)
@@ -118,9 +128,9 @@ 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.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).
@@ -137,12 +147,15 @@ 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.
- * 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 877cd85..c17738f 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,124 @@
# *PyQuiver*
+[](https://github.com/ekwan/PyQuiver/actions/workflows/ci.yml)
+[](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 infinite 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, 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
+```
+
+`System` objects hold a parsed geometry and Hessian, so you can read each file
+once and reuse it, which is 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` with a
+`{label: (gs, ts)}` dictionary and get back one 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, name, uncorrected, wigner, infinite_parabola
+results["b3lyp"] # the KIE_Calculation for that pair
+```
+
+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`,
+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,18 +126,18 @@ 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
*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
-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..1119522
--- /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:_console"
+
+[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..ea5610e
--- /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, infinite-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..00fa579
--- /dev/null
+++ b/pyquiver/batch.py
@@ -0,0 +1,106 @@
+"""Run a KIE/EIE calculation over many ground-state/transition-state pairs.
+
+``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
+
+ 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()
+"""
+
+from collections import OrderedDict
+
+from .config import Config
+from .kie import KIE_Calculation
+
+
+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 ``{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
+ 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 pairs.items():
+ 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..d84df9a
--- /dev/null
+++ b/pyquiver/cli.py
@@ -0,0 +1,61 @@
+"""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
+
+
+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
new file mode 100644
index 0000000..048cbf4
--- /dev/null
+++ b/pyquiver/config.py
@@ -0,0 +1,228 @@
+# 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
+
+ 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
+ normalized_rules.append((int(from_atom), int(to_atom), replacement))
+ normalized[str(name)] = normalized_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!s: >3}\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..b9c41ca
--- /dev/null
+++ b/pyquiver/kie.py
@@ -0,0 +1,485 @@
+# 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
+
+# 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):
+ # 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
+ self._warn_if_primary_hydrogen(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 infinite 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 _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) 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
+ 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 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
+ 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 infinite 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 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):
+ # 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..8ba6ee2
--- /dev/null
+++ b/pyquiver/quiver.py
@@ -0,0 +1,143 @@
+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._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__()
+ 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..582ba53
--- /dev/null
+++ b/pyquiver/results.py
@@ -0,0 +1,81 @@
+"""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", "infinite_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)
+ 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)
+
+ 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", "infinite_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,
+ "infinite_parabola": r.infinite_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..fecebb4
--- /dev/null
+++ b/pyquiver/tunneling.py
@@ -0,0 +1,77 @@
+"""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 infinite
+ 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 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"]
+
+
+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")
+ 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/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/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 97f31eb..c0ccb63 100644
--- a/scripts/pyquiver.ipynb
+++ b/scripts/pyquiver.ipynb
@@ -1,675 +1,777 @@
{
"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,
+ "id": "661eabb0",
"metadata": {
- "collapsed": false
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Read atomic weight data for 32 elements.\n"
- ]
+ "execution": {
+ "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": [],
"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"
+ "import glob\n",
+ "import os\n",
+ "import pandas as pd\n",
+ "import cctk\n",
+ "from IPython.display import display\n",
+ "\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": "348a1d32",
"metadata": {},
"source": [
- "## A Simple Calculation\n",
+ "# PyQuiver Tutorial\n",
"\n",
- "We can reproduce the command-line interface by creating a KIE_Calculation object:"
+ "This notebook explains how to run KIE calculations using *PyQuiver*."
]
},
{
- "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"
- ]
- }
- ],
+ "cell_type": "markdown",
+ "id": "7985a51f-0f3c-4dfa-8e97-75f23c06364b",
+ "metadata": {},
"source": [
- "claisen_calculation = KIE_Calculation(\"../tutorial/gaussian/claisen_demo.config\", \"../tutorial/gaussian/claisen_gs.out\", \"../tutorial/gaussian/claisen_ts.out\", style=\"g09\")"
+ "### 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": [
- "To print the KIEs:"
+ "### 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",
+ "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:"
]
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": 2,
+ "id": "68119487",
"metadata": {
- "collapsed": false
+ "execution": {
+ "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": [
{
"name": "stdout",
"output_type": "stream",
"text": [
- "The claisen_calculation object:\n",
"\n",
"=== PyQuiver Analysis ===\n",
- "Isotopologue uncorrected Wigner inverted parabola\n",
+ "Isotopologue uncorrected Wigner infinite 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",
+ "Isotopologue C1 1.0127 1.0144 1.0147 \n",
"\n",
- "KIEs referenced to isotopologue C5, whose absolute KIEs are:\n",
- "Isotopologue C5 1.0019 1.0019 1.0019 \n"
+ "Absolute KIEs are given.\n"
]
}
],
"source": [
- "print(\"The claisen_calculation object:\")\n",
- "print(claisen_calculation)"
+ "config = Config.from_dict(\n",
+ " isotopologues={\"C1\": [(1, 1, \"13C\")]},\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",
+ "print(calc)"
]
},
{
"cell_type": "markdown",
+ "id": "af85e604",
"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)."
- ]
+ "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",
- "execution_count": 4,
+ "execution_count": 3,
+ "id": "c6c14337",
"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"
- ]
+ "execution": {
+ "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"
}
- ],
- "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"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## `System` objects\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."
- ]
- },
- {
- "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"
+ "KIEResult(name='C1', uncorrected=1.012746371067466, wigner=1.014443004304243, infinite_parabola=1.014743121186644)\n"
]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " name | \n",
+ " uncorrected | \n",
+ " wigner | \n",
+ " infinite_parabola | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " C1 | \n",
+ " 1.012746 | \n",
+ " 1.014443 | \n",
+ " 1.014743 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " name uncorrected wigner infinite_parabola\n",
+ "0 C1 1.012746 1.014443 1.014743"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
}
],
"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])"
+ "print(calc.results[\"C1\"]) # one isotopologue, by name\n",
+ "display(calc.to_dataframe()) # the whole set as a table"
]
},
{
"cell_type": "markdown",
+ "id": "4e90fc24",
"metadata": {},
"source": [
- "### Running calculations with `System` objects\n",
- "\n",
- "To run a KIE calculation with two `System` objects, we simply pass them into the relevant fields of a `KIE_Calculation` object:"
+ "Here's how to make multiple substitutions in the same isotopologue:"
]
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": 4,
+ "id": "d1ed9f2f",
"metadata": {
- "collapsed": false
+ "execution": {
+ "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": [
{
- "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"
- ]
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " name | \n",
+ " uncorrected | \n",
+ " wigner | \n",
+ " infinite_parabola | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " C1 | \n",
+ " 1.012746 | \n",
+ " 1.014443 | \n",
+ " 1.014743 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " O3 | \n",
+ " 1.018842 | \n",
+ " 1.020365 | \n",
+ " 1.020635 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " H/D | \n",
+ " 0.954837 | \n",
+ " 0.956311 | \n",
+ " 0.956572 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " 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"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
}
],
"source": [
- "claisen_calculation2 = KIE_Calculation(\"../tutorial/gaussian/claisen_demo.config\", gs, ts)\n",
- "print(claisen_calculation2)"
+ "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": "a0c9392f",
"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",
+ "## Other input formats\n",
"\n",
- "Let's build the default light ground state `Isotopologue`:"
+ "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."
]
},
{
"cell_type": "code",
- "execution_count": 7,
+ "execution_count": 5,
+ "id": "124ea698",
"metadata": {
- "collapsed": false
+ "execution": {
+ "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": [
{
"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"
+ "Gaussian C1: 1.0127\n",
+ "ORCA C1: 1.0130\n",
+ "native C1: 1.0127\n"
]
}
],
"source": [
- "from quiver import Isotopologue\n",
- "from constants import DEFAULT_MASSES\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",
- "# 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",
- "\n",
- "gs_light = Isotopologue(\"the light ground state\", gs, gs_masses)\n",
- "print(gs_light)"
+ "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": "5d7a7087",
"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:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 8,
- "metadata": {
- "collapsed": false
- },
- "outputs": [],
- "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",
- "\n",
- "gs_heavy = Isotopologue(\"the super heavy ground state\", gs, sub_masses)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Calculating Reduced Isotopic Partition Function Ratios\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",
+ "## Parameter scans\n",
"\n",
- "To print the individual contributions to the RPFR, set `quiver.DEBUG = True` in `quiver.py` and restart the kernel. "
+ "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:"
]
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": 6,
+ "id": "fffea2ea",
"metadata": {
- "collapsed": false
+ "execution": {
+ "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": [
{
"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"
+ "298 K: C1 KIE = 1.0144\n",
+ "350 K: C1 KIE = 1.0134\n",
+ "393 K: C1 KIE = 1.0127\n"
]
}
],
"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)"
+ "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": "ded0c9a4",
"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",
+ "## Many structures at once\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."
+ "`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": 10,
+ "execution_count": 7,
+ "id": "9efc070e",
"metadata": {
- "collapsed": false
+ "execution": {
+ "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": [
{
- "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"
- ]
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " label | \n",
+ " name | \n",
+ " uncorrected | \n",
+ " wigner | \n",
+ " infinite_parabola | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " b3lyp | \n",
+ " C1 | \n",
+ " 1.012746 | \n",
+ " 1.014443 | \n",
+ " 1.014743 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " b3lyp | \n",
+ " O3 | \n",
+ " 1.018842 | \n",
+ " 1.020365 | \n",
+ " 1.020635 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " b3lyp | \n",
+ " H/D | \n",
+ " 0.954837 | \n",
+ " 0.956311 | \n",
+ " 0.956572 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " m06 | \n",
+ " C1 | \n",
+ " 1.012746 | \n",
+ " 1.014443 | \n",
+ " 1.014743 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " m06 | \n",
+ " O3 | \n",
+ " 1.018842 | \n",
+ " 1.020365 | \n",
+ " 1.020635 | \n",
+ "
\n",
+ " \n",
+ " | 5 | \n",
+ " m06 | \n",
+ " H/D | \n",
+ " 0.954837 | \n",
+ " 0.956311 | \n",
+ " 0.956572 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " 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",
+ "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": {},
+ "output_type": "display_data"
}
],
"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)"
+ "# 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",
+ "})\n",
+ "display(results.to_dataframe())"
]
},
{
"cell_type": "markdown",
+ "id": "f493ba64",
"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.)"
- ]
- },
- {
- "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)"
+ "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",
+ "```"
]
},
{
"cell_type": "markdown",
+ "id": "16eb7b85",
"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",
+ "## Tunnelling corrections\n",
+ "\n",
+ "Recall that we have the Wigner and Bell infinite parabola tunneling corrections to the transmission coefficients $\\kappa$\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",
+ "Writing $u = \\frac{h c\\, \\tilde\\nu}{k_B T}$ for an imaginary mode of magnitude $\\tilde\\nu$ (cm⁻¹),\n",
"\n",
- "```\n",
- "config.config\n",
+ "we have for each isotopologue:\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",
+ "$$\\kappa_{\\text{Wigner}} = 1 + \\frac{u^2}{24}, \\qquad \\kappa_{\\text{Bell}} = \\frac{u/2}{\\sin(u/2)},$$\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."
+ "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**."
]
},
{
"cell_type": "markdown",
+ "id": "1cd23132",
"metadata": {},
"source": [
- "### The Arguments\n",
- "The function `autoquiver` (which is the only object available in the `autoquiver` module) requires the following arguments:\n",
+ "### Skodje-Truhlar\n",
+ "\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",
- "* `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",
+ "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",
- "In the README, we discussed the command:\n",
+ "$$\\nu^{\\ddagger} = \\frac{1}{2\\pi}\\sqrt{\\frac{k^{\\ddagger}}{\\mu^{\\ddagger}}}.$$\n",
"\n",
- "```\n",
- "python src/autoquiver.py -e .output auto/ auto/substitutions.config gs ts -\n",
- "```\n",
+ "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",
- "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",
+ "For a small $\\nu^{\\ddagger}$ / high $T$ ($\\alpha > \\beta$):\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}.$$\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",
+ "For a large $\\nu^{\\ddagger}$ / low $T$ ($\\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",
+ "$$\\kappa = \\frac{\\beta}{\\beta-\\alpha}\\left(e^{(\\beta-\\alpha)V} - 1\\right).$$\n",
"\n",
- "```\n",
- "ground_state1.out\n",
- "gs1_rotomer.out\n",
- "GROUNDSTATE2.out\n",
- "gs3.out\n",
- "```\n",
+ "And at the crossover $\\alpha = \\beta$:\n",
"\n",
- "We can accomplish this with the following Python function:"
+ "$$\\kappa = \\frac{\\beta\\pi/\\alpha}{\\sin(\\beta\\pi/\\alpha)}.$$\n",
+ "\n",
+ "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}}$."
]
},
{
"cell_type": "code",
- "execution_count": 12,
+ "execution_count": 8,
+ "id": "6153ddef",
"metadata": {
- "collapsed": false
+ "execution": {
+ "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": [],
+ "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": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
"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"
+ "def energy(path):\n",
+ " ensemble = cctk.GaussianFile.read_file(path).ensemble\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",
+ "# 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": "122d7a20",
"metadata": {},
"source": [
- "Let's test this on all the filenames in the example:"
+ "## Under the hood: partition function ratios\n",
+ "\n",
+ "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": 13,
+ "execution_count": 9,
+ "id": "3dc0f56a",
"metadata": {
- "collapsed": false
+ "execution": {
+ "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": [
{
"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"
+ "ground-state RPFR = 1.0872\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",
- "\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))"
- ]
- },
- {
- "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`:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 14,
- "metadata": {
- "collapsed": true
- },
- "outputs": [],
- "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, heavy_freqs, light_freqs = calculate_rpfr((light, heavy), 50.0, 0.9614, 393)\n",
+ "print(f\"ground-state RPFR = {rpfr:.4f}\")"
]
},
{
"cell_type": "markdown",
+ "id": "c2fda086",
"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."
+ "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)."
]
},
{
"cell_type": "code",
- "execution_count": 15,
+ "execution_count": 10,
+ "id": "097d069b",
"metadata": {
- "collapsed": false
+ "execution": {
+ "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": [
{
- "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"
- ]
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " light_freq | \n",
+ " heavy_freq | \n",
+ " freq_ratio | \n",
+ " excitation | \n",
+ " ZPE | \n",
+ " contribution | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 67.790652 | \n",
+ " 67.225890 | \n",
+ " 0.991669 | \n",
+ " 1.007402 | \n",
+ " 1.001034 | \n",
+ " 1.000043 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 159.689475 | \n",
+ " 159.684161 | \n",
+ " 0.999967 | \n",
+ " 1.000024 | \n",
+ " 1.000010 | \n",
+ " 1.000001 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 179.501554 | \n",
+ " 178.499592 | \n",
+ " 0.994418 | \n",
+ " 1.003970 | \n",
+ " 1.001836 | \n",
+ " 1.000199 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " 246.525660 | \n",
+ " 246.070898 | \n",
+ " 0.998155 | \n",
+ " 1.001138 | \n",
+ " 1.000833 | \n",
+ " 1.000123 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " 276.476300 | \n",
+ " 273.262636 | \n",
+ " 0.988376 | \n",
+ " 1.006802 | \n",
+ " 1.005900 | \n",
+ " 1.000971 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " 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": {},
+ "output_type": "display_data"
}
],
"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",
- "\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
- },
- "outputs": [],
- "source": [
- "# missing example of how to actually use autoquiver"
+ "components = partition_components(heavy_freqs, light_freqs, 393)\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())"
]
}
],
"metadata": {
"kernelspec": {
- "display_name": "python_3_7",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
- "name": "python_3_7"
+ "name": "python3"
},
"language_info": {
"codemirror_mode": {
@@ -681,9 +783,9 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.7.1"
+ "version": "3.13.5"
}
},
"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..2abae0b 100644
--- a/src/AUTOQUIVER.md
+++ b/src/AUTOQUIVER.md
@@ -1,61 +1,53 @@
-### `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` 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.
-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, infinite_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.
+## Building the pairs
-To accomplish this we run `autoquiver.py` as follows:
+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 src/autoquiver.py -e .output auto/ auto/substitutions.config gs ts -
-```
+```python
+import glob
+from pyquiver import batch
-The arguments are:
+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)
+```
-* `-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.
+Adjust the `label`/`replace` rule to match your own naming.
-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.
+## Skodje-Truhlar in batch
-For more information, the output of `python autoquiver.py -h` has been reproduced below:
+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..46aa170
--- /dev/null
+++ b/test/test_batch.py
@@ -0,0 +1,86 @@
+"""Tests for the batch runner (pyquiver.batch)."""
+
+import pytest
+
+from pyquiver import batch, KIE_Calculation
+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
+ assert [label for label, _ in results.items()] == ["a", "b"] # order preserved
+
+
+def test_bad_pair_value_rejected(files):
+ cfg, gs, ts = files
+ with pytest.raises(ValueError):
+ batch(cfg, {"a": (gs,)}) # value must be a (gs, ts) pair
+
+
+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
+
+ # 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"])
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..42badf1
--- /dev/null
+++ b/test/test_config.py
@@ -0,0 +1,251 @@
+"""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_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
+ 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..4b6622b
--- /dev/null
+++ b/test/test_edge_cases.py
@@ -0,0 +1,249 @@
+"""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))
+ # 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"]
+
+
+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_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])
+ 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..abeb56e
--- /dev/null
+++ b/test/test_kie.py
@@ -0,0 +1,113 @@
+"""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")
+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, infinite 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_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 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]
+ 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;
+ # 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..37d5ef5
--- /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 infinite-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..b8ec754
--- /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, infinite-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..672a78f
--- /dev/null
+++ b/test/test_results.py
@@ -0,0 +1,64 @@
+"""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.infinite_parabola) == \
+ pytest.approx(tuple(float(v) for v in calc.KIES["C1"].value))
+
+
+def test_to_dict_kie(calc):
+ d = calc.to_dict()
+ # 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", "infinite_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,infinite_parabola"
+ # 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", "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)
+
+
+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..8118851
--- /dev/null
+++ b/test/test_tunneling.py
@@ -0,0 +1,131 @@
+"""Tests for the Skodje-Truhlar tunnelling correction (pyquiver.tunneling and
+the KIE_Calculation.skodje_truhlar method)."""
+
+import logging
+
+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_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
+ 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_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
+ 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..4bb067b 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
@@ -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. 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 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
@@ -80,14 +78,14 @@ 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.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
@@ -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" },
+]